@hydra-acp/cli 0.1.6 → 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) {
@@ -32,6 +45,9 @@ var init_paths = __esm({
32
45
  paths = {
33
46
  home: hydraHome,
34
47
  config: () => path.join(hydraHome(), "config.json"),
48
+ // Auth token lives in its own file so config.json can be version-
49
+ // controlled without leaking the secret. Raw string contents, mode 0600.
50
+ authToken: () => path.join(hydraHome(), "auth-token"),
35
51
  pidFile: () => path.join(hydraHome(), "daemon.pid"),
36
52
  logFile: () => path.join(hydraHome(), "daemon.log"),
37
53
  currentLogFile: () => path.join(hydraHome(), "current.log"),
@@ -42,6 +58,18 @@ var init_paths = __esm({
42
58
  // machine's binaries cleanly separated. `ls agents/` immediately
43
59
  // shows which platforms have ever installed anything.
44
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
+ ),
45
73
  sessionsDir: () => path.join(hydraHome(), "sessions"),
46
74
  // One directory per session id under sessions/. Co-locates the
47
75
  // session record, its transcript, and any future per-session state
@@ -68,61 +96,98 @@ function extensionList(config) {
68
96
  ...body
69
97
  }));
70
98
  }
71
- async function loadConfig() {
72
- const configPath = paths.config();
99
+ async function readConfigFile() {
73
100
  let raw;
74
101
  try {
75
- raw = await fs.readFile(configPath, "utf8");
102
+ raw = await fs.readFile(paths.config(), "utf8");
76
103
  } catch (err) {
77
104
  const e = err;
78
105
  if (e.code === "ENOENT") {
79
- throw new Error(
80
- `No config found at ${configPath}. Run \`hydra-acp init\` to create one.`
81
- );
106
+ return {};
82
107
  }
83
108
  throw err;
84
109
  }
85
- const parsed = JSON.parse(raw);
86
- return HydraConfig.parse(parsed);
110
+ return JSON.parse(raw);
87
111
  }
88
- async function ensureConfig() {
112
+ async function loadAuthToken() {
113
+ let tokenFile;
89
114
  try {
90
- await fs.access(paths.config());
115
+ const text = await fs.readFile(paths.authToken(), "utf8");
116
+ const trimmed = text.trim();
117
+ if (trimmed.length > 0) {
118
+ tokenFile = trimmed;
119
+ }
91
120
  } catch (err) {
92
121
  const e = err;
93
122
  if (e.code !== "ENOENT") {
94
123
  throw err;
95
124
  }
96
- const config = await writeMinimalInitConfig();
97
- process.stderr.write(
98
- `hydra-acp: initialized ${paths.config()} with a fresh auth token.
99
- `
125
+ }
126
+ const raw = await readConfigFile();
127
+ const daemon = raw.daemon;
128
+ const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
129
+ if (tokenFile && legacy) {
130
+ throw new Error(
131
+ `Auth token present in both ${paths.authToken()} and ${paths.config()} (daemon.authToken). Remove daemon.authToken from config.json to resolve.`
100
132
  );
101
- return config;
102
133
  }
103
- return loadConfig();
134
+ if (tokenFile) {
135
+ return tokenFile;
136
+ }
137
+ if (legacy) {
138
+ await migrateLegacyAuthToken(raw, daemon, legacy);
139
+ return legacy;
140
+ }
141
+ return void 0;
104
142
  }
105
- async function writeMinimalInitConfig(authToken) {
106
- const token = authToken ?? generateAuthToken();
107
- const minimal = { daemon: { authToken: token } };
108
- await fs.mkdir(paths.home(), { recursive: true });
109
- await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
143
+ async function migrateLegacyAuthToken(raw, daemon, token) {
144
+ await writeAuthToken(token);
145
+ delete daemon.authToken;
146
+ if (Object.keys(daemon).length === 0) {
147
+ delete raw.daemon;
148
+ }
149
+ await fs.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
110
150
  encoding: "utf8",
111
151
  mode: 384
112
152
  });
113
- return HydraConfig.parse(minimal);
114
- }
115
- async function updateConfigField(mutate) {
116
- const path7 = paths.config();
117
- const text = await fs.readFile(path7, "utf8");
118
- const raw = JSON.parse(text);
119
- mutate(raw);
120
- HydraConfig.parse(raw);
121
- await fs.writeFile(path7, JSON.stringify(raw, null, 2) + "\n", {
153
+ process.stderr.write(
154
+ `hydra-acp: migrated auth token from ${paths.config()} to ${paths.authToken()}.
155
+ `
156
+ );
157
+ }
158
+ async function writeAuthToken(token) {
159
+ await fs.mkdir(paths.home(), { recursive: true });
160
+ await fs.writeFile(paths.authToken(), token + "\n", {
122
161
  encoding: "utf8",
123
162
  mode: 384
124
163
  });
125
164
  }
165
+ async function loadConfig() {
166
+ const token = await loadAuthToken();
167
+ if (!token) {
168
+ throw new Error(
169
+ `No auth token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
170
+ );
171
+ }
172
+ const raw = await readConfigFile();
173
+ const daemon = raw.daemon ??= {};
174
+ daemon.authToken = token;
175
+ return HydraConfig.parse(raw);
176
+ }
177
+ async function loadConfigReadOnly() {
178
+ return HydraConfigReadOnly.parse(await readConfigFile());
179
+ }
180
+ async function ensureConfig() {
181
+ if (!await loadAuthToken()) {
182
+ const token = generateAuthToken();
183
+ await writeAuthToken(token);
184
+ process.stderr.write(
185
+ `hydra-acp: initialized ${paths.authToken()} with a fresh auth token.
186
+ `
187
+ );
188
+ }
189
+ return loadConfig();
190
+ }
126
191
  function generateAuthToken() {
127
192
  const bytes = new Uint8Array(32);
128
193
  crypto.getRandomValues(bytes);
@@ -144,7 +209,7 @@ function expandHome(p) {
144
209
  }
145
210
  return p;
146
211
  }
147
- var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig;
212
+ var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig, HydraConfigReadOnly;
148
213
  var init_config = __esm({
149
214
  "src/core/config.ts"() {
150
215
  "use strict";
@@ -160,7 +225,16 @@ var init_config = __esm({
160
225
  authToken: z.string().min(16),
161
226
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
162
227
  tls: TlsConfig.optional(),
163
- 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)
164
238
  });
165
239
  RegistryConfig = z.object({
166
240
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -177,7 +251,20 @@ var init_config = __esm({
177
251
  // Cap on logical lines retained in the in-memory scrollback render
178
252
  // buffer. Oldest lines are dropped on overflow. The on-disk session
179
253
  // history is unaffected; this only bounds the TUI's local view buffer.
180
- maxScrollbackLines: z.number().int().positive().default(1e4)
254
+ maxScrollbackLines: z.number().int().positive().default(1e4),
255
+ // When true (default), the TUI captures mouse events so the wheel can
256
+ // drive scrollback. The cost: terminals route clicks to the app, so
257
+ // text selection requires shift+drag to bypass mouse reporting. Set
258
+ // false to disable capture — wheel scrollback stops working, but
259
+ // plain click-drag selects text via the terminal emulator.
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)
181
268
  });
182
269
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
183
270
  ExtensionBody = z.object({
@@ -210,7 +297,16 @@ var init_config = __esm({
210
297
  // recency and truncated to this count. `--all` overrides in the CLI.
211
298
  sessionListColdLimit: z.number().int().nonnegative().default(20),
212
299
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
213
- tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
300
+ tui: TuiConfig.default({
301
+ repaintThrottleMs: 1e3,
302
+ maxScrollbackLines: 1e4,
303
+ mouse: true,
304
+ logMaxBytes: 5 * 1024 * 1024,
305
+ cwdColumnMaxWidth: 24
306
+ })
307
+ });
308
+ HydraConfigReadOnly = HydraConfig.extend({
309
+ daemon: DaemonConfig.omit({ authToken: true })
214
310
  });
215
311
  }
216
312
  });
@@ -285,10 +381,11 @@ function extractHydraMeta(meta) {
285
381
  function mergeMeta(passthrough, ours) {
286
382
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
287
383
  }
288
- 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;
289
385
  var init_types = __esm({
290
386
  "src/acp/types.ts"() {
291
387
  "use strict";
388
+ ACP_PROTOCOL_VERSION = 1;
292
389
  JsonRpcErrorCodes = {
293
390
  ParseError: -32700,
294
391
  InvalidRequest: -32600,
@@ -395,7 +492,7 @@ var init_connection = __esm({
395
492
  "src/acp/connection.ts"() {
396
493
  "use strict";
397
494
  init_types();
398
- JsonRpcConnection = class {
495
+ JsonRpcConnection = class _JsonRpcConnection {
399
496
  constructor(stream) {
400
497
  this.stream = stream;
401
498
  this.stream.onMessage((m) => this.handleIncoming(m));
@@ -405,6 +502,16 @@ var init_connection = __esm({
405
502
  requestHandlers = /* @__PURE__ */ new Map();
406
503
  defaultRequestHandler;
407
504
  notificationHandlers = /* @__PURE__ */ new Map();
505
+ // Notifications received before a handler was registered. Some agents
506
+ // (e.g. claude-acp) advertise their command list in the same chunk as
507
+ // the `session/new` response, which is processed before the consumer
508
+ // can attach its `session/update` handler. Without this buffer those
509
+ // notifications would be silently dropped, so e.g. `/model` would
510
+ // never appear in the TUI's slash-completion palette. Capped per
511
+ // method to keep the buffer from growing unboundedly when nothing
512
+ // ever subscribes.
513
+ bufferedNotifications = /* @__PURE__ */ new Map();
514
+ static MAX_BUFFERED_PER_METHOD = 64;
408
515
  pending = /* @__PURE__ */ new Map();
409
516
  closed = false;
410
517
  closeHandlers = [];
@@ -416,6 +523,17 @@ var init_connection = __esm({
416
523
  }
417
524
  onNotification(method, handler) {
418
525
  this.notificationHandlers.set(method, handler);
526
+ const queued = this.bufferedNotifications.get(method);
527
+ if (!queued) {
528
+ return;
529
+ }
530
+ this.bufferedNotifications.delete(method);
531
+ for (const note of queued) {
532
+ try {
533
+ handler(note.params, note.method);
534
+ } catch {
535
+ }
536
+ }
419
537
  }
420
538
  onClose(handler) {
421
539
  this.closeHandlers.push(handler);
@@ -460,6 +578,13 @@ var init_connection = __esm({
460
578
  }
461
579
  await this.stream.close();
462
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
+ }
463
588
  handleIncoming(message) {
464
589
  if ("method" in message) {
465
590
  if ("id" in message && message.id !== void 0) {
@@ -501,6 +626,16 @@ var init_connection = __esm({
501
626
  const handler = this.notificationHandlers.get(note.method);
502
627
  if (handler) {
503
628
  handler(note.params, note.method);
629
+ return;
630
+ }
631
+ let queued = this.bufferedNotifications.get(note.method);
632
+ if (!queued) {
633
+ queued = [];
634
+ this.bufferedNotifications.set(note.method, queued);
635
+ }
636
+ queued.push(note);
637
+ if (queued.length > _JsonRpcConnection.MAX_BUFFERED_PER_METHOD) {
638
+ queued.shift();
504
639
  }
505
640
  }
506
641
  handleResponse(res) {
@@ -557,12 +692,12 @@ var init_hydra_commands = __esm({
557
692
  HYDRA_COMMANDS = [
558
693
  {
559
694
  verb: "title",
560
- name: "/hydra title",
695
+ name: "hydra title",
561
696
  description: "Regenerate the session title via the agent (or set manually with an arg)"
562
697
  },
563
698
  {
564
699
  verb: "agent",
565
- name: "/hydra agent",
700
+ name: "hydra agent",
566
701
  argsHint: "<agent>",
567
702
  description: "Swap the agent backing this session, preserving context"
568
703
  }
@@ -661,7 +796,7 @@ function firstLine(text, max) {
661
796
  }
662
797
  return void 0;
663
798
  }
664
- 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;
665
800
  var init_session = __esm({
666
801
  "src/core/session.ts"() {
667
802
  "use strict";
@@ -670,8 +805,7 @@ var init_session = __esm({
670
805
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
671
806
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
672
807
  HYDRA_SESSION_PREFIX = "hydra_session_";
673
- MAX_HISTORY_ENTRIES = 1e3;
674
- COMPACT_EVERY = 200;
808
+ DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
675
809
  Session = class {
676
810
  sessionId;
677
811
  cwd;
@@ -713,11 +847,13 @@ var init_session = __esm({
713
847
  // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
714
848
  // Drives the mid-turn elapsed counter delivered to fresh attachers.
715
849
  promptStartedAt;
716
- // Counts appends since the last compaction. When it hits COMPACT_EVERY
850
+ // Counts appends since the last compaction. When it hits compactEvery
717
851
  // we ask the history store to trim the file to the most recent
718
- // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
852
+ // historyMaxEntries. Keeps file growth bounded without per-append
719
853
  // file-size checks.
720
854
  appendCount = 0;
855
+ historyMaxEntries;
856
+ compactEvery;
721
857
  // Permission requests that have been broadcast to one or more
722
858
  // clients but have not yet resolved. Replayed to clients that
723
859
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -774,6 +910,8 @@ var init_session = __esm({
774
910
  this.firstPromptSeeded = true;
775
911
  }
776
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));
777
915
  this.updatedAt = Date.now();
778
916
  this.createdAt = init.createdAt ?? this.updatedAt;
779
917
  this.lastRecordedAt = this.updatedAt;
@@ -1612,9 +1750,9 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1612
1750
  if (this.historyStore) {
1613
1751
  const store = this.historyStore;
1614
1752
  void store.append(this.sessionId, entry).catch(() => void 0);
1615
- if (this.appendCount >= COMPACT_EVERY) {
1753
+ if (this.appendCount >= this.compactEvery) {
1616
1754
  this.appendCount = 0;
1617
- void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1755
+ void store.compact(this.sessionId, this.historyMaxEntries).catch(
1618
1756
  () => void 0
1619
1757
  );
1620
1758
  }
@@ -1731,9 +1869,187 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1731
1869
  }
1732
1870
  });
1733
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
+
1734
2050
  // src/tui/history.ts
1735
2051
  import { promises as fs7 } from "fs";
1736
- import * as path4 from "path";
2052
+ import * as path5 from "path";
1737
2053
  async function loadHistory(file) {
1738
2054
  let text;
1739
2055
  try {
@@ -1777,7 +2093,7 @@ function appendEntry(history, entry) {
1777
2093
  return out;
1778
2094
  }
1779
2095
  async function saveHistory(file, history) {
1780
- await fs7.mkdir(path4.dirname(file), { recursive: true });
2096
+ await fs7.mkdir(path5.dirname(file), { recursive: true });
1781
2097
  const lines = history.map((entry) => JSON.stringify(entry));
1782
2098
  await fs7.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1783
2099
  }
@@ -1789,6 +2105,113 @@ var init_history = __esm({
1789
2105
  }
1790
2106
  });
1791
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
+
1792
2215
  // src/acp/ws-stream.ts
1793
2216
  function wsToMessageStream(ws) {
1794
2217
  const messageHandlers = [];
@@ -1867,7 +2290,7 @@ var init_ws_stream = __esm({
1867
2290
  });
1868
2291
 
1869
2292
  // src/core/daemon-bootstrap.ts
1870
- import { spawn as spawn4 } from "child_process";
2293
+ import { spawn as spawn5 } from "child_process";
1871
2294
  import { setTimeout as sleep } from "timers/promises";
1872
2295
  async function ensureDaemonReachable(config) {
1873
2296
  if (await pingHealth(config)) {
@@ -1894,7 +2317,7 @@ function spawnDaemonDetached() {
1894
2317
  if (!cliPath) {
1895
2318
  throw new Error("Cannot determine hydra-acp binary path to spawn daemon");
1896
2319
  }
1897
- const child = spawn4(
2320
+ const child = spawn5(
1898
2321
  process.execPath,
1899
2322
  [cliPath, "daemon", "start", "--foreground"],
1900
2323
  {
@@ -1942,8 +2365,8 @@ function formatAgentWithModel(agentId, model) {
1942
2365
  }
1943
2366
  return `${agent}${AGENT_MODEL_SEP}${short}`;
1944
2367
  }
1945
- function formatAgentCell(agentId, model, usage) {
1946
- const base = formatAgentWithModel(agentId, model);
2368
+ function formatAgentCell(agentId, usage) {
2369
+ const base = agentId ?? "?";
1947
2370
  if (!usage || typeof usage.costAmount !== "number") {
1948
2371
  return base;
1949
2372
  }
@@ -1980,10 +2403,10 @@ function toRow(s, now = Date.now()) {
1980
2403
  session: stripHydraSessionPrefix(s.sessionId),
1981
2404
  upstream: s.upstreamSessionId ?? "-",
1982
2405
  state: formatState(s.status, s.attachedClients),
1983
- agent: formatAgentCell(s.agentId, s.currentModel, s.currentUsage),
2406
+ agent: formatAgentCell(s.agentId, s.currentUsage),
1984
2407
  age: formatRelativeAge(s.updatedAt, now),
1985
2408
  title: s.title ?? "-",
1986
- cwd: s.cwd
2409
+ cwd: shortenHomePath(s.cwd)
1987
2410
  };
1988
2411
  }
1989
2412
  function formatState(status, clients) {
@@ -1999,6 +2422,7 @@ function computeWidths(rows) {
1999
2422
  state: maxLen(HEADER.state, rows.map((r) => r.state)),
2000
2423
  agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
2001
2424
  age: maxLen(HEADER.age, rows.map((r) => r.age)),
2425
+ cwd: maxLen(HEADER.cwd, rows.map((r) => r.cwd)),
2002
2426
  title: maxLen(HEADER.title, rows.map((r) => r.title))
2003
2427
  };
2004
2428
  }
@@ -2047,7 +2471,7 @@ function maxLen(headerCell, values) {
2047
2471
  }
2048
2472
  return max;
2049
2473
  }
2050
- function formatRow(r, w, maxWidth) {
2474
+ function formatRow(r, w, maxWidth, cwdMaxWidth = DEFAULT_CWD_MAX_WIDTH) {
2051
2475
  const fixed = [
2052
2476
  r.session.padEnd(w.session),
2053
2477
  r.upstream.padEnd(w.upstream),
@@ -2056,20 +2480,18 @@ function formatRow(r, w, maxWidth) {
2056
2480
  r.age.padStart(w.age)
2057
2481
  ].join(SEP);
2058
2482
  if (maxWidth === void 0) {
2059
- return [fixed, r.title.padEnd(w.title), r.cwd].join(SEP);
2483
+ return [fixed, r.cwd.padEnd(w.cwd), r.title].join(SEP);
2060
2484
  }
2061
- const titleCap = Math.min(w.title, TITLE_MAX_WIDTH);
2062
2485
  const budget = maxWidth - fixed.length - SEP.length;
2063
2486
  if (budget <= 0) {
2064
2487
  return fixed.slice(0, maxWidth);
2065
2488
  }
2066
- const titleNatural = Math.min(r.title.length, titleCap);
2067
- let titleAlloc = titleNatural + SEP.length + MIN_CWD <= budget ? titleCap : Math.max(0, budget - SEP.length - MIN_CWD);
2068
- titleAlloc = Math.min(titleAlloc, Math.max(0, budget - SEP.length - 1));
2069
- const titleCell = truncateRight(r.title, titleAlloc).padEnd(titleAlloc);
2070
- const cwdBudget = Math.max(0, budget - titleAlloc - SEP.length);
2071
- const cwdCell = truncateMiddle(r.cwd, cwdBudget);
2072
- 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);
2073
2495
  }
2074
2496
  function truncateRight(s, max) {
2075
2497
  if (max <= 0) {
@@ -2097,11 +2519,12 @@ function truncateMiddle(s, max) {
2097
2519
  const tail = max - 1 - head;
2098
2520
  return s.slice(0, head) + "\u2026" + s.slice(s.length - tail);
2099
2521
  }
2100
- var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
2522
+ var HEADER, SEP, DEFAULT_CWD_MAX_WIDTH;
2101
2523
  var init_session_row = __esm({
2102
2524
  "src/cli/session-row.ts"() {
2103
2525
  "use strict";
2104
2526
  init_agent_display();
2527
+ init_paths();
2105
2528
  init_session();
2106
2529
  HEADER = {
2107
2530
  session: "SESSION",
@@ -2113,14 +2536,13 @@ var init_session_row = __esm({
2113
2536
  cwd: "CWD"
2114
2537
  };
2115
2538
  SEP = " ";
2116
- MIN_CWD = 8;
2117
- TITLE_MAX_WIDTH = 40;
2539
+ DEFAULT_CWD_MAX_WIDTH = 24;
2118
2540
  }
2119
2541
  });
2120
2542
 
2121
2543
  // src/cli/commands/sessions.ts
2122
- import * as fs12 from "fs/promises";
2123
- import * as path6 from "path";
2544
+ import * as fs13 from "fs/promises";
2545
+ import * as path8 from "path";
2124
2546
  async function runSessionsList(opts = {}) {
2125
2547
  const config = await loadConfig();
2126
2548
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -2159,9 +2581,10 @@ async function runSessionsList(opts = {}) {
2159
2581
  const rows = visible.map((s) => toRow(s, now));
2160
2582
  const widths = computeWidths(rows);
2161
2583
  const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
2162
- process.stdout.write(formatRow(HEADER, widths, maxWidth) + "\n");
2584
+ const cwdMax = config.tui.cwdColumnMaxWidth;
2585
+ process.stdout.write(formatRow(HEADER, widths, maxWidth, cwdMax) + "\n");
2163
2586
  for (const r of rows) {
2164
- process.stdout.write(formatRow(r, widths, maxWidth) + "\n");
2587
+ process.stdout.write(formatRow(r, widths, maxWidth, cwdMax) + "\n");
2165
2588
  }
2166
2589
  if (truncated > 0) {
2167
2590
  process.stdout.write(
@@ -2190,9 +2613,9 @@ async function runSessionsKill(id) {
2190
2613
  process.stdout.write(`Killed ${id}
2191
2614
  `);
2192
2615
  }
2193
- async function runSessionsRm(id) {
2616
+ async function runSessionsRemove(id) {
2194
2617
  if (!id) {
2195
- process.stderr.write("Usage: hydra-acp sessions rm <session-id>\n");
2618
+ process.stderr.write("Usage: hydra-acp sessions remove <session-id>\n");
2196
2619
  process.exit(2);
2197
2620
  }
2198
2621
  const config = await loadConfig();
@@ -2239,23 +2662,40 @@ async function runSessionsExport(id, outPath) {
2239
2662
  return;
2240
2663
  }
2241
2664
  const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
2242
- await fs12.mkdir(path6.dirname(path6.resolve(resolved)), { recursive: true });
2243
- 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 });
2244
2667
  process.stdout.write(`Wrote ${resolved}
2245
2668
  `);
2246
2669
  }
2247
2670
  async function runSessionsImport(file, opts = {}) {
2248
2671
  if (!file) {
2249
2672
  process.stderr.write(
2250
- "Usage: hydra-acp sessions import <file>|- [--replace]\n"
2673
+ "Usage: hydra-acp sessions import <file>|- [--replace] [--cwd <path>] [--info]\n"
2251
2674
  );
2252
2675
  process.exit(2);
2253
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
+ }
2254
2694
  let body;
2255
2695
  if (file === "-") {
2256
2696
  body = await readStdin();
2257
2697
  } else {
2258
- body = await fs12.readFile(file, "utf8");
2698
+ body = await fs13.readFile(file, "utf8");
2259
2699
  }
2260
2700
  let bundle;
2261
2701
  try {
@@ -2265,6 +2705,11 @@ async function runSessionsImport(file, opts = {}) {
2265
2705
  `);
2266
2706
  process.exit(1);
2267
2707
  }
2708
+ if (opts.info === true) {
2709
+ const inspectConfig = await loadConfigReadOnly();
2710
+ printBundleInfo(bundle, inspectConfig.tui.cwdColumnMaxWidth);
2711
+ return;
2712
+ }
2268
2713
  const config = await loadConfig();
2269
2714
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2270
2715
  const response = await fetch(`${baseUrl}/v1/sessions/import`, {
@@ -2273,7 +2718,11 @@ async function runSessionsImport(file, opts = {}) {
2273
2718
  "Content-Type": "application/json",
2274
2719
  Authorization: `Bearer ${config.daemon.authToken}`
2275
2720
  },
2276
- 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
+ })
2277
2726
  });
2278
2727
  if (response.status === 409) {
2279
2728
  const detail = await response.json().catch(() => ({}));
@@ -2296,6 +2745,42 @@ async function runSessionsImport(file, opts = {}) {
2296
2745
  `
2297
2746
  );
2298
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
+ }
2299
2784
  async function readStdin() {
2300
2785
  const chunks = [];
2301
2786
  for await (const chunk of process.stdin) {
@@ -2322,6 +2807,7 @@ var init_sessions = __esm({
2322
2807
  "src/cli/commands/sessions.ts"() {
2323
2808
  "use strict";
2324
2809
  init_config();
2810
+ init_bundle();
2325
2811
  init_session_row();
2326
2812
  }
2327
2813
  });
@@ -2656,12 +3142,15 @@ async function pickSession(term, opts) {
2656
3142
  return b.updatedAt.localeCompare(a.updatedAt);
2657
3143
  });
2658
3144
  };
2659
- let visible = sortSessions(opts.sessions);
3145
+ let allSessions = sortSessions(opts.sessions);
3146
+ let visible = allSessions;
2660
3147
  let rows = visible.map((s) => toRow(s, Date.now()));
2661
3148
  let widths = computeWidths(rows);
2662
3149
  let total = 1 + visible.length;
2663
3150
  let selectedIdx = 0;
2664
3151
  let scrollOffset = 0;
3152
+ let searchActive = false;
3153
+ let searchTerm = "";
2665
3154
  let mode = "normal";
2666
3155
  let pendingAction = null;
2667
3156
  let transientStatus = null;
@@ -2672,6 +3161,7 @@ async function pickSession(term, opts) {
2672
3161
  let headerLine = "";
2673
3162
  let sessionLines = [];
2674
3163
  let startRow = 1;
3164
+ const cwdMaxWidth = opts.config.tui.cwdColumnMaxWidth;
2675
3165
  const computeLayout = () => {
2676
3166
  termHeight = readTermHeight(term);
2677
3167
  termWidth = readTermWidth(term);
@@ -2679,8 +3169,8 @@ async function pickSession(term, opts) {
2679
3169
  viewportSize = Math.min(visible.length, maxViewportRows);
2680
3170
  const rowMaxWidth = Math.max(10, termWidth - ROW_PREFIX_WIDTH);
2681
3171
  newSessionLabel = formatNewSessionLabel(opts.cwd, rowMaxWidth);
2682
- headerLine = formatRow(HEADER, widths, rowMaxWidth);
2683
- 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));
2684
3174
  };
2685
3175
  const rebuildRows = () => {
2686
3176
  rows = visible.map((s) => toRow(s, Date.now()));
@@ -2688,6 +3178,24 @@ async function pickSession(term, opts) {
2688
3178
  total = 1 + visible.length;
2689
3179
  computeLayout();
2690
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
+ };
2691
3199
  const adjustScroll = () => {
2692
3200
  if (selectedIdx === 0) {
2693
3201
  return;
@@ -2750,6 +3258,13 @@ async function pickSession(term, opts) {
2750
3258
  term.dim.noFormat(` ${transientStatus}`);
2751
3259
  return;
2752
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
+ }
2753
3268
  term.dim.noFormat(formatIndicator());
2754
3269
  };
2755
3270
  const indicatorRow = () => startRow + 3 + viewportSize;
@@ -2816,8 +3331,8 @@ async function pickSession(term, opts) {
2816
3331
  const refresh = async (preferredId) => {
2817
3332
  try {
2818
3333
  const next = await listSessions(opts.config);
2819
- visible = sortSessions(next);
2820
- rebuildRows();
3334
+ allSessions = sortSessions(next);
3335
+ applyFilter();
2821
3336
  if (preferredId !== void 0) {
2822
3337
  const idx = visible.findIndex((s) => s.sessionId === preferredId);
2823
3338
  if (idx >= 0) {
@@ -2914,7 +3429,37 @@ async function pickSession(term, opts) {
2914
3429
  return;
2915
3430
  }
2916
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
+ }
2917
3455
  if (data?.isCharacter) {
3456
+ if (name === "/") {
3457
+ searchActive = true;
3458
+ searchTerm = "";
3459
+ applyFilter();
3460
+ renderFromScratch();
3461
+ return;
3462
+ }
2918
3463
  if (name === "r" || name === "R") {
2919
3464
  const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
2920
3465
  void refresh(currentId);
@@ -3019,13 +3564,34 @@ function readTermWidth(term) {
3019
3564
  function formatNewSessionLabel(cwd, maxWidth) {
3020
3565
  const prefix = "+ New session in ";
3021
3566
  const budget = Math.max(1, maxWidth - prefix.length);
3022
- 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;
3023
3588
  }
3024
3589
  var ROW_PREFIX_WIDTH;
3025
3590
  var init_picker = __esm({
3026
3591
  "src/tui/picker.ts"() {
3027
3592
  "use strict";
3028
3593
  init_session_row();
3594
+ init_paths();
3029
3595
  init_session();
3030
3596
  init_discovery();
3031
3597
  ROW_PREFIX_WIDTH = 2;
@@ -3033,14 +3599,14 @@ var init_picker = __esm({
3033
3599
  });
3034
3600
 
3035
3601
  // src/tui/screen.ts
3036
- import os3 from "os";
3037
3602
  import stringWidth from "string-width";
3038
3603
  import wrapAnsi from "wrap-ansi";
3039
- function formattedLineSig(zone, width, line) {
3604
+ function formattedLineSig(zone, width, line, highlight2 = null, activeCol = null) {
3605
+ const active = activeCol === null ? "" : `a${activeCol}`;
3040
3606
  if (!line) {
3041
- return `${zone}|${width}|empty`;
3607
+ return `${zone}|${width}|empty|${highlight2 ?? ""}|${active}`;
3042
3608
  }
3043
- 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}`;
3044
3610
  }
3045
3611
  function computePromptVisualRows(buffer, room) {
3046
3612
  const rows = [];
@@ -3052,12 +3618,27 @@ function computePromptVisualRows(buffer, room) {
3052
3618
  }
3053
3619
  let pos = 0;
3054
3620
  while (pos < line.length) {
3055
- const end = Math.min(line.length, pos + room);
3056
- rows.push({ bufferIdx: i, startCol: pos, endCol: end });
3057
- pos = end;
3058
- }
3059
- }
3060
- if (rows.length === 0) {
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;
3639
+ }
3640
+ }
3641
+ if (rows.length === 0) {
3061
3642
  rows.push({ bufferIdx: 0, startCol: 0, endCol: 0 });
3062
3643
  }
3063
3644
  return rows;
@@ -3102,6 +3683,34 @@ function computePromptLayout(visualRows, state, maxRows) {
3102
3683
  }
3103
3684
  return { cursorVisualRow, cursorVisualCol, windowStart, rendered };
3104
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
+ }
3105
3714
  function writeStyled(term, text, style) {
3106
3715
  if (text.length === 0) {
3107
3716
  return;
@@ -3164,6 +3773,12 @@ function writeStyled(term, text, style) {
3164
3773
  case "heading-3":
3165
3774
  term.bold.noFormat(text);
3166
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;
3167
3782
  default:
3168
3783
  term.noFormat(text);
3169
3784
  }
@@ -3177,17 +3792,80 @@ function wrapAnsiBody(text, width) {
3177
3792
  }
3178
3793
  return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
3179
3794
  }
3180
- 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 = {}) {
3181
3858
  if (width <= 0) {
3182
3859
  return [text];
3183
3860
  }
3184
3861
  if (text.length === 0) {
3185
3862
  return [""];
3186
3863
  }
3187
- if (!NON_ASCII.test(text)) {
3864
+ const stripMarkup = opts.stripMarkup === true && hasTkMarkup(text);
3865
+ if (!stripMarkup && !NON_ASCII.test(text)) {
3188
3866
  return wrapAscii(text, width);
3189
3867
  }
3190
- return wrapVisible(text, width);
3868
+ return wrapVisible(text, width, stripMarkup);
3191
3869
  }
3192
3870
  function wrapAscii(text, width) {
3193
3871
  const out = [];
@@ -3212,32 +3890,33 @@ function wrapAscii(text, width) {
3212
3890
  out.push(remaining);
3213
3891
  return out;
3214
3892
  }
3215
- function wrapVisible(text, width) {
3893
+ function wrapVisible(text, width, stripMarkup) {
3216
3894
  const out = [];
3217
- const graphemes = [];
3218
- for (const { segment } of SEGMENTER.segment(text)) {
3219
- graphemes.push({ seg: segment, w: stringWidth(segment) });
3220
- }
3895
+ const segments = stripMarkup ? [...segmentForWidth(text)] : graphemeSegments(text);
3221
3896
  let i = 0;
3222
- while (i < graphemes.length) {
3897
+ while (i < segments.length) {
3223
3898
  let chunk = "";
3224
3899
  let chunkW = 0;
3225
3900
  let lastSpaceI = -1;
3226
3901
  let chunkAtLastSpace = "";
3227
- while (i < graphemes.length) {
3228
- const g = graphemes[i];
3229
- 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
+ }
3230
3909
  break;
3231
3910
  }
3232
- if (g.seg === " ") {
3911
+ if (s.text === " " && s.width === 1) {
3233
3912
  lastSpaceI = i;
3234
3913
  chunkAtLastSpace = chunk;
3235
3914
  }
3236
- chunk += g.seg;
3237
- chunkW += g.w;
3915
+ chunk += s.text;
3916
+ chunkW += s.width;
3238
3917
  i += 1;
3239
3918
  }
3240
- if (i >= graphemes.length) {
3919
+ if (i >= segments.length) {
3241
3920
  out.push(chunk);
3242
3921
  break;
3243
3922
  }
@@ -3245,7 +3924,7 @@ function wrapVisible(text, width) {
3245
3924
  out.push(chunkAtLastSpace);
3246
3925
  i = lastSpaceI + 1;
3247
3926
  } else if (chunk.length === 0) {
3248
- out.push(graphemes[i].seg);
3927
+ out.push(segments[i].text);
3249
3928
  i += 1;
3250
3929
  } else {
3251
3930
  out.push(chunk);
@@ -3253,34 +3932,43 @@ function wrapVisible(text, width) {
3253
3932
  }
3254
3933
  return out;
3255
3934
  }
3256
- function shortenHomePath(p) {
3257
- const home = os3.homedir();
3258
- if (!home) {
3259
- return p;
3260
- }
3261
- if (p === home) {
3262
- return "~";
3263
- }
3264
- if (p.startsWith(home + "/")) {
3265
- 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) });
3266
3939
  }
3267
- return p;
3940
+ return out;
3268
3941
  }
3269
- function truncate(text, max) {
3942
+ function truncate(text, max, opts = {}) {
3270
3943
  if (max <= 0) {
3271
3944
  return "";
3272
3945
  }
3273
- 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)) {
3274
3948
  return text;
3275
3949
  }
3276
- 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
+ }
3277
3965
  if (visible <= max) {
3278
3966
  return text;
3279
3967
  }
3280
3968
  if (max <= 1) {
3281
- return takeByWidth(text, max);
3969
+ return takeFromSegments(segments, max);
3282
3970
  }
3283
- return takeByWidth(text, max - 1) + "\u2026";
3971
+ return takeFromSegments(segments, max - 1) + "\u2026";
3284
3972
  }
3285
3973
  function takeByWidth(text, budget) {
3286
3974
  if (budget <= 0) {
@@ -3298,6 +3986,21 @@ function takeByWidth(text, budget) {
3298
3986
  }
3299
3987
  return out;
3300
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;
4001
+ }
4002
+ return out;
4003
+ }
3301
4004
  function firstLine2(text) {
3302
4005
  const idx = text.indexOf("\n");
3303
4006
  return idx === -1 ? text : `${text.slice(0, idx)} \u21B5`;
@@ -3394,6 +4097,10 @@ function mapKeyName(name) {
3394
4097
  return "ctrl-o";
3395
4098
  case "CTRL_P":
3396
4099
  return "ctrl-p";
4100
+ case "CTRL_R":
4101
+ return "ctrl-r";
4102
+ case "CTRL_S":
4103
+ return "ctrl-s";
3397
4104
  case "CTRL_U":
3398
4105
  return "ctrl-u";
3399
4106
  case "CTRL_W":
@@ -3406,11 +4113,12 @@ function mapKeyName(name) {
3406
4113
  return null;
3407
4114
  }
3408
4115
  }
3409
- 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;
3410
4117
  var init_screen = __esm({
3411
4118
  "src/tui/screen.ts"() {
3412
4119
  "use strict";
3413
4120
  init_agent_display();
4121
+ init_paths();
3414
4122
  init_session();
3415
4123
  HEADER_ROWS = 2;
3416
4124
  BANNER_ROWS = 1;
@@ -3437,6 +4145,7 @@ var init_screen = __esm({
3437
4145
  streamingActive = false;
3438
4146
  lastPromptRows = 0;
3439
4147
  queuedTexts = [];
4148
+ lastQueueEditingIndex = -1;
3440
4149
  repaintPaused = 0;
3441
4150
  repaintPending = false;
3442
4151
  lastRepaintAt = 0;
@@ -3453,6 +4162,12 @@ var init_screen = __esm({
3453
4162
  lineIds = /* @__PURE__ */ new WeakMap();
3454
4163
  wrapCache = /* @__PURE__ */ new Map();
3455
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();
3456
4171
  // Per-row signature of what was painted to each terminal row on the
3457
4172
  // previous repaint. drawX methods funnel through paintRow(), which
3458
4173
  // skips the moveTo+eraseLineAfter+write sequence when the new
@@ -3470,10 +4185,30 @@ var init_screen = __esm({
3470
4185
  // above the bottom. Mouse wheel and PgUp/PgDn adjust this; new content
3471
4186
  // pushes the view down naturally when at 0.
3472
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;
3473
4208
  banner = {
3474
4209
  status: "ready",
3475
4210
  planMode: false,
3476
- 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",
3477
4212
  queued: 0
3478
4213
  };
3479
4214
  header = { agent: "?", cwd: "?", sessionId: "?" };
@@ -3493,12 +4228,14 @@ var init_screen = __esm({
3493
4228
  pasteActive = false;
3494
4229
  pasteBuffer = "";
3495
4230
  rawStdinHandler;
4231
+ mouseEnabled;
3496
4232
  constructor(opts) {
3497
4233
  this.term = opts.term;
3498
4234
  this.dispatcher = opts.dispatcher;
3499
4235
  this.onKey = opts.onKey;
3500
4236
  this.contentRepaintThrottleMs = opts.repaintThrottleMs ?? DEFAULT_CONTENT_REPAINT_THROTTLE_MS;
3501
4237
  this.maxScrollbackLines = opts.maxScrollbackLines ?? DEFAULT_MAX_SCROLLBACK_LINES;
4238
+ this.mouseEnabled = opts.mouse ?? true;
3502
4239
  this.resizeHandler = () => this.repaint();
3503
4240
  this.keyHandler = (name, _matches, data) => this.handleKey(name, data);
3504
4241
  this.mouseHandler = (name) => this.handleMouse(name);
@@ -3515,10 +4252,16 @@ var init_screen = __esm({
3515
4252
  this.lastFrameH = 0;
3516
4253
  this.lastWindowTitle = null;
3517
4254
  process.stdout.write("\x1B[?7l");
3518
- this.term.grabInput({ mouse: "button" });
4255
+ if (this.mouseEnabled) {
4256
+ this.term.grabInput({ mouse: "button" });
4257
+ } else {
4258
+ this.term.grabInput(true);
4259
+ }
3519
4260
  this.term.hideCursor(false);
3520
4261
  this.term.on("key", this.keyHandler);
3521
- this.term.on("mouse", this.mouseHandler);
4262
+ if (this.mouseEnabled) {
4263
+ this.term.on("mouse", this.mouseHandler);
4264
+ }
3522
4265
  this.term.on("resize", this.resizeHandler);
3523
4266
  this.installBracketedPaste();
3524
4267
  this.repaint();
@@ -3528,9 +4271,15 @@ var init_screen = __esm({
3528
4271
  return;
3529
4272
  }
3530
4273
  this.started = false;
4274
+ if (this.bannerNotificationTimer) {
4275
+ clearTimeout(this.bannerNotificationTimer);
4276
+ this.bannerNotificationTimer = null;
4277
+ }
3531
4278
  this.uninstallBracketedPaste();
3532
4279
  this.term.off("key", this.keyHandler);
3533
- this.term.off("mouse", this.mouseHandler);
4280
+ if (this.mouseEnabled) {
4281
+ this.term.off("mouse", this.mouseHandler);
4282
+ }
3534
4283
  this.term.off("resize", this.resizeHandler);
3535
4284
  this.term.grabInput(false);
3536
4285
  this.term.hideCursor(false);
@@ -3800,6 +4549,58 @@ var init_screen = __esm({
3800
4549
  this.drawBanner();
3801
4550
  this.placeCursor();
3802
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
+ }
3803
4604
  clearScrollback() {
3804
4605
  this.lines = [];
3805
4606
  this.keyedBlocks.clear();
@@ -3880,6 +4681,7 @@ var init_screen = __esm({
3880
4681
  }
3881
4682
  setQueuedPrompts(texts) {
3882
4683
  this.queuedTexts = [...texts];
4684
+ this.lastQueueEditingIndex = this.dispatcher.state().queueIndex;
3883
4685
  this.repaint();
3884
4686
  }
3885
4687
  // While a permission prompt is active, the prompt area is replaced with
@@ -3932,12 +4734,19 @@ var init_screen = __esm({
3932
4734
  // row count changed (alt+enter added a line, backspace joined two), the
3933
4735
  // separator and scrollback bottom shift, so we need a full repaint;
3934
4736
  // otherwise an in-place prompt redraw is enough. (Queued-zone changes
3935
- // already trigger a full repaint via setQueuedPrompts.)
4737
+ // already trigger a full repaint via setQueuedPrompts.) Queue-edit
4738
+ // navigation may also change which queued row is marked, so check
4739
+ // for that and redraw just that zone in-place.
3936
4740
  refreshPrompt() {
3937
4741
  if (this.promptRows() !== this.lastPromptRows) {
3938
4742
  this.repaint();
3939
4743
  return;
3940
4744
  }
4745
+ const editingIndex = this.dispatcher.state().queueIndex;
4746
+ if (editingIndex !== this.lastQueueEditingIndex) {
4747
+ this.lastQueueEditingIndex = editingIndex;
4748
+ this.drawQueuedZone();
4749
+ }
3941
4750
  this.drawPrompt();
3942
4751
  this.placeCursor();
3943
4752
  }
@@ -3973,6 +4782,9 @@ var init_screen = __esm({
3973
4782
  if (delta === 0) {
3974
4783
  return;
3975
4784
  }
4785
+ if (this.scrollbackSearch !== null) {
4786
+ this.acceptScrollbackSearch();
4787
+ }
3976
4788
  const max = this.maxScrollOffset();
3977
4789
  const next = Math.min(max, Math.max(0, this.scrollOffset + delta));
3978
4790
  if (next === this.scrollOffset) {
@@ -3982,12 +4794,241 @@ var init_screen = __esm({
3982
4794
  this.repaint();
3983
4795
  }
3984
4796
  scrollToBottom() {
4797
+ if (this.scrollbackSearch !== null) {
4798
+ this.acceptScrollbackSearch();
4799
+ }
3985
4800
  if (this.scrollOffset === 0) {
3986
4801
  return;
3987
4802
  }
3988
4803
  this.scrollOffset = 0;
3989
4804
  this.repaint();
3990
4805
  }
4806
+ scrollToTop() {
4807
+ if (this.scrollbackSearch !== null) {
4808
+ this.acceptScrollbackSearch();
4809
+ }
4810
+ const max = this.maxScrollOffset();
4811
+ if (this.scrollOffset === max) {
4812
+ return;
4813
+ }
4814
+ this.scrollOffset = max;
4815
+ this.repaint();
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
+ }
3991
5032
  scrollPageSize() {
3992
5033
  return Math.max(1, this.scrollbackVisibleRows() - 2);
3993
5034
  }
@@ -4110,8 +5151,8 @@ var init_screen = __esm({
4110
5151
  }
4111
5152
  if (usage) {
4112
5153
  const col = Math.max(1, w - usage.length + 1);
4113
- this.term.moveTo(col, 1);
4114
- this.term.dim(usage);
5154
+ this.term.moveTo(col, 1).eraseLineAfter();
5155
+ this.term.dim.noFormat(usage);
4115
5156
  }
4116
5157
  });
4117
5158
  }
@@ -4142,14 +5183,23 @@ var init_screen = __esm({
4142
5183
  const start = Math.max(0, end - visibleRows);
4143
5184
  const slice = wrapped.slice(start, end);
4144
5185
  const padTop = Math.max(0, visibleRows - slice.length);
5186
+ const matchInfo = this.currentMatchInfo();
5187
+ const activeLength = matchInfo?.length ?? 0;
4145
5188
  for (let i = 0; i < visibleRows; i++) {
4146
5189
  const row = top + i;
4147
5190
  const sliceIdx = i - padTop;
4148
5191
  const line = sliceIdx >= 0 ? slice[sliceIdx] : void 0;
4149
- 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
+ );
4150
5200
  this.paintRow(row, sig, () => {
4151
5201
  if (line) {
4152
- this.writeFormattedLine(line, w);
5202
+ this.writeFormattedLine(line, w, activeCol, activeLength);
4153
5203
  }
4154
5204
  });
4155
5205
  }
@@ -4215,19 +5265,26 @@ var init_screen = __esm({
4215
5265
  const separatorRow = this.term.height - promptRows - BANNER_ROWS;
4216
5266
  const queuedBottom = separatorRow - 1;
4217
5267
  const queuedTop = queuedBottom - rows + 1;
5268
+ const editingIndex = this.dispatcher.state().queueIndex;
4218
5269
  for (let i = 0; i < rows; i++) {
4219
5270
  const row = queuedTop + i;
4220
5271
  const text = this.queuedTexts[i];
4221
5272
  const isLast = i === rows - 1 && this.queuedTexts.length > MAX_QUEUED_ROWS;
4222
5273
  const overflow = this.queuedTexts.length - MAX_QUEUED_ROWS;
4223
5274
  const summary = text === void 0 ? "" : isLast ? `+ ${overflow + 1} more queued` : truncate(firstLine2(text), w - 4);
4224
- const sig = text === void 0 ? `queued|${w}|empty` : `queued|${w}|${isLast ? "ovf" : "row"}|${summary}`;
5275
+ const editing = !isLast && i === editingIndex;
5276
+ const sig = text === void 0 ? `queued|${w}|empty` : `queued|${w}|${editing ? "edit" : isLast ? "ovf" : "row"}|${summary}`;
4225
5277
  this.paintRow(row, sig, () => {
4226
5278
  if (text === void 0) {
4227
5279
  return;
4228
5280
  }
4229
- const display = ` \u23F3 ${summary}`;
4230
- const padded = display + " ".repeat(Math.max(0, w - display.length));
5281
+ const rest = `\u23F3 ${summary}`;
5282
+ const padded = rest + " ".repeat(Math.max(0, w - 1 - rest.length));
5283
+ if (editing) {
5284
+ this.term.bgBlue.brightYellow("\u25B8");
5285
+ } else {
5286
+ this.term.bgBlue(" ");
5287
+ }
4231
5288
  this.term.bgBlue.brightWhite.noFormat(padded);
4232
5289
  });
4233
5290
  }
@@ -4342,7 +5399,9 @@ var init_screen = __esm({
4342
5399
  const row = this.term.height;
4343
5400
  const w = this.term.width;
4344
5401
  const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
4345
- 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;
4346
5405
  this.paintRow(row, sig, () => {
4347
5406
  const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
4348
5407
  const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
@@ -4369,6 +5428,16 @@ var init_screen = __esm({
4369
5428
  this.term.dim(planLabel);
4370
5429
  }
4371
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
+ }
4372
5441
  });
4373
5442
  }
4374
5443
  placeCursor() {
@@ -4384,6 +5453,11 @@ var init_screen = __esm({
4384
5453
  this.term.moveTo(2, top2);
4385
5454
  return;
4386
5455
  }
5456
+ if (this.scrollbackSearch) {
5457
+ this.term.hideCursor(true);
5458
+ return;
5459
+ }
5460
+ this.term.hideCursor(false);
4387
5461
  const w = this.term.width;
4388
5462
  const room = Math.max(1, w - 2);
4389
5463
  const state = this.dispatcher.state();
@@ -4470,8 +5544,10 @@ var init_screen = __esm({
4470
5544
  }
4471
5545
  const prefix = line.prefix ?? "";
4472
5546
  const room = Math.max(1, width - prefix.length);
4473
- 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 });
4474
5549
  const wrapped = [];
5550
+ let scanPos = 0;
4475
5551
  for (let i = 0; i < chunks.length; i++) {
4476
5552
  const chunk = chunks[i] ?? "";
4477
5553
  const wrappedLine = {
@@ -4490,6 +5566,15 @@ var init_screen = __esm({
4490
5566
  if (line.ansi) {
4491
5567
  wrappedLine.ansi = true;
4492
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
+ }
4493
5578
  wrapped.push(wrappedLine);
4494
5579
  }
4495
5580
  if (id !== void 0) {
@@ -4497,13 +5582,25 @@ var init_screen = __esm({
4497
5582
  }
4498
5583
  return wrapped;
4499
5584
  }
4500
- writeFormattedLine(line, width) {
5585
+ writeFormattedLine(line, width, activeMatchCol = null, activeMatchLength = 0) {
4501
5586
  if (line.prefix) {
4502
5587
  writeStyled(this.term, line.prefix, line.prefixStyle ?? line.bodyStyle);
4503
5588
  }
4504
5589
  const remaining = Math.max(0, width - (line.prefix?.length ?? 0));
4505
- const bodyText = line.ansi ? line.body : truncate(line.body, remaining);
4506
- 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
+ }
4507
5604
  if (line.fillRow) {
4508
5605
  const visible = line.ansi ? stringWidth(bodyText) : bodyText.length;
4509
5606
  const pad = remaining - visible;
@@ -4518,6 +5615,7 @@ var init_screen = __esm({
4518
5615
  };
4519
5616
  NON_ASCII = /[^\x20-\x7e]/;
4520
5617
  SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
5618
+ TK_MARKUP_STYLE_CHAR = /[a-zA-Z+\-:_!#/]/;
4521
5619
  shortId = stripHydraSessionPrefix;
4522
5620
  }
4523
5621
  });
@@ -4533,8 +5631,25 @@ var init_input = __esm({
4533
5631
  col = 0;
4534
5632
  planMode = false;
4535
5633
  historyIndex = -1;
5634
+ // Queue editing: when the user walks Up past row 0 with queued prompts
5635
+ // present, the most-recently-queued item lands in the buffer and
5636
+ // queueIndex tracks which slot of `queue` is being edited. Enter submits
5637
+ // the edit (queue-edit) or, on an empty buffer, drops the slot
5638
+ // (queue-remove). -1 means not editing a queue slot.
5639
+ queueIndex = -1;
4536
5640
  savedDraft = null;
4537
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;
5650
+ // Waiting queue snapshot (excludes the in-flight head). Newest item lives
5651
+ // at the end so Up walks the array right-to-left.
5652
+ queue = [];
4538
5653
  turnRunning = false;
4539
5654
  // Single-slot kill ring. The most recent killed text (^U, ^K, ^W) lands
4540
5655
  // here so ^Y can yank it back. Standard readline keeps a stack; we
@@ -4550,7 +5665,9 @@ var init_input = __esm({
4550
5665
  row: this.row,
4551
5666
  col: this.col,
4552
5667
  planMode: this.planMode,
4553
- historyIndex: this.historyIndex
5668
+ historyIndex: this.historyIndex,
5669
+ queueIndex: this.queueIndex,
5670
+ historySearchQuery: this.historySearch?.query ?? null
4554
5671
  };
4555
5672
  }
4556
5673
  setTurnRunning(running) {
@@ -4560,6 +5677,17 @@ var init_input = __esm({
4560
5677
  this.history = [...history];
4561
5678
  this.historyIndex = -1;
4562
5679
  this.savedDraft = null;
5680
+ this.historySearch = null;
5681
+ }
5682
+ // Snapshot of the waiting queue (head excluded). Called by the app after
5683
+ // every queue mutation so Up/Down can walk a fresh view. queueIndex is
5684
+ // only invalidated when it falls outside the new bounds — staying in
5685
+ // bounds preserves the user's edit if the queue grew or stayed put.
5686
+ setQueue(queue) {
5687
+ this.queue = [...queue];
5688
+ if (this.queueIndex >= this.queue.length) {
5689
+ this.queueIndex = -1;
5690
+ }
4563
5691
  }
4564
5692
  // Replace the contents of the first row, leaving subsequent rows alone.
4565
5693
  // Used by slash-command completion: the partial /foo gets swapped for the
@@ -4570,7 +5698,52 @@ var init_input = __esm({
4570
5698
  this.col = text.length;
4571
5699
  }
4572
5700
  }
5701
+ // Public seed for the buffer (used for Escape pre-fill). Treated like a
5702
+ // fresh draft: nav state and any saved draft are cleared, cursor lands
5703
+ // at the end so the user can edit immediately.
5704
+ setBuffer(text) {
5705
+ this.loadEntry(text);
5706
+ this.historyIndex = -1;
5707
+ this.queueIndex = -1;
5708
+ this.savedDraft = null;
5709
+ this.historySearch = null;
5710
+ }
4573
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
+ }
4574
5747
  if (event.type === "char") {
4575
5748
  this.insertChar(event.ch);
4576
5749
  return [];
@@ -4607,14 +5780,16 @@ var init_input = __esm({
4607
5780
  case "right":
4608
5781
  this.moveRight();
4609
5782
  return [];
4610
- case "home":
4611
5783
  case "ctrl-a":
4612
5784
  this.col = 0;
4613
5785
  return [];
4614
- case "end":
4615
5786
  case "ctrl-e":
4616
5787
  this.col = this.currentLine().length;
4617
5788
  return [];
5789
+ case "home":
5790
+ return this.handleHome();
5791
+ case "end":
5792
+ return this.handleEnd();
4618
5793
  case "ctrl-b":
4619
5794
  this.moveLeft();
4620
5795
  return [];
@@ -4637,11 +5812,19 @@ var init_input = __esm({
4637
5812
  case "ctrl-c":
4638
5813
  return this.handleCtrlC();
4639
5814
  case "ctrl-d":
4640
- return this.bufferIsEmpty() ? [{ type: "exit" }] : [];
5815
+ if (this.bufferIsEmpty()) {
5816
+ return [{ type: "exit" }];
5817
+ }
5818
+ this.deleteForward();
5819
+ return [];
4641
5820
  case "ctrl-l":
4642
5821
  return [{ type: "redraw" }];
4643
5822
  case "ctrl-p":
4644
5823
  return [{ type: "switch-session" }];
5824
+ case "ctrl-r":
5825
+ return this.startHistorySearch();
5826
+ case "ctrl-s":
5827
+ return [];
4645
5828
  case "ctrl-u":
4646
5829
  this.killLine();
4647
5830
  return [];
@@ -4652,6 +5835,9 @@ var init_input = __esm({
4652
5835
  this.yank();
4653
5836
  return [];
4654
5837
  case "escape":
5838
+ if (this.turnRunning) {
5839
+ return [{ type: "cancel", prefill: true }];
5840
+ }
4655
5841
  return [];
4656
5842
  }
4657
5843
  }
@@ -4672,7 +5858,9 @@ var init_input = __esm({
4672
5858
  this.row = 0;
4673
5859
  this.col = 0;
4674
5860
  this.historyIndex = -1;
5861
+ this.queueIndex = -1;
4675
5862
  this.savedDraft = null;
5863
+ this.historySearch = null;
4676
5864
  }
4677
5865
  insertChar(ch) {
4678
5866
  if (ch.length === 0) {
@@ -4805,50 +5993,92 @@ var init_input = __esm({
4805
5993
  this.col = 0;
4806
5994
  }
4807
5995
  }
4808
- // Up scrolls back through history when the cursor is on the first line of
4809
- // the buffer; otherwise it just moves the cursor up one line.
5996
+ // Up walks the navigation stack from newest to oldest: pending queue
5997
+ // items first (so the user can edit something they just enqueued),
5998
+ // then prompt history. Cursor movement within a multi-line buffer
5999
+ // takes priority when not already navigating.
4810
6000
  handleUp() {
4811
6001
  if (this.row > 0) {
4812
6002
  this.row -= 1;
4813
6003
  this.col = Math.min(this.col, this.currentLine().length);
4814
6004
  return [];
4815
6005
  }
4816
- if (this.history.length === 0) {
4817
- return [];
4818
- }
4819
- if (this.historyIndex === -1) {
6006
+ if (this.queueIndex === -1 && this.historyIndex === -1) {
6007
+ if (this.queue.length === 0 && this.history.length === 0) {
6008
+ return [];
6009
+ }
4820
6010
  this.savedDraft = {
4821
6011
  buffer: [...this.buffer],
4822
6012
  row: this.row,
4823
6013
  col: this.col
4824
6014
  };
6015
+ if (this.queue.length > 0) {
6016
+ this.queueIndex = this.queue.length - 1;
6017
+ this.loadEntry(this.queue[this.queueIndex] ?? "");
6018
+ } else {
6019
+ this.historyIndex = this.history.length - 1;
6020
+ this.loadEntry(this.history[this.historyIndex] ?? "");
6021
+ }
6022
+ return [];
6023
+ }
6024
+ if (this.queueIndex >= 0) {
6025
+ if (this.queueIndex > 0) {
6026
+ this.queueIndex -= 1;
6027
+ this.loadEntry(this.queue[this.queueIndex] ?? "");
6028
+ return [];
6029
+ }
6030
+ if (this.history.length === 0) {
6031
+ return [];
6032
+ }
6033
+ this.queueIndex = -1;
4825
6034
  this.historyIndex = this.history.length - 1;
4826
- } else if (this.historyIndex > 0) {
4827
- this.historyIndex -= 1;
4828
- } else {
6035
+ this.loadEntry(this.history[this.historyIndex] ?? "");
4829
6036
  return [];
4830
6037
  }
4831
- this.loadHistoryEntry(this.historyIndex);
6038
+ if (this.historyIndex > 0) {
6039
+ this.historyIndex -= 1;
6040
+ this.loadEntry(this.history[this.historyIndex] ?? "");
6041
+ }
4832
6042
  return [];
4833
6043
  }
4834
- // Down advances within history; when we walk off the end, restore the
4835
- // saved draft. When already on a multi-line buffer's middle row, just
4836
- // moves the cursor down.
6044
+ // Down reverses the Up walk: history (older newer), then queue
6045
+ // (oldest newest), then restore the original draft. Within a
6046
+ // multi-line buffer, plain cursor movement still wins when no
6047
+ // navigation is in progress.
4837
6048
  handleDown() {
4838
- if (this.row < this.buffer.length - 1 && this.historyIndex === -1) {
6049
+ if (this.row < this.buffer.length - 1 && this.historyIndex === -1 && this.queueIndex === -1) {
4839
6050
  this.row += 1;
4840
6051
  this.col = Math.min(this.col, this.currentLine().length);
4841
6052
  return [];
4842
6053
  }
4843
- if (this.historyIndex === -1) {
6054
+ if (this.historyIndex >= 0) {
6055
+ if (this.historyIndex < this.history.length - 1) {
6056
+ this.historyIndex += 1;
6057
+ this.loadEntry(this.history[this.historyIndex] ?? "");
6058
+ return [];
6059
+ }
6060
+ this.historyIndex = -1;
6061
+ if (this.queue.length > 0) {
6062
+ this.queueIndex = 0;
6063
+ this.loadEntry(this.queue[this.queueIndex] ?? "");
6064
+ return [];
6065
+ }
6066
+ this.restoreDraft();
4844
6067
  return [];
4845
6068
  }
4846
- if (this.historyIndex < this.history.length - 1) {
4847
- this.historyIndex += 1;
4848
- this.loadHistoryEntry(this.historyIndex);
6069
+ if (this.queueIndex >= 0) {
6070
+ if (this.queueIndex < this.queue.length - 1) {
6071
+ this.queueIndex += 1;
6072
+ this.loadEntry(this.queue[this.queueIndex] ?? "");
6073
+ return [];
6074
+ }
6075
+ this.queueIndex = -1;
6076
+ this.restoreDraft();
4849
6077
  return [];
4850
6078
  }
4851
- this.historyIndex = -1;
6079
+ return [];
6080
+ }
6081
+ restoreDraft() {
4852
6082
  if (this.savedDraft) {
4853
6083
  this.buffer = [...this.savedDraft.buffer];
4854
6084
  this.row = this.savedDraft.row;
@@ -4857,11 +6087,146 @@ var init_input = __esm({
4857
6087
  } else {
4858
6088
  this.clearBuffer();
4859
6089
  }
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] ?? "");
4860
6158
  return [];
4861
6159
  }
4862
- loadHistoryEntry(index) {
4863
- const entry = this.history[index] ?? "";
4864
- this.buffer = entry.split("\n");
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
+ }
6228
+ loadEntry(text) {
6229
+ this.buffer = text.split("\n");
4865
6230
  if (this.buffer.length === 0) {
4866
6231
  this.buffer = [""];
4867
6232
  }
@@ -4870,6 +6235,14 @@ var init_input = __esm({
4870
6235
  }
4871
6236
  send() {
4872
6237
  const text = this.bufferText();
6238
+ if (this.queueIndex >= 0 && this.queueIndex < this.queue.length) {
6239
+ const index = this.queueIndex;
6240
+ this.clearBuffer();
6241
+ if (text.trim().length === 0) {
6242
+ return [{ type: "queue-remove", index }];
6243
+ }
6244
+ return [{ type: "queue-edit", index, text }];
6245
+ }
4873
6246
  if (text.trim().length === 0) {
4874
6247
  return [];
4875
6248
  }
@@ -4877,25 +6250,105 @@ var init_input = __esm({
4877
6250
  this.clearBuffer();
4878
6251
  return [{ type: "send", text, planMode }];
4879
6252
  }
4880
- handleCtrlC() {
4881
- if (this.turnRunning) {
4882
- return [{ type: "cancel" }];
6253
+ // Home: jump to the very start of the prompt buffer. If we're already
6254
+ // there, fall through to scrolling the scrollback to its top.
6255
+ handleHome() {
6256
+ if (this.row !== 0 || this.col !== 0) {
6257
+ this.row = 0;
6258
+ this.col = 0;
6259
+ return [];
6260
+ }
6261
+ return [{ type: "scroll-to-top" }];
6262
+ }
6263
+ // End: jump to the end of the last line of the prompt buffer. Already
6264
+ // there → scroll the scrollback to the bottom (newest).
6265
+ handleEnd() {
6266
+ const lastRow = this.buffer.length - 1;
6267
+ const lastCol = (this.buffer[lastRow] ?? "").length;
6268
+ if (this.row !== lastRow || this.col !== lastCol) {
6269
+ this.row = lastRow;
6270
+ this.col = lastCol;
6271
+ return [];
4883
6272
  }
6273
+ return [{ type: "scroll-to-bottom" }];
6274
+ }
6275
+ handleCtrlC() {
4884
6276
  if (!this.bufferIsEmpty()) {
4885
- this.clearBuffer();
6277
+ this.buffer = [""];
6278
+ this.row = 0;
6279
+ this.col = 0;
6280
+ if (this.queueIndex === -1) {
6281
+ this.historyIndex = -1;
6282
+ this.savedDraft = null;
6283
+ }
6284
+ return [];
6285
+ }
6286
+ if (this.queueIndex >= 0) {
6287
+ this.queueIndex = -1;
6288
+ this.restoreDraft();
4886
6289
  return [];
4887
6290
  }
6291
+ if (this.turnRunning) {
6292
+ return [{ type: "cancel" }];
6293
+ }
4888
6294
  return [{ type: "exit" }];
4889
6295
  }
4890
6296
  };
4891
6297
  }
4892
6298
  });
4893
6299
 
6300
+ // src/tui/completion.ts
6301
+ function longestCommonPrefix(names) {
6302
+ if (names.length === 0) {
6303
+ return "";
6304
+ }
6305
+ let prefix = names[0] ?? "";
6306
+ for (let i = 1; i < names.length; i++) {
6307
+ const n = names[i] ?? "";
6308
+ let j = 0;
6309
+ while (j < prefix.length && j < n.length && prefix[j] === n[j]) {
6310
+ j += 1;
6311
+ }
6312
+ prefix = prefix.slice(0, j);
6313
+ if (prefix.length === 0) {
6314
+ break;
6315
+ }
6316
+ }
6317
+ return prefix;
6318
+ }
6319
+ function computeTabCompletion(args) {
6320
+ const { matches, firstLine: firstLine3 } = args;
6321
+ if (matches.length === 0) {
6322
+ return null;
6323
+ }
6324
+ const space = firstLine3.indexOf(" ");
6325
+ const typedPrefix = space === -1 ? firstLine3 : firstLine3.slice(0, space);
6326
+ const tail = space === -1 ? "" : firstLine3.slice(space);
6327
+ if (matches.length === 1) {
6328
+ const name = matches[0] ?? "";
6329
+ const suffix = tail.startsWith(" ") ? "" : " ";
6330
+ return name + suffix + tail;
6331
+ }
6332
+ const commonPrefix = longestCommonPrefix(matches);
6333
+ if (commonPrefix.length <= typedPrefix.length) {
6334
+ return null;
6335
+ }
6336
+ return commonPrefix + tail;
6337
+ }
6338
+ var init_completion = __esm({
6339
+ "src/tui/completion.ts"() {
6340
+ "use strict";
6341
+ }
6342
+ });
6343
+
4894
6344
  // src/tui/render-update.ts
4895
6345
  import stripAnsi from "strip-ansi";
4896
6346
  function sanitizeWireText(text) {
4897
6347
  return stripAnsi(text).replace(STRIP_CONTROLS, "");
4898
6348
  }
6349
+ function sanitizeSingleLine(text) {
6350
+ return sanitizeWireText(text).replace(/[\n\t]+/g, " ").replace(/ +/g, " ").trim();
6351
+ }
4899
6352
  function mapUpdate(update) {
4900
6353
  if (!update || typeof update !== "object") {
4901
6354
  return null;
@@ -4939,7 +6392,7 @@ function mapUpdate(update) {
4939
6392
  }
4940
6393
  function mapSessionInfo(u) {
4941
6394
  const rawTitle = readString(u, "title");
4942
- const title = rawTitle !== void 0 ? sanitizeWireText(rawTitle) : void 0;
6395
+ const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
4943
6396
  const meta = u._meta;
4944
6397
  let agentId;
4945
6398
  if (meta && typeof meta === "object" && !Array.isArray(meta)) {
@@ -4963,10 +6416,9 @@ function mapSessionInfo(u) {
4963
6416
  }
4964
6417
  return event;
4965
6418
  }
4966
- function mapAvailableCommands(u) {
4967
- const list = u.availableCommands ?? u.commands;
6419
+ function normalizeAdvertisedCommands(list) {
4968
6420
  if (!Array.isArray(list)) {
4969
- return null;
6421
+ return [];
4970
6422
  }
4971
6423
  const out = [];
4972
6424
  for (const raw of list) {
@@ -4978,13 +6430,20 @@ function mapAvailableCommands(u) {
4978
6430
  continue;
4979
6431
  }
4980
6432
  const rawName = c.name.startsWith("/") ? c.name : `/${c.name}`;
4981
- const cmd = { name: sanitizeWireText(rawName) };
6433
+ const cmd = { name: sanitizeSingleLine(rawName) };
4982
6434
  if (typeof c.description === "string") {
4983
- cmd.description = sanitizeWireText(c.description);
6435
+ cmd.description = sanitizeSingleLine(c.description);
4984
6436
  }
4985
6437
  out.push(cmd);
4986
6438
  }
4987
- return { kind: "available-commands", commands: out };
6439
+ return out;
6440
+ }
6441
+ function mapAvailableCommands(u) {
6442
+ const list = u.availableCommands ?? u.commands;
6443
+ if (!Array.isArray(list)) {
6444
+ return null;
6445
+ }
6446
+ return { kind: "available-commands", commands: normalizeAdvertisedCommands(list) };
4988
6447
  }
4989
6448
  function mapUsage(u) {
4990
6449
  const event = { kind: "usage-update" };
@@ -5046,7 +6505,7 @@ function mapToolCall(u) {
5046
6505
  return null;
5047
6506
  }
5048
6507
  const rawTitle = readString(u, "title") ?? readString(u, "name") ?? readString(u, "label") ?? "tool call";
5049
- const title = sanitizeWireText(rawTitle);
6508
+ const title = sanitizeSingleLine(rawTitle);
5050
6509
  const status = readString(u, "status");
5051
6510
  const rawKind = readString(u, "kind");
5052
6511
  const event = { kind: "tool-call", toolCallId, title };
@@ -5064,7 +6523,7 @@ function mapToolCallUpdate(u) {
5064
6523
  return null;
5065
6524
  }
5066
6525
  const rawTitle = readString(u, "title");
5067
- const title = rawTitle !== void 0 ? sanitizeWireText(rawTitle) : void 0;
6526
+ const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
5068
6527
  const status = readString(u, "status");
5069
6528
  const meaningful = title !== void 0 || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
5070
6529
  if (!meaningful) {
@@ -5090,7 +6549,7 @@ function mapPlan(u) {
5090
6549
  continue;
5091
6550
  }
5092
6551
  const e = raw;
5093
- const content = typeof e.content === "string" ? sanitizeWireText(e.content) : void 0;
6552
+ const content = typeof e.content === "string" ? sanitizeSingleLine(e.content) : void 0;
5094
6553
  if (!content) {
5095
6554
  continue;
5096
6555
  }
@@ -5110,14 +6569,14 @@ function mapMode(u) {
5110
6569
  if (!mode) {
5111
6570
  return null;
5112
6571
  }
5113
- return { kind: "mode-changed", mode: sanitizeWireText(mode) };
6572
+ return { kind: "mode-changed", mode: sanitizeSingleLine(mode) };
5114
6573
  }
5115
6574
  function mapModel(u) {
5116
6575
  const model = readString(u, "currentModel") ?? readString(u, "model");
5117
6576
  if (!model) {
5118
6577
  return null;
5119
6578
  }
5120
- return { kind: "model-changed", model: sanitizeWireText(model) };
6579
+ return { kind: "model-changed", model: sanitizeSingleLine(model) };
5121
6580
  }
5122
6581
  function mapTurnComplete(u) {
5123
6582
  const stopReason = readString(u, "stopReason");
@@ -5505,6 +6964,7 @@ import { nanoid as nanoid3 } from "nanoid";
5505
6964
  import termkit from "terminal-kit";
5506
6965
  async function runTuiApp(opts) {
5507
6966
  const config = await ensureConfig();
6967
+ logMaxBytes = config.tui.logMaxBytes;
5508
6968
  await ensureDaemonReachable(config);
5509
6969
  const term = termkit.terminal;
5510
6970
  const exitHint = {};
@@ -5525,7 +6985,7 @@ async function runSession(term, config, opts, exitHint) {
5525
6985
  process.exit(0);
5526
6986
  }
5527
6987
  const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
5528
- term.cyan(launchLabel)("\n");
6988
+ term.brightYellow(launchLabel)("\n");
5529
6989
  const protocol = config.daemon.tls ? "wss" : "ws";
5530
6990
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
5531
6991
  const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
@@ -5606,7 +7066,8 @@ async function runSession(term, config, opts, exitHint) {
5606
7066
  const { update } = params ?? {};
5607
7067
  const event = mapUpdate(update);
5608
7068
  debugLogUpdate(update, event);
5609
- if (event?.kind === "user-text") {
7069
+ const rawTag = update?.sessionUpdate;
7070
+ if (rawTag === "prompt_received") {
5610
7071
  adjustPendingTurns(1);
5611
7072
  } else if (event?.kind === "turn-complete") {
5612
7073
  adjustPendingTurns(-1);
@@ -5677,11 +7138,11 @@ async function runSession(term, config, opts, exitHint) {
5677
7138
  const rawOptions = Array.isArray(p.options) ? p.options : [];
5678
7139
  const options = rawOptions.map((o) => ({
5679
7140
  optionId: o.optionId,
5680
- name: sanitizeWireText(o.name ?? ""),
7141
+ name: sanitizeSingleLine(o.name ?? ""),
5681
7142
  ...o.kind !== void 0 ? { kind: o.kind } : {}
5682
7143
  }));
5683
7144
  const rawTitle = p.toolCall?.title ?? p.toolCall?.name ?? "tool";
5684
- const title = sanitizeWireText(rawTitle);
7145
+ const title = sanitizeSingleLine(rawTitle);
5685
7146
  const toolCallId = p.toolCall?.toolCallId;
5686
7147
  if (options.length === 0) {
5687
7148
  screen.appendLines([
@@ -5711,12 +7172,12 @@ async function runSession(term, config, opts, exitHint) {
5711
7172
  let agentInfoName;
5712
7173
  try {
5713
7174
  const initResult = await conn.request("initialize", {
5714
- protocolVersion: 1,
7175
+ protocolVersion: ACP_PROTOCOL_VERSION,
5715
7176
  clientCapabilities: {
5716
7177
  fs: { readTextFile: false, writeTextFile: false },
5717
7178
  terminal: false
5718
7179
  },
5719
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
7180
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
5720
7181
  });
5721
7182
  agentInfoName = initResult?.agentInfo?.name;
5722
7183
  } catch {
@@ -5759,15 +7220,13 @@ async function runSession(term, config, opts, exitHint) {
5759
7220
  initialMode = hydraMeta.currentMode;
5760
7221
  initialTurnStartedAt = hydraMeta.turnStartedAt;
5761
7222
  if (hydraMeta.availableCommands) {
5762
- initialCommands = hydraMeta.availableCommands.map(
5763
- (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
5764
- );
7223
+ initialCommands = normalizeAdvertisedCommands(hydraMeta.availableCommands);
5765
7224
  }
5766
7225
  } else {
5767
7226
  const attached = await conn.request("session/attach", {
5768
7227
  sessionId: ctx.sessionId,
5769
7228
  historyPolicy: "full",
5770
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
7229
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
5771
7230
  });
5772
7231
  resolvedSessionId = attached.sessionId;
5773
7232
  exitHint.sessionId = resolvedSessionId;
@@ -5786,9 +7245,7 @@ async function runSession(term, config, opts, exitHint) {
5786
7245
  initialMode = hydraMeta.currentMode;
5787
7246
  initialTurnStartedAt = hydraMeta.turnStartedAt;
5788
7247
  if (hydraMeta.availableCommands) {
5789
- initialCommands = hydraMeta.availableCommands.map(
5790
- (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
5791
- );
7248
+ initialCommands = normalizeAdvertisedCommands(hydraMeta.availableCommands);
5792
7249
  }
5793
7250
  }
5794
7251
  const historyFile = paths.tuiHistoryFile(resolvedSessionId);
@@ -5799,11 +7256,13 @@ async function runSession(term, config, opts, exitHint) {
5799
7256
  dispatcher.setTurnRunning(true);
5800
7257
  }
5801
7258
  let turnInFlight = null;
7259
+ let pendingPrefill = null;
5802
7260
  const screen = new Screen({
5803
7261
  term,
5804
7262
  dispatcher,
5805
7263
  repaintThrottleMs: config.tui.repaintThrottleMs,
5806
7264
  maxScrollbackLines: config.tui.maxScrollbackLines,
7265
+ mouse: config.tui.mouse,
5807
7266
  onKey: (events) => {
5808
7267
  for (const ev of events) {
5809
7268
  if (pendingPermission && tryHandlePermissionKey(ev)) {
@@ -5812,6 +7271,9 @@ async function runSession(term, config, opts, exitHint) {
5812
7271
  if (exitConfirmation && tryHandleExitConfirmKey(ev)) {
5813
7272
  continue;
5814
7273
  }
7274
+ if (tryHandleScrollbackSearchKey(ev)) {
7275
+ continue;
7276
+ }
5815
7277
  if (tryHandleCompletionKey(ev)) {
5816
7278
  continue;
5817
7279
  }
@@ -5821,6 +7283,9 @@ async function runSession(term, config, opts, exitHint) {
5821
7283
  }
5822
7284
  }
5823
7285
  refreshCompletions();
7286
+ screen.setBannerSearchIndicator(
7287
+ dispatcher.state().historySearchQuery
7288
+ );
5824
7289
  screen.refreshPrompt();
5825
7290
  }
5826
7291
  });
@@ -5830,6 +7295,7 @@ async function runSession(term, config, opts, exitHint) {
5830
7295
  { name: "/quit", description: "Exit the TUI" },
5831
7296
  { name: "/clear", description: "Clear scrollback" },
5832
7297
  { name: "/sessions", description: "List sessions" },
7298
+ { name: "/model", description: "Switch model: /model <model-id>" },
5833
7299
  { name: "/demo-plan", description: "Inject synthetic plan events (UI test)" },
5834
7300
  { name: "/demo-tool", description: "Inject a synthetic tool-call sequence (UI test)" }
5835
7301
  ];
@@ -5864,48 +7330,73 @@ async function runSession(term, config, opts, exitHint) {
5864
7330
  screen.setCompletions(currentCompletions());
5865
7331
  };
5866
7332
  const tryHandleCompletionKey = (ev) => {
5867
- if (ev.type !== "key") {
7333
+ if (ev.type !== "key" || ev.name !== "tab") {
5868
7334
  return false;
5869
7335
  }
5870
- if (ev.name === "tab") {
5871
- const matches = currentCompletions();
5872
- const first = matches[0];
5873
- if (!first) {
5874
- return false;
5875
- }
5876
- const commonPrefix = longestCommonPrefix(matches.map((m) => m.name));
5877
- const buf = dispatcher.state().buffer;
5878
- const firstLine3 = buf[0] ?? "";
5879
- const space = firstLine3.indexOf(" ");
5880
- const typedPrefix = space === -1 ? firstLine3 : firstLine3.slice(0, space);
5881
- const tail = space === -1 ? "" : firstLine3.slice(space);
5882
- let next = commonPrefix;
5883
- if (commonPrefix.length <= typedPrefix.length || matches.length === 1) {
5884
- next = first.name + (tail.startsWith(" ") ? "" : " ");
5885
- }
5886
- dispatcher.replaceFirstLine(next + tail);
7336
+ const matches = currentCompletions();
7337
+ if (matches.length === 0) {
7338
+ return false;
7339
+ }
7340
+ const firstLine3 = dispatcher.state().buffer[0] ?? "";
7341
+ const next = computeTabCompletion({
7342
+ matches: matches.map((m) => m.name),
7343
+ firstLine: firstLine3
7344
+ });
7345
+ if (next === null) {
5887
7346
  return true;
5888
7347
  }
5889
- return false;
7348
+ dispatcher.replaceFirstLine(next);
7349
+ return true;
5890
7350
  };
5891
- function longestCommonPrefix(names) {
5892
- if (names.length === 0) {
5893
- return "";
5894
- }
5895
- let prefix = names[0] ?? "";
5896
- for (let i = 1; i < names.length; i++) {
5897
- const n = names[i] ?? "";
5898
- let j = 0;
5899
- while (j < prefix.length && j < n.length && prefix[j] === n[j]) {
5900
- j += 1;
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;
5901
7357
  }
5902
- prefix = prefix.slice(0, j);
5903
- if (prefix.length === 0) {
5904
- break;
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;
5905
7396
  }
5906
7397
  }
5907
- return prefix;
5908
- }
7398
+ return true;
7399
+ };
5909
7400
  const tryHandlePermissionKey = (ev) => {
5910
7401
  if (!pendingPermission) {
5911
7402
  return false;
@@ -6112,22 +7603,45 @@ async function runSession(term, config, opts, exitHint) {
6112
7603
  }
6113
7604
  resume(nextOpts);
6114
7605
  };
7606
+ const queueHeadOffset = () => workerActive ? 1 : 0;
6115
7607
  const handleEffect = (effect) => {
6116
7608
  switch (effect.type) {
6117
7609
  case "send":
6118
7610
  enqueuePrompt(effect.text, effect.planMode);
6119
7611
  return;
6120
- case "cancel":
7612
+ case "queue-edit": {
7613
+ const realIdx = effect.index + queueHeadOffset();
7614
+ const existing = promptQueue[realIdx];
7615
+ if (existing) {
7616
+ promptQueue[realIdx] = { text: effect.text, planMode: existing.planMode };
7617
+ refreshQueueDisplay();
7618
+ }
7619
+ return;
7620
+ }
7621
+ case "queue-remove": {
7622
+ const realIdx = effect.index + queueHeadOffset();
7623
+ if (realIdx >= 0 && realIdx < promptQueue.length) {
7624
+ promptQueue.splice(realIdx, 1);
7625
+ refreshQueueDisplay();
7626
+ }
7627
+ return;
7628
+ }
7629
+ case "cancel": {
7630
+ if (effect.prefill && turnInFlight) {
7631
+ const headOffset = workerActive ? 1 : 0;
7632
+ const waitingEmpty = promptQueue.length <= headOffset;
7633
+ const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
7634
+ if (waitingEmpty && bufferEmpty) {
7635
+ pendingPrefill = turnInFlight.text;
7636
+ }
7637
+ }
6121
7638
  if (turnInFlight) {
6122
7639
  turnInFlight.cancel();
6123
7640
  } else if (pendingTurns > 0) {
6124
7641
  cancelRemoteTurn();
6125
7642
  }
6126
- if (promptQueue.length > (workerActive ? 1 : 0)) {
6127
- promptQueue.length = workerActive ? 1 : 0;
6128
- refreshQueueDisplay();
6129
- }
6130
7643
  return;
7644
+ }
6131
7645
  case "exit":
6132
7646
  void requestExit();
6133
7647
  return;
@@ -6140,6 +7654,12 @@ async function runSession(term, config, opts, exitHint) {
6140
7654
  case "redraw":
6141
7655
  screen.fullRedraw();
6142
7656
  return;
7657
+ case "scroll-to-top":
7658
+ screen.scrollToTop();
7659
+ return;
7660
+ case "scroll-to-bottom":
7661
+ screen.scrollToBottom();
7662
+ return;
6143
7663
  case "switch-session":
6144
7664
  void switchSession();
6145
7665
  return;
@@ -6147,6 +7667,10 @@ async function runSession(term, config, opts, exitHint) {
6147
7667
  toolsExpanded = !toolsExpanded;
6148
7668
  renderToolsBlock();
6149
7669
  return;
7670
+ case "escalate-search":
7671
+ screen.enterScrollbackSearch();
7672
+ screen.updateScrollbackSearchTerm(effect.query);
7673
+ return;
6150
7674
  }
6151
7675
  };
6152
7676
  const promptQueue = [];
@@ -6155,6 +7679,7 @@ async function runSession(term, config, opts, exitHint) {
6155
7679
  const waiting = promptQueue.slice(workerActive ? 1 : 0);
6156
7680
  screen.setQueuedPrompts(waiting.map((p) => p.text));
6157
7681
  screen.setBanner({ queued: waiting.length });
7682
+ dispatcher.setQueue(waiting.map((p) => p.text));
6158
7683
  };
6159
7684
  const enqueuePrompt = (text, planMode) => {
6160
7685
  screen.scrollToBottom();
@@ -6191,6 +7716,7 @@ async function runSession(term, config, opts, exitHint) {
6191
7716
  toolCallOrder.length = 0;
6192
7717
  toolsBlockStartedAt = null;
6193
7718
  toolsBlockEndedAt = null;
7719
+ toolsBlockStopReason = null;
6194
7720
  toolsExpanded = false;
6195
7721
  screen.clearScrollback();
6196
7722
  return true;
@@ -6283,6 +7809,40 @@ async function runSession(term, config, opts, exitHint) {
6283
7809
  }
6284
7810
  ]);
6285
7811
  return true;
7812
+ case "/model": {
7813
+ const arg = space === -1 ? "" : trimmed.slice(space + 1).trim();
7814
+ if (arg === "") {
7815
+ screen.appendLines([
7816
+ {
7817
+ prefix: " ",
7818
+ body: "Usage: /model <model-id>",
7819
+ bodyStyle: "info"
7820
+ }
7821
+ ]);
7822
+ return true;
7823
+ }
7824
+ conn.request("session/set_model", {
7825
+ sessionId: resolvedSessionId,
7826
+ modelId: arg
7827
+ }).then(() => {
7828
+ screen.appendLines([
7829
+ {
7830
+ prefix: " ",
7831
+ body: `model set to ${arg}`,
7832
+ bodyStyle: "system"
7833
+ }
7834
+ ]);
7835
+ }).catch((err) => {
7836
+ screen.appendLines([
7837
+ {
7838
+ prefix: " ",
7839
+ body: `set_model failed: ${err.message}`,
7840
+ bodyStyle: "tool-status-fail"
7841
+ }
7842
+ ]);
7843
+ });
7844
+ return true;
7845
+ }
6286
7846
  default:
6287
7847
  return false;
6288
7848
  }
@@ -6302,6 +7862,15 @@ async function runSession(term, config, opts, exitHint) {
6302
7862
  } finally {
6303
7863
  workerActive = false;
6304
7864
  refreshQueueDisplay();
7865
+ if (pendingPrefill !== null) {
7866
+ const text = pendingPrefill;
7867
+ pendingPrefill = null;
7868
+ const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
7869
+ if (bufferEmpty) {
7870
+ dispatcher.setBuffer(text);
7871
+ screen.refreshPrompt();
7872
+ }
7873
+ }
6305
7874
  }
6306
7875
  };
6307
7876
  const processPrompt = async (text, planMode) => {
@@ -6311,6 +7880,7 @@ async function runSession(term, config, opts, exitHint) {
6311
7880
  appendRender({ kind: "user-text", text });
6312
7881
  let cancelled = false;
6313
7882
  turnInFlight = {
7883
+ text,
6314
7884
  cancel: () => {
6315
7885
  if (cancelled) {
6316
7886
  return;
@@ -6350,6 +7920,7 @@ async function runSession(term, config, opts, exitHint) {
6350
7920
  let toolsExpanded = false;
6351
7921
  let toolsBlockStartedAt = null;
6352
7922
  let toolsBlockEndedAt = null;
7923
+ let toolsBlockStopReason = null;
6353
7924
  const TOOLS_COLLAPSED_LIMIT = 5;
6354
7925
  let agentBuffer = "";
6355
7926
  let agentKey = null;
@@ -6391,12 +7962,17 @@ async function runSession(term, config, opts, exitHint) {
6391
7962
  const inProgress = toolsBlockEndedAt === null;
6392
7963
  const end = toolsBlockEndedAt ?? Date.now();
6393
7964
  const elapsed = end - toolsBlockStartedAt;
7965
+ const stoppedReason = !inProgress && toolsBlockStopReason !== null && toolsBlockStopReason !== "end_turn" ? toolsBlockStopReason : null;
6394
7966
  let summary;
6395
7967
  if (total === 0) {
6396
- 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
+ }
6397
7973
  } else {
6398
7974
  const noun = total === 1 ? "tool" : "tools";
6399
- const timing = inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
7975
+ const timing = stoppedReason !== null ? `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}` : inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
6400
7976
  const parts = [`${total} ${noun}`, timing];
6401
7977
  if (inProgress) {
6402
7978
  if (hidden > 0) {
@@ -6407,12 +7983,15 @@ async function runSession(term, config, opts, exitHint) {
6407
7983
  }
6408
7984
  summary = parts.join(" \xB7 ");
6409
7985
  }
7986
+ const pureThinking = total === 0 && inProgress;
7987
+ const frozenStyle = stoppedReason !== null ? "tool-status-fail" : "tool";
7988
+ const frozenBodyStyle = stoppedReason !== null ? "tool-status-fail" : "dim";
6410
7989
  const lines = [
6411
7990
  {
6412
7991
  prefix: "\u2692 ",
6413
- prefixStyle: "tool",
7992
+ prefixStyle: pureThinking ? "tool-status-running" : frozenStyle,
6414
7993
  body: summary,
6415
- bodyStyle: "dim"
7994
+ bodyStyle: pureThinking ? "tool-status-running" : frozenBodyStyle
6416
7995
  }
6417
7996
  ];
6418
7997
  for (const id of visibleIds) {
@@ -6426,6 +8005,7 @@ async function runSession(term, config, opts, exitHint) {
6426
8005
  const startToolsBlock = () => {
6427
8006
  toolsBlockStartedAt = Date.now();
6428
8007
  toolsBlockEndedAt = null;
8008
+ toolsBlockStopReason = null;
6429
8009
  renderToolsBlock();
6430
8010
  };
6431
8011
  const recordToolCall = (id, title, status) => {
@@ -6450,6 +8030,7 @@ async function runSession(term, config, opts, exitHint) {
6450
8030
  if (toolsBlockStartedAt === null) {
6451
8031
  toolsBlockStartedAt = Date.now();
6452
8032
  toolsBlockEndedAt = null;
8033
+ toolsBlockStopReason = null;
6453
8034
  }
6454
8035
  toolCallOrder.push(id);
6455
8036
  }
@@ -6551,6 +8132,7 @@ async function runSession(term, config, opts, exitHint) {
6551
8132
  screen.clearKey("plan");
6552
8133
  if (toolsBlockStartedAt !== null) {
6553
8134
  toolsBlockEndedAt = Date.now();
8135
+ toolsBlockStopReason = event.stopReason ?? null;
6554
8136
  renderToolsBlock();
6555
8137
  screen.clearKey("tools");
6556
8138
  }
@@ -6558,6 +8140,7 @@ async function runSession(term, config, opts, exitHint) {
6558
8140
  toolCallOrder.length = 0;
6559
8141
  toolsBlockStartedAt = null;
6560
8142
  toolsBlockEndedAt = null;
8143
+ toolsBlockStopReason = null;
6561
8144
  toolsExpanded = false;
6562
8145
  screen.ensureSeparator();
6563
8146
  }
@@ -6588,6 +8171,8 @@ async function runSession(term, config, opts, exitHint) {
6588
8171
  }, 1e3);
6589
8172
  }
6590
8173
  startToolsBlock();
8174
+ } else if (initialTurnStartedAt === void 0 && pendingTurns > 0) {
8175
+ adjustPendingTurns(-pendingTurns);
6591
8176
  }
6592
8177
  const resetInFlightUiState = () => {
6593
8178
  if (pendingPermission) {
@@ -6599,12 +8184,14 @@ async function runSession(term, config, opts, exitHint) {
6599
8184
  closeAgentText();
6600
8185
  if (toolsBlockStartedAt !== null) {
6601
8186
  toolsBlockEndedAt = Date.now();
8187
+ toolsBlockStopReason = null;
6602
8188
  renderToolsBlock();
6603
8189
  screen.clearKey("tools");
6604
8190
  toolStates.clear();
6605
8191
  toolCallOrder.length = 0;
6606
8192
  toolsBlockStartedAt = null;
6607
8193
  toolsBlockEndedAt = null;
8194
+ toolsBlockStopReason = null;
6608
8195
  toolsExpanded = false;
6609
8196
  }
6610
8197
  screen.clearKey("plan");
@@ -6622,12 +8209,12 @@ async function runSession(term, config, opts, exitHint) {
6622
8209
  id: `tui-reinit-${nanoid3()}`,
6623
8210
  method: "initialize",
6624
8211
  params: {
6625
- protocolVersion: 1,
8212
+ protocolVersion: ACP_PROTOCOL_VERSION,
6626
8213
  clientCapabilities: {
6627
8214
  fs: { readTextFile: false, writeTextFile: false },
6628
8215
  terminal: false
6629
8216
  },
6630
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
8217
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6631
8218
  }
6632
8219
  };
6633
8220
  try {
@@ -6641,7 +8228,7 @@ async function runSession(term, config, opts, exitHint) {
6641
8228
  params: {
6642
8229
  sessionId: resolvedSessionId,
6643
8230
  historyPolicy: "none",
6644
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" },
8231
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION },
6645
8232
  ...upstreamSessionId !== void 0 ? {
6646
8233
  _meta: {
6647
8234
  [HYDRA_META_KEY]: {
@@ -6765,15 +8352,15 @@ function writeDebugLine(payload) {
6765
8352
  }
6766
8353
  function rotateIfBig(target) {
6767
8354
  try {
6768
- const stat3 = statSync(target);
6769
- if (stat3.size < TUI_LOG_MAX_BYTES) {
8355
+ const stat4 = statSync(target);
8356
+ if (stat4.size < logMaxBytes) {
6770
8357
  return;
6771
8358
  }
6772
8359
  renameSync(target, `${target}.0`);
6773
8360
  } catch {
6774
8361
  }
6775
8362
  }
6776
- var PLAN_PREFIX_TEXT, TUI_LOG_MAX_BYTES;
8363
+ var PLAN_PREFIX_TEXT, logMaxBytes;
6777
8364
  var init_app = __esm({
6778
8365
  "src/tui/app.ts"() {
6779
8366
  "use strict";
@@ -6784,15 +8371,17 @@ var init_app = __esm({
6784
8371
  init_daemon_bootstrap();
6785
8372
  init_session();
6786
8373
  init_paths();
8374
+ init_hydra_version();
6787
8375
  init_history();
6788
8376
  init_discovery();
6789
8377
  init_picker();
6790
8378
  init_screen();
6791
8379
  init_input();
8380
+ init_completion();
6792
8381
  init_render_update();
6793
8382
  init_format();
6794
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.";
6795
- TUI_LOG_MAX_BYTES = 5 * 1024 * 1024;
8384
+ logMaxBytes = 5 * 1024 * 1024;
6796
8385
  }
6797
8386
  });
6798
8387
 
@@ -6809,9 +8398,9 @@ var init_tui = __esm({
6809
8398
  });
6810
8399
 
6811
8400
  // src/cli.ts
6812
- import { readFileSync } from "fs";
6813
- import { fileURLToPath } from "url";
6814
- 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";
6815
8404
 
6816
8405
  // src/cli/parse-args.ts
6817
8406
  function parseArgs(argv) {
@@ -6875,35 +8464,28 @@ init_config();
6875
8464
  import * as fs2 from "fs/promises";
6876
8465
  async function runInit(flags) {
6877
8466
  await fs2.mkdir(paths.home(), { recursive: true });
6878
- let existing;
6879
- try {
6880
- existing = await loadConfig();
6881
- } catch {
6882
- existing = void 0;
6883
- }
6884
- if (!existing) {
6885
- const config = await writeMinimalInitConfig();
8467
+ const existingToken = await loadAuthToken();
8468
+ if (!existingToken) {
8469
+ const token = generateAuthToken();
8470
+ await writeAuthToken(token);
6886
8471
  process.stdout.write(
6887
- `Initialized ${paths.config()}
6888
- Auth token: ${config.daemon.authToken}
8472
+ `Initialized ${paths.authToken()}
8473
+ Auth token: ${token}
6889
8474
  `
6890
8475
  );
6891
8476
  return;
6892
8477
  }
6893
8478
  if (flagBool(flags, "rotate-token")) {
6894
8479
  const newToken = generateAuthToken();
6895
- await updateConfigField((raw) => {
6896
- const daemon = raw.daemon ??= {};
6897
- daemon.authToken = newToken;
6898
- });
8480
+ await writeAuthToken(newToken);
6899
8481
  process.stdout.write(
6900
- `Rotated token in ${paths.config()}
8482
+ `Rotated token in ${paths.authToken()}
6901
8483
  New token: ${newToken}
6902
8484
  `
6903
8485
  );
6904
8486
  return;
6905
8487
  }
6906
- process.stdout.write(`Config already exists at ${paths.config()}.
8488
+ process.stdout.write(`Auth token already exists at ${paths.authToken()}.
6907
8489
  `);
6908
8490
  process.stdout.write("Pass --rotate-token to generate a new auth token.\n");
6909
8491
  }
@@ -6911,13 +8493,13 @@ New token: ${newToken}
6911
8493
  // src/cli/commands/daemon.ts
6912
8494
  init_paths();
6913
8495
  init_config();
6914
- import * as fsp5 from "fs/promises";
8496
+ import * as fsp6 from "fs/promises";
6915
8497
  import { setTimeout as sleep2 } from "timers/promises";
6916
8498
 
6917
8499
  // src/daemon/server.ts
6918
8500
  init_config();
6919
- import * as fs10 from "fs";
6920
- import * as fsp3 from "fs/promises";
8501
+ import * as fs11 from "fs";
8502
+ import * as fsp4 from "fs/promises";
6921
8503
  import Fastify from "fastify";
6922
8504
  import websocketPlugin from "@fastify/websocket";
6923
8505
  import pino from "pino";
@@ -7072,60 +8654,177 @@ function formatProgress(agentId, received, total, done = false) {
7072
8654
  const tag2 = done ? "downloaded" : "downloading";
7073
8655
  return `hydra-acp: ${tag2} ${agentId} ${rxMb}/${totalMb} MB (${pct}%)`;
7074
8656
  }
7075
- const tag = done ? "downloaded" : "downloading";
7076
- return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
7077
- }
7078
- function inferArchiveName(url) {
7079
- const u = new URL(url);
7080
- const base = path2.posix.basename(u.pathname);
7081
- return base || "archive";
7082
- }
7083
- async function extract(archivePath, dest) {
7084
- const lower = archivePath.toLowerCase();
7085
- if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
7086
- await run("tar", ["-xf", archivePath, "-C", dest]);
7087
- 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
+ );
7088
8753
  }
7089
- if (lower.endsWith(".zip")) {
7090
- if (await hasCommand("unzip")) {
7091
- await run("unzip", ["-q", archivePath, "-d", dest]);
7092
- 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;
7093
8778
  }
7094
- await run("tar", ["-xf", archivePath, "-C", dest]);
7095
- 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;
7096
8785
  }
7097
- throw new Error(`Unsupported archive format: ${archivePath}`);
7098
8786
  }
7099
- function run(cmd, args) {
8787
+ function runNpmInstall(args) {
7100
8788
  return new Promise((resolve5, reject) => {
7101
- const child = spawn(cmd, args, {
7102
- 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));
7103
8808
  });
7104
- child.on("error", reject);
7105
8809
  child.on("exit", (code, signal) => {
7106
8810
  if (code === 0) {
7107
8811
  resolve5();
7108
8812
  return;
7109
8813
  }
8814
+ const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
8815
+ const tail = stderrTail.trim();
7110
8816
  reject(
7111
8817
  new Error(
7112
- `${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})`
7113
8820
  )
7114
8821
  );
7115
8822
  });
7116
8823
  });
7117
8824
  }
7118
- async function hasCommand(name) {
7119
- return new Promise((resolve5) => {
7120
- const finder = process.platform === "win32" ? "where" : "which";
7121
- const child = spawn(finder, [name], { stdio: "ignore" });
7122
- child.on("error", () => resolve5(false));
7123
- child.on("exit", (code) => resolve5(code === 0));
7124
- });
7125
- }
7126
- async function fileExists(p) {
8825
+ async function fileExists2(p) {
7127
8826
  try {
7128
- await fsp.access(p);
8827
+ await fsp2.access(p);
7129
8828
  return true;
7130
8829
  } catch {
7131
8830
  return false;
@@ -7135,6 +8834,10 @@ async function fileExists(p) {
7135
8834
  // src/core/registry.ts
7136
8835
  var NpxDistribution = z2.object({
7137
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(),
7138
8841
  args: z2.array(z2.string()).optional(),
7139
8842
  env: z2.record(z2.string()).optional()
7140
8843
  });
@@ -7294,13 +8997,27 @@ function npxPackageBasename(agent) {
7294
8997
  const atIdx = afterSlash.lastIndexOf("@");
7295
8998
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
7296
8999
  }
7297
- async function planSpawn(agent, extraArgs = []) {
9000
+ async function planSpawn(agent, callerArgs = []) {
7298
9001
  if (agent.distribution.npx) {
7299
9002
  const npx = agent.distribution.npx;
7300
- const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
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
+ });
7301
9018
  return {
7302
- command: "npx",
7303
- args,
9019
+ command: binPath,
9020
+ args: tail,
7304
9021
  env: npx.env ?? {}
7305
9022
  };
7306
9023
  }
@@ -7316,30 +9033,27 @@ async function planSpawn(agent, extraArgs = []) {
7316
9033
  version: agent.version ?? "current",
7317
9034
  target
7318
9035
  });
9036
+ const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
7319
9037
  return {
7320
9038
  command: cmdPath,
7321
- args: [...target.args ?? [], ...extraArgs],
9039
+ args: tail,
7322
9040
  env: target.env ?? {}
7323
9041
  };
7324
9042
  }
7325
9043
  if (agent.distribution.uvx) {
7326
9044
  const uvx = agent.distribution.uvx;
7327
- const args = [uvx.package, ...uvx.args ?? [], ...extraArgs];
9045
+ const tail = callerArgs.length > 0 ? callerArgs : uvx.args ?? [];
7328
9046
  return {
7329
9047
  command: "uvx",
7330
- args,
9048
+ args: [uvx.package, ...tail],
7331
9049
  env: uvx.env ?? {}
7332
9050
  };
7333
9051
  }
7334
9052
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
7335
9053
  }
7336
9054
 
7337
- // src/core/session-manager.ts
7338
- import * as fs8 from "fs/promises";
7339
- import { customAlphabet as customAlphabet3 } from "nanoid";
7340
-
7341
9055
  // src/core/agent-instance.ts
7342
- import { spawn as spawn2 } from "child_process";
9056
+ import { spawn as spawn3 } from "child_process";
7343
9057
 
7344
9058
  // src/acp/framing.ts
7345
9059
  init_types();
@@ -7420,17 +9134,22 @@ function ndjsonStreamFromStdio(stdout, stdin) {
7420
9134
 
7421
9135
  // src/core/agent-instance.ts
7422
9136
  init_connection();
9137
+ var DEFAULT_STDERR_TAIL_BYTES = 4096;
7423
9138
  var AgentInstance = class _AgentInstance {
7424
9139
  agentId;
7425
9140
  cwd;
7426
9141
  connection;
7427
9142
  child;
7428
9143
  exited = false;
9144
+ killed = false;
9145
+ stderrTail = "";
9146
+ stderrTailBytes;
7429
9147
  exitHandlers = [];
7430
9148
  constructor(opts, child) {
7431
9149
  this.agentId = opts.agentId;
7432
9150
  this.cwd = opts.cwd;
7433
9151
  this.child = child;
9152
+ this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
7434
9153
  if (!child.stdout || !child.stdin) {
7435
9154
  throw new Error("agent subprocess missing stdio");
7436
9155
  }
@@ -7438,22 +9157,36 @@ var AgentInstance = class _AgentInstance {
7438
9157
  this.connection = new JsonRpcConnection(stream);
7439
9158
  child.stderr?.setEncoding("utf8");
7440
9159
  child.stderr?.on("data", (chunk) => {
9160
+ this.stderrTail = (this.stderrTail + chunk).slice(-this.stderrTailBytes);
7441
9161
  process.stderr.write(`[${opts.agentId}] ${chunk}`);
7442
9162
  });
9163
+ child.on("error", (err) => {
9164
+ const msg = this.formatFailure(err.message);
9165
+ this.connection.fail(new Error(msg));
9166
+ });
7443
9167
  child.on("exit", (code, signal) => {
7444
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
+ }
7445
9173
  for (const handler of this.exitHandlers) {
7446
9174
  handler(code, signal);
7447
9175
  }
7448
9176
  });
7449
9177
  }
9178
+ formatFailure(reason) {
9179
+ const tail = this.stderrTail.trim();
9180
+ return tail ? `${reason}
9181
+ stderr: ${tail}` : reason;
9182
+ }
7450
9183
  static spawn(opts) {
7451
9184
  const env = {
7452
9185
  ...process.env,
7453
9186
  ...opts.plan.env,
7454
9187
  ...opts.extraEnv ?? {}
7455
9188
  };
7456
- const child = spawn2(opts.plan.command, opts.plan.args, {
9189
+ const child = spawn3(opts.plan.command, opts.plan.args, {
7457
9190
  cwd: opts.cwd,
7458
9191
  env,
7459
9192
  stdio: ["pipe", "pipe", "pipe"]
@@ -7470,196 +9203,33 @@ var AgentInstance = class _AgentInstance {
7470
9203
  if (this.exited) {
7471
9204
  return;
7472
9205
  }
9206
+ this.killed = true;
7473
9207
  await this.connection.close().catch(() => void 0);
7474
9208
  this.child.kill(signal);
7475
9209
  }
7476
9210
  };
7477
9211
 
7478
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";
7479
9216
  init_session();
7480
-
7481
- // src/core/session-store.ts
7482
- init_paths();
7483
- import * as fs5 from "fs/promises";
7484
- import * as path3 from "path";
7485
- import { customAlphabet as customAlphabet2 } from "nanoid";
7486
- import { z as z4 } from "zod";
7487
- var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7488
- var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
7489
- var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
7490
- function generateLineageId() {
7491
- return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
7492
- }
7493
- var PersistedAgentCommand = z4.object({
7494
- name: z4.string(),
7495
- description: z4.string().optional()
7496
- });
7497
- var PersistedUsage = z4.object({
7498
- used: z4.number().optional(),
7499
- size: z4.number().optional(),
7500
- costAmount: z4.number().optional(),
7501
- costCurrency: z4.string().optional()
7502
- });
7503
- var SessionRecord = z4.object({
7504
- version: z4.literal(1),
7505
- sessionId: z4.string(),
7506
- // Optional for back-compat with records written before this field
7507
- // existed; mergeForPersistence generates one on next write so any
7508
- // touched session converges to having a lineageId. A record that
7509
- // never gets written again (truly cold and untouched) just won't
7510
- // participate in lineage-based dedup, which is correct — it was
7511
- // never exported, so no incoming bundle can claim its lineage.
7512
- lineageId: z4.string().optional(),
7513
- upstreamSessionId: z4.string(),
7514
- // When non-empty, marks a session that was created by import and is
7515
- // waiting for its first attach to bootstrap a fresh upstream agent
7516
- // and replay the imported history as a takeover transcript. The
7517
- // origin's local id at export time, kept for debuggability and as a
7518
- // breadcrumb in `sessions list` (informational, not used for routing).
7519
- importedFromSessionId: z4.string().optional(),
7520
- agentId: z4.string(),
7521
- cwd: z4.string(),
7522
- title: z4.string().optional(),
7523
- agentArgs: z4.array(z4.string()).optional(),
7524
- // Snapshot of "what is currently true about this session" carried in
7525
- // meta.json so a late-attaching or cold-resurrected client can be
7526
- // told via the attach response _meta without depending on history
7527
- // replay of a snapshot-shaped notification.
7528
- currentModel: z4.string().optional(),
7529
- currentMode: z4.string().optional(),
7530
- currentUsage: PersistedUsage.optional(),
7531
- agentCommands: z4.array(PersistedAgentCommand).optional(),
7532
- createdAt: z4.string(),
7533
- updatedAt: z4.string()
7534
- });
7535
- var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7536
- function assertSafeId(id) {
7537
- if (!SESSION_ID_PATTERN.test(id)) {
7538
- throw new Error(`unsafe session id: ${id}`);
7539
- }
7540
- }
7541
- var SessionStore = class {
7542
- async write(record) {
7543
- assertSafeId(record.sessionId);
7544
- await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
7545
- const full = { version: 1, ...record };
7546
- await fs5.writeFile(
7547
- paths.sessionFile(record.sessionId),
7548
- JSON.stringify(full, null, 2) + "\n",
7549
- { encoding: "utf8", mode: 384 }
7550
- );
7551
- }
7552
- async read(sessionId) {
7553
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7554
- return void 0;
7555
- }
7556
- let raw;
7557
- try {
7558
- raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
7559
- } catch (err) {
7560
- const e = err;
7561
- if (e.code === "ENOENT") {
7562
- return void 0;
7563
- }
7564
- throw err;
7565
- }
7566
- try {
7567
- return SessionRecord.parse(JSON.parse(raw));
7568
- } catch {
7569
- return void 0;
7570
- }
7571
- }
7572
- async delete(sessionId) {
7573
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7574
- return;
7575
- }
7576
- try {
7577
- await fs5.unlink(paths.sessionFile(sessionId));
7578
- } catch (err) {
7579
- const e = err;
7580
- if (e.code !== "ENOENT") {
7581
- throw err;
7582
- }
7583
- }
7584
- try {
7585
- await fs5.rmdir(paths.sessionDir(sessionId));
7586
- } catch (err) {
7587
- const e = err;
7588
- if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
7589
- throw err;
7590
- }
7591
- }
7592
- }
7593
- // Find a persisted session by lineageId. Used by SessionManager.import
7594
- // to detect bundles that have already been imported (lineageId match)
7595
- // so we can either error out or, with replace:true, overwrite.
7596
- // Returns undefined if no record has that lineageId. Records that
7597
- // pre-date the lineageId field simply don't match — which is
7598
- // correct: they were never exported, so no incoming bundle can
7599
- // legitimately claim their lineage.
7600
- async findByLineageId(lineageId) {
7601
- if (lineageId.length === 0) {
7602
- return void 0;
7603
- }
7604
- const all = await this.list().catch(() => []);
7605
- for (const record of all) {
7606
- if (record.lineageId === lineageId) {
7607
- return record;
7608
- }
7609
- }
7610
- return void 0;
7611
- }
7612
- async list() {
7613
- let entries;
7614
- try {
7615
- entries = await fs5.readdir(paths.sessionsDir());
7616
- } catch (err) {
7617
- const e = err;
7618
- if (e.code === "ENOENT") {
7619
- return [];
7620
- }
7621
- throw err;
7622
- }
7623
- const records = [];
7624
- for (const entry of entries) {
7625
- const record = await this.read(entry);
7626
- if (record) {
7627
- records.push(record);
7628
- }
7629
- }
7630
- return records;
7631
- }
7632
- };
7633
- function recordFromMemorySession(args) {
7634
- const now = (/* @__PURE__ */ new Date()).toISOString();
7635
- return {
7636
- sessionId: args.sessionId,
7637
- lineageId: args.lineageId,
7638
- upstreamSessionId: args.upstreamSessionId,
7639
- importedFromSessionId: args.importedFromSessionId,
7640
- agentId: args.agentId,
7641
- cwd: args.cwd,
7642
- title: args.title,
7643
- agentArgs: args.agentArgs,
7644
- currentModel: args.currentModel,
7645
- currentMode: args.currentMode,
7646
- currentUsage: args.currentUsage,
7647
- agentCommands: args.agentCommands,
7648
- createdAt: args.createdAt ?? now,
7649
- updatedAt: args.updatedAt ?? now
7650
- };
7651
- }
9217
+ init_session_store();
7652
9218
 
7653
9219
  // src/core/history-store.ts
7654
9220
  init_paths();
7655
9221
  import * as fs6 from "fs/promises";
7656
9222
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
7657
- var MAX_ENTRIES = 1e3;
9223
+ var DEFAULT_MAX_ENTRIES = 1e3;
7658
9224
  var HistoryStore = class {
7659
9225
  // Serialize writes per session id so appends and rewrites don't
7660
9226
  // interleave JSONL lines on disk. The chain swallows errors so one
7661
9227
  // failed append doesn't poison every subsequent write.
7662
9228
  writeQueues = /* @__PURE__ */ new Map();
9229
+ maxEntries;
9230
+ constructor(options = {}) {
9231
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
9232
+ }
7663
9233
  async append(sessionId, entry) {
7664
9234
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
7665
9235
  return;
@@ -7761,8 +9331,8 @@ var HistoryStore = class {
7761
9331
  recordedAt: obj.recordedAt
7762
9332
  });
7763
9333
  }
7764
- if (out.length > MAX_ENTRIES) {
7765
- return out.slice(-MAX_ENTRIES);
9334
+ if (out.length > this.maxEntries) {
9335
+ return out.slice(-this.maxEntries);
7766
9336
  }
7767
9337
  return out;
7768
9338
  }
@@ -7807,6 +9377,7 @@ var HistoryStore = class {
7807
9377
  init_paths();
7808
9378
  init_history();
7809
9379
  init_types();
9380
+ init_hydra_version();
7810
9381
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7811
9382
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
7812
9383
  var SessionManager = class {
@@ -7814,7 +9385,8 @@ var SessionManager = class {
7814
9385
  this.registry = registry;
7815
9386
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
7816
9387
  this.store = store ?? new SessionStore();
7817
- this.histories = new HistoryStore();
9388
+ this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
9389
+ this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
7818
9390
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
7819
9391
  this.defaultModels = options.defaultModels ?? {};
7820
9392
  }
@@ -7826,6 +9398,7 @@ var SessionManager = class {
7826
9398
  histories;
7827
9399
  idleTimeoutMs;
7828
9400
  defaultModels;
9401
+ sessionHistoryMaxEntries;
7829
9402
  // Serialize meta.json read-modify-write operations per session id so
7830
9403
  // concurrent snapshot updates (e.g. an agent emitting model + mode
7831
9404
  // back-to-back) don't lose writes via interleaved reads.
@@ -7849,6 +9422,7 @@ var SessionManager = class {
7849
9422
  idleTimeoutMs: this.idleTimeoutMs,
7850
9423
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7851
9424
  historyStore: this.histories,
9425
+ historyMaxEntries: this.sessionHistoryMaxEntries,
7852
9426
  currentModel: fresh.initialModel
7853
9427
  });
7854
9428
  await this.attachManagerHooks(session);
@@ -7900,11 +9474,16 @@ var SessionManager = class {
7900
9474
  cwd: params.cwd,
7901
9475
  plan
7902
9476
  });
7903
- await agent.connection.request("initialize", {
7904
- protocolVersion: 1,
7905
- clientCapabilities: {},
7906
- clientInfo: { name: "hydra", version: "0.1.0" }
7907
- });
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
+ }
7908
9487
  let loadResult;
7909
9488
  try {
7910
9489
  loadResult = await agent.connection.request(
@@ -7916,10 +9495,12 @@ var SessionManager = class {
7916
9495
  }
7917
9496
  );
7918
9497
  } catch (err) {
7919
- await agent.kill().catch(() => void 0);
7920
- throw new Error(
7921
- `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
+ `
7922
9501
  );
9502
+ await agent.kill().catch(() => void 0);
9503
+ return this.doResurrectFromImport(params);
7923
9504
  }
7924
9505
  const session = new Session({
7925
9506
  sessionId: params.hydraSessionId,
@@ -7933,6 +9514,7 @@ var SessionManager = class {
7933
9514
  idleTimeoutMs: this.idleTimeoutMs,
7934
9515
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7935
9516
  historyStore: this.histories,
9517
+ historyMaxEntries: this.sessionHistoryMaxEntries,
7936
9518
  // Prefer what we previously stored from a current_model_update; if
7937
9519
  // we never captured one (e.g. old opencode sessions on disk before
7938
9520
  // this fix), fall back to the model the agent ships in its
@@ -7959,15 +9541,16 @@ var SessionManager = class {
7959
9541
  // so subsequent resurrects of this session use the normal session/load
7960
9542
  // path.
7961
9543
  async doResurrectFromImport(params) {
9544
+ const cwd = await this.resolveImportCwd(params.cwd);
7962
9545
  const fresh = await this.bootstrapAgent({
7963
9546
  agentId: params.agentId,
7964
- cwd: params.cwd,
9547
+ cwd,
7965
9548
  agentArgs: params.agentArgs,
7966
9549
  mcpServers: []
7967
9550
  });
7968
9551
  const session = new Session({
7969
9552
  sessionId: params.hydraSessionId,
7970
- cwd: params.cwd,
9553
+ cwd,
7971
9554
  agentId: params.agentId,
7972
9555
  agent: fresh.agent,
7973
9556
  upstreamSessionId: fresh.upstreamSessionId,
@@ -7977,6 +9560,7 @@ var SessionManager = class {
7977
9560
  idleTimeoutMs: this.idleTimeoutMs,
7978
9561
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7979
9562
  historyStore: this.histories,
9563
+ historyMaxEntries: this.sessionHistoryMaxEntries,
7980
9564
  // Prefer the stored value (set by a previous current_model_update);
7981
9565
  // fall back to whatever the agent ships in its session/new response.
7982
9566
  currentModel: params.currentModel ?? fresh.initialModel,
@@ -7990,6 +9574,16 @@ var SessionManager = class {
7990
9574
  void session.seedFromImport().catch(() => void 0);
7991
9575
  return session;
7992
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
+ }
7993
9587
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
7994
9588
  // → session/new. Shared by create() and the /hydra agent path so both
7995
9589
  // go through the same env / capabilities / error-handling.
@@ -8010,9 +9604,9 @@ var SessionManager = class {
8010
9604
  });
8011
9605
  try {
8012
9606
  await agent.connection.request("initialize", {
8013
- protocolVersion: 1,
9607
+ protocolVersion: ACP_PROTOCOL_VERSION,
8014
9608
  clientCapabilities: {},
8015
- clientInfo: { name: "hydra", version: "0.1.0" }
9609
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
8016
9610
  });
8017
9611
  const newResult = await agent.connection.request(
8018
9612
  "session/new",
@@ -8292,7 +9886,8 @@ var SessionManager = class {
8292
9886
  await this.writeImportedRecord({
8293
9887
  sessionId: existing.sessionId,
8294
9888
  bundle,
8295
- preservedCreatedAt: existing.createdAt
9889
+ preservedCreatedAt: existing.createdAt,
9890
+ cwd: opts.cwd
8296
9891
  });
8297
9892
  return {
8298
9893
  sessionId: existing.sessionId,
@@ -8301,7 +9896,11 @@ var SessionManager = class {
8301
9896
  };
8302
9897
  }
8303
9898
  const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
8304
- await this.writeImportedRecord({ sessionId: newId, bundle });
9899
+ await this.writeImportedRecord({
9900
+ sessionId: newId,
9901
+ bundle,
9902
+ cwd: opts.cwd
9903
+ });
8305
9904
  return {
8306
9905
  sessionId: newId,
8307
9906
  importedFromSessionId: bundle.session.sessionId,
@@ -8331,7 +9930,7 @@ var SessionManager = class {
8331
9930
  upstreamSessionId: "",
8332
9931
  importedFromSessionId: args.bundle.session.sessionId,
8333
9932
  agentId: args.bundle.session.agentId,
8334
- cwd: args.bundle.session.cwd,
9933
+ cwd: args.cwd ?? args.bundle.session.cwd,
8335
9934
  title: args.bundle.session.title,
8336
9935
  currentModel: args.bundle.session.currentModel,
8337
9936
  currentMode: args.bundle.session.currentMode,
@@ -8524,7 +10123,7 @@ function asString(value) {
8524
10123
  }
8525
10124
  async function loadPromptHistorySafely(sessionId) {
8526
10125
  try {
8527
- const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
10126
+ const raw = await fs9.readFile(paths.tuiHistoryFile(sessionId), "utf8");
8528
10127
  const out = [];
8529
10128
  for (const line of raw.split("\n")) {
8530
10129
  if (line.length === 0) {
@@ -8545,7 +10144,7 @@ async function loadPromptHistorySafely(sessionId) {
8545
10144
  }
8546
10145
  async function historyMtimeIso(sessionId) {
8547
10146
  try {
8548
- const st = await fs8.stat(paths.historyFile(sessionId));
10147
+ const st = await fs9.stat(paths.historyFile(sessionId));
8549
10148
  return new Date(st.mtimeMs).toISOString();
8550
10149
  } catch {
8551
10150
  return void 0;
@@ -8554,10 +10153,10 @@ async function historyMtimeIso(sessionId) {
8554
10153
 
8555
10154
  // src/core/extensions.ts
8556
10155
  init_paths();
8557
- import { spawn as spawn3 } from "child_process";
8558
- import * as fs9 from "fs";
8559
- import * as fsp2 from "fs/promises";
8560
- 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";
8561
10160
  var RESTART_BASE_MS = 1e3;
8562
10161
  var RESTART_CAP_MS = 6e4;
8563
10162
  var STOP_GRACE_MS = 3e3;
@@ -8578,7 +10177,7 @@ var ExtensionManager = class {
8578
10177
  if (!this.context) {
8579
10178
  throw new Error("ExtensionManager: setContext must be called before start");
8580
10179
  }
8581
- await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
10180
+ await fsp3.mkdir(paths.extensionsDir(), { recursive: true });
8582
10181
  await this.reapOrphans();
8583
10182
  for (const entry of this.entries.values()) {
8584
10183
  if (!entry.config.enabled) {
@@ -8787,7 +10386,7 @@ var ExtensionManager = class {
8787
10386
  async reapOrphans() {
8788
10387
  let entries;
8789
10388
  try {
8790
- entries = await fsp2.readdir(paths.extensionsDir());
10389
+ entries = await fsp3.readdir(paths.extensionsDir());
8791
10390
  } catch (err) {
8792
10391
  const e = err;
8793
10392
  if (e.code === "ENOENT") {
@@ -8799,10 +10398,10 @@ var ExtensionManager = class {
8799
10398
  if (!entry.endsWith(".pid")) {
8800
10399
  continue;
8801
10400
  }
8802
- const pidPath = path5.join(paths.extensionsDir(), entry);
10401
+ const pidPath = path7.join(paths.extensionsDir(), entry);
8803
10402
  let pid;
8804
10403
  try {
8805
- const raw = await fsp2.readFile(pidPath, "utf8");
10404
+ const raw = await fsp3.readFile(pidPath, "utf8");
8806
10405
  const parsed = Number.parseInt(raw.trim(), 10);
8807
10406
  if (Number.isInteger(parsed) && parsed > 0) {
8808
10407
  pid = parsed;
@@ -8825,7 +10424,7 @@ var ExtensionManager = class {
8825
10424
  }
8826
10425
  }
8827
10426
  }
8828
- await fsp2.unlink(pidPath).catch(() => void 0);
10427
+ await fsp3.unlink(pidPath).catch(() => void 0);
8829
10428
  }
8830
10429
  }
8831
10430
  spawn(entry, attempt) {
@@ -8838,7 +10437,7 @@ var ExtensionManager = class {
8838
10437
  }
8839
10438
  const ext = entry.config;
8840
10439
  const command = ext.command.length > 0 ? ext.command : [ext.name];
8841
- const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
10440
+ const logStream = fs10.createWriteStream(paths.extensionLogFile(ext.name), {
8842
10441
  flags: "a"
8843
10442
  });
8844
10443
  logStream.write(
@@ -8866,7 +10465,7 @@ var ExtensionManager = class {
8866
10465
  const args = [...baseArgs, ...ext.args];
8867
10466
  let child;
8868
10467
  try {
8869
- child = spawn3(cmd, args, {
10468
+ child = spawn4(cmd, args, {
8870
10469
  env,
8871
10470
  stdio: ["ignore", "pipe", "pipe"],
8872
10471
  detached: false
@@ -8888,7 +10487,7 @@ var ExtensionManager = class {
8888
10487
  }
8889
10488
  if (typeof child.pid === "number") {
8890
10489
  try {
8891
- fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
10490
+ fs10.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
8892
10491
  `, {
8893
10492
  encoding: "utf8",
8894
10493
  mode: 384
@@ -8913,7 +10512,7 @@ var ExtensionManager = class {
8913
10512
  });
8914
10513
  child.on("exit", (code, signal) => {
8915
10514
  try {
8916
- fs9.unlinkSync(paths.extensionPidFile(ext.name));
10515
+ fs10.unlinkSync(paths.extensionPidFile(ext.name));
8917
10516
  } catch {
8918
10517
  }
8919
10518
  logStream.write(
@@ -8971,6 +10570,7 @@ function withCode2(err, code) {
8971
10570
 
8972
10571
  // src/daemon/server.ts
8973
10572
  init_paths();
10573
+ init_hydra_version();
8974
10574
 
8975
10575
  // src/daemon/auth.ts
8976
10576
  var BEARER_PREFIX = "Bearer ";
@@ -9026,78 +10626,10 @@ function constantTimeEqual(a, b) {
9026
10626
 
9027
10627
  // src/daemon/routes/sessions.ts
9028
10628
  init_config();
9029
- import * as os2 from "os";
9030
-
9031
- // src/core/bundle.ts
9032
- import { z as z5 } from "zod";
9033
- var HistoryEntrySchema = z5.object({
9034
- method: z5.string(),
9035
- params: z5.unknown(),
9036
- recordedAt: z5.number()
9037
- });
9038
- var BundleSession = z5.object({
9039
- // The exporter's local id. Regenerated fresh on import (sessionId is
9040
- // the local namespace; lineageId is what survives across hops).
9041
- sessionId: z5.string(),
9042
- // Required on bundles — the export path backfills if the source
9043
- // record was written before lineageId existed.
9044
- lineageId: z5.string(),
9045
- agentId: z5.string(),
9046
- cwd: z5.string(),
9047
- title: z5.string().optional(),
9048
- currentModel: z5.string().optional(),
9049
- currentMode: z5.string().optional(),
9050
- currentUsage: PersistedUsage.optional(),
9051
- agentCommands: z5.array(PersistedAgentCommand).optional(),
9052
- createdAt: z5.string(),
9053
- updatedAt: z5.string()
9054
- });
9055
- var Bundle = z5.object({
9056
- version: z5.literal(1),
9057
- exportedAt: z5.string(),
9058
- exportedFrom: z5.object({
9059
- hydraVersion: z5.string(),
9060
- machine: z5.string()
9061
- }),
9062
- session: BundleSession,
9063
- history: z5.array(HistoryEntrySchema),
9064
- promptHistory: z5.array(z5.string()).optional()
9065
- });
9066
- function encodeBundle(params) {
9067
- const bundle = {
9068
- version: 1,
9069
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9070
- exportedFrom: {
9071
- hydraVersion: params.hydraVersion,
9072
- machine: params.machine
9073
- },
9074
- session: {
9075
- sessionId: params.record.sessionId,
9076
- lineageId: params.record.lineageId,
9077
- agentId: params.record.agentId,
9078
- cwd: params.record.cwd,
9079
- ...params.record.title !== void 0 ? { title: params.record.title } : {},
9080
- ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
9081
- ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
9082
- ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
9083
- ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
9084
- createdAt: params.record.createdAt,
9085
- updatedAt: params.record.updatedAt
9086
- },
9087
- history: params.history
9088
- };
9089
- if (params.promptHistory !== void 0) {
9090
- bundle.promptHistory = params.promptHistory;
9091
- }
9092
- return bundle;
9093
- }
9094
- function decodeBundle(raw) {
9095
- return Bundle.parse(raw);
9096
- }
9097
-
9098
- // src/daemon/routes/sessions.ts
10629
+ init_bundle();
9099
10630
  init_types();
9100
- var HYDRA_VERSION = "0.1.0";
10631
+ init_hydra_version();
10632
+ import * as os3 from "os";
9101
10633
  function registerSessionRoutes(app, manager, defaults) {
9102
10634
  app.get("/v1/sessions", async (request) => {
9103
10635
  const query = request.query;
@@ -9168,7 +10700,7 @@ function registerSessionRoutes(app, manager, defaults) {
9168
10700
  history: exported.history,
9169
10701
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
9170
10702
  hydraVersion: HYDRA_VERSION,
9171
- machine: os2.hostname()
10703
+ machine: os3.hostname()
9172
10704
  });
9173
10705
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9174
10706
  reply.header(
@@ -9183,6 +10715,14 @@ function registerSessionRoutes(app, manager, defaults) {
9183
10715
  reply.code(400).send({ error: "missing bundle" });
9184
10716
  return;
9185
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
+ }
9186
10726
  let bundle;
9187
10727
  try {
9188
10728
  bundle = decodeBundle(body.bundle);
@@ -9195,7 +10735,8 @@ function registerSessionRoutes(app, manager, defaults) {
9195
10735
  }
9196
10736
  try {
9197
10737
  const result = await manager.importBundle(bundle, {
9198
- replace: body.replace === true
10738
+ replace: body.replace === true,
10739
+ ...cwdOverride !== void 0 ? { cwd: cwdOverride } : {}
9199
10740
  });
9200
10741
  reply.code(201).send(result);
9201
10742
  } catch (err) {
@@ -9436,8 +10977,7 @@ init_connection();
9436
10977
  init_ws_stream();
9437
10978
  init_types();
9438
10979
  import { nanoid as nanoid2 } from "nanoid";
9439
- var HYDRA_VERSION2 = "0.1.0";
9440
- var HYDRA_PROTOCOL_VERSION = 1;
10980
+ init_hydra_version();
9441
10981
  function registerAcpWsEndpoint(app, deps) {
9442
10982
  app.get("/acp", { websocket: true }, (socket, request) => {
9443
10983
  const token = tokenFromUpgradeRequest({
@@ -9700,8 +11240,8 @@ function buildResponseMeta(session) {
9700
11240
  }
9701
11241
  function buildInitializeResult() {
9702
11242
  return {
9703
- protocolVersion: HYDRA_PROTOCOL_VERSION,
9704
- agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
11243
+ protocolVersion: ACP_PROTOCOL_VERSION,
11244
+ agentInfo: { name: "hydra", version: HYDRA_VERSION },
9705
11245
  agentCapabilities: {
9706
11246
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
9707
11247
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -9740,14 +11280,13 @@ function bindClientToSession(connection, session, state, clientInfo) {
9740
11280
  }
9741
11281
 
9742
11282
  // src/daemon/server.ts
9743
- var HYDRA_VERSION3 = "0.1.0";
9744
11283
  async function startDaemon(config) {
9745
11284
  ensureLoopbackOrTls(config);
9746
11285
  const httpsOptions = config.daemon.tls ? {
9747
- key: await fsp3.readFile(config.daemon.tls.key),
9748
- 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)
9749
11288
  } : void 0;
9750
- await fsp3.mkdir(paths.home(), { recursive: true });
11289
+ await fsp4.mkdir(paths.home(), { recursive: true });
9751
11290
  const { stream: logStream, fileStream } = await buildLogStream(
9752
11291
  config.daemon.logLevel
9753
11292
  );
@@ -9756,12 +11295,18 @@ async function startDaemon(config) {
9756
11295
  level: config.daemon.logLevel,
9757
11296
  stream: logStream
9758
11297
  },
9759
- 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
9760
11302
  });
9761
11303
  await app.register(websocketPlugin);
9762
11304
  setBinaryInstallLogger((msg) => {
9763
11305
  app.log.info(msg);
9764
11306
  });
11307
+ setNpmInstallLogger((msg) => {
11308
+ app.log.info(msg);
11309
+ });
9765
11310
  const auth = bearerAuth({ config });
9766
11311
  app.addHook("onRequest", async (request, reply) => {
9767
11312
  if (request.routeOptions.config?.skipAuth) {
@@ -9773,12 +11318,14 @@ async function startDaemon(config) {
9773
11318
  await auth(request, reply);
9774
11319
  });
9775
11320
  const registry = new Registry(config);
9776
- 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, {
9777
11323
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
9778
- defaultModels: config.defaultModels
11324
+ defaultModels: config.defaultModels,
11325
+ sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries
9779
11326
  });
9780
11327
  const extensions = new ExtensionManager(extensionList(config));
9781
- registerHealthRoutes(app, HYDRA_VERSION3);
11328
+ registerHealthRoutes(app, HYDRA_VERSION);
9782
11329
  registerSessionRoutes(app, manager, {
9783
11330
  agentId: config.defaultAgent,
9784
11331
  cwd: config.defaultCwd
@@ -9797,8 +11344,8 @@ async function startDaemon(config) {
9797
11344
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
9798
11345
  const address = app.server.address();
9799
11346
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
9800
- await fsp3.mkdir(paths.home(), { recursive: true });
9801
- await fsp3.writeFile(
11347
+ await fsp4.mkdir(paths.home(), { recursive: true });
11348
+ await fsp4.writeFile(
9802
11349
  paths.pidFile(),
9803
11350
  JSON.stringify({
9804
11351
  pid: process.pid,
@@ -9824,9 +11371,10 @@ async function startDaemon(config) {
9824
11371
  await manager.closeAll();
9825
11372
  await manager.flushMetaWrites();
9826
11373
  setBinaryInstallLogger(null);
11374
+ setNpmInstallLogger(null);
9827
11375
  await app.close();
9828
11376
  try {
9829
- fs10.unlinkSync(paths.pidFile());
11377
+ fs11.unlinkSync(paths.pidFile());
9830
11378
  } catch {
9831
11379
  }
9832
11380
  try {
@@ -9865,13 +11413,13 @@ function ensureLoopbackOrTls(config) {
9865
11413
  init_daemon_bootstrap();
9866
11414
 
9867
11415
  // src/cli/commands/log-tail.ts
9868
- import * as fs11 from "fs";
9869
- import * as fsp4 from "fs/promises";
11416
+ import * as fs12 from "fs";
11417
+ import * as fsp5 from "fs/promises";
9870
11418
  async function runLogTail(logPath, argv, notFoundMessage) {
9871
11419
  const opts = parseLogTailFlags(argv);
9872
- let stat3;
11420
+ let stat4;
9873
11421
  try {
9874
- stat3 = await fsp4.stat(logPath);
11422
+ stat4 = await fsp5.stat(logPath);
9875
11423
  } catch (err) {
9876
11424
  const e = err;
9877
11425
  if (e.code === "ENOENT") {
@@ -9882,14 +11430,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
9882
11430
  }
9883
11431
  throw err;
9884
11432
  }
9885
- let position = await printTail(logPath, stat3.size, opts.tail);
11433
+ let position = await printTail(logPath, stat4.size, opts.tail);
9886
11434
  if (!opts.follow) {
9887
11435
  return;
9888
11436
  }
9889
11437
  process.stdout.write(`-- following ${logPath} --
9890
11438
  `);
9891
11439
  let pending = false;
9892
- const watcher = fs11.watch(logPath, () => {
11440
+ const watcher = fs12.watch(logPath, () => {
9893
11441
  if (pending) {
9894
11442
  return;
9895
11443
  }
@@ -9897,14 +11445,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
9897
11445
  setImmediate(async () => {
9898
11446
  pending = false;
9899
11447
  try {
9900
- const s = await fsp4.stat(logPath);
11448
+ const s = await fsp5.stat(logPath);
9901
11449
  if (s.size <= position) {
9902
11450
  if (s.size < position) {
9903
11451
  position = s.size;
9904
11452
  }
9905
11453
  return;
9906
11454
  }
9907
- const fd = await fsp4.open(logPath, "r");
11455
+ const fd = await fsp5.open(logPath, "r");
9908
11456
  try {
9909
11457
  const buf = Buffer.alloc(s.size - position);
9910
11458
  await fd.read(buf, 0, buf.length, position);
@@ -9931,7 +11479,7 @@ async function printTail(logPath, fileSize, lines) {
9931
11479
  return fileSize;
9932
11480
  }
9933
11481
  const CHUNK = 64 * 1024;
9934
- const fd = await fsp4.open(logPath, "r");
11482
+ const fd = await fsp5.open(logPath, "r");
9935
11483
  try {
9936
11484
  let position = fileSize;
9937
11485
  let collected = "";
@@ -10100,7 +11648,7 @@ async function runDaemonStatus() {
10100
11648
  }
10101
11649
  async function readPidFile() {
10102
11650
  try {
10103
- const raw = await fsp5.readFile(paths.pidFile(), "utf8");
11651
+ const raw = await fsp6.readFile(paths.pidFile(), "utf8");
10104
11652
  return JSON.parse(raw);
10105
11653
  } catch (err) {
10106
11654
  const e = err;
@@ -10125,7 +11673,7 @@ init_sessions();
10125
11673
  // src/cli/commands/extensions.ts
10126
11674
  init_config();
10127
11675
  init_paths();
10128
- import * as fsp6 from "fs/promises";
11676
+ import * as fsp7 from "fs/promises";
10129
11677
  init_sessions();
10130
11678
  async function runExtensionsList() {
10131
11679
  const config = await loadConfig();
@@ -10321,11 +11869,11 @@ async function runExtensionsRemove(name) {
10321
11869
  }
10322
11870
  }
10323
11871
  async function readRawConfig() {
10324
- const raw = await fsp6.readFile(paths.config(), "utf8");
11872
+ const raw = await fsp7.readFile(paths.config(), "utf8");
10325
11873
  return JSON.parse(raw);
10326
11874
  }
10327
11875
  async function writeRawConfig(raw) {
10328
- await fsp6.writeFile(
11876
+ await fsp7.writeFile(
10329
11877
  paths.config(),
10330
11878
  JSON.stringify(raw, null, 2) + "\n",
10331
11879
  { encoding: "utf8", mode: 384 }
@@ -11090,8 +12638,8 @@ async function main() {
11090
12638
  await runSessionsKill(positional[2]);
11091
12639
  return;
11092
12640
  }
11093
- if (sub === "rm") {
11094
- await runSessionsRm(positional[2]);
12641
+ if (sub === "remove") {
12642
+ await runSessionsRemove(positional[2]);
11095
12643
  return;
11096
12644
  }
11097
12645
  if (sub === "export") {
@@ -11100,8 +12648,11 @@ async function main() {
11100
12648
  return;
11101
12649
  }
11102
12650
  if (sub === "import") {
12651
+ const cwd = resolveOption(flags, "cwd");
11103
12652
  await runSessionsImport(positional[2], {
11104
- replace: flags.replace === true
12653
+ replace: flags.replace === true,
12654
+ info: flags.info === true,
12655
+ ...cwd !== void 0 ? { cwd } : {}
11105
12656
  });
11106
12657
  return;
11107
12658
  }
@@ -11204,9 +12755,9 @@ async function dispatchTui(flags, base) {
11204
12755
  }
11205
12756
  function readVersion() {
11206
12757
  try {
11207
- const here = dirname4(fileURLToPath(import.meta.url));
12758
+ const here = dirname6(fileURLToPath2(import.meta.url));
11208
12759
  const pkg = JSON.parse(
11209
- readFileSync(resolve4(here, "../package.json"), "utf8")
12760
+ readFileSync2(resolve4(here, "../package.json"), "utf8")
11210
12761
  );
11211
12762
  return pkg.version ?? "unknown";
11212
12763
  } catch {
@@ -11233,11 +12784,11 @@ function printHelp() {
11233
12784
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
11234
12785
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
11235
12786
  " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
11236
- " 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)",
11237
12788
  " hydra-acp sessions export <id> [--out <file>|.]",
11238
12789
  " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
11239
- " hydra-acp sessions import <file>|- [--replace]",
11240
- " 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",
11241
12792
  " hydra-acp extensions list List configured extensions and live state",
11242
12793
  " hydra-acp extensions add <name> [opts] Add an extension to config",
11243
12794
  " hydra-acp extensions remove <name> Remove an extension from config",