@hydra-acp/cli 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -12,6 +12,19 @@ var __export = (target, all) => {
12
12
  // src/core/paths.ts
13
13
  import * as path from "path";
14
14
  import * as os from "os";
15
+ function shortenHomePath(p) {
16
+ const home = os.homedir();
17
+ if (!home) {
18
+ return p;
19
+ }
20
+ if (p === home) {
21
+ return "~";
22
+ }
23
+ if (p.startsWith(home + "/")) {
24
+ return "~" + p.slice(home.length);
25
+ }
26
+ return p;
27
+ }
15
28
  function hydraHome() {
16
29
  const override = process.env[ROOT_ENV];
17
30
  if (override && override.length > 0) {
@@ -45,6 +58,18 @@ var init_paths = __esm({
45
58
  // machine's binaries cleanly separated. `ls agents/` immediately
46
59
  // shows which platforms have ever installed anything.
47
60
  agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
61
+ // npm install cache for npx-distributed agents. The trailing
62
+ // node<ABI> segment keys on process.versions.modules so a Node
63
+ // major bump (different ABI → native modules incompatible) yields
64
+ // a fresh install rather than failing at require() time.
65
+ agentNpmInstallDir: (id, platformKey, version) => path.join(
66
+ hydraHome(),
67
+ "agents",
68
+ platformKey,
69
+ id,
70
+ version,
71
+ `node${process.versions.modules}`
72
+ ),
48
73
  sessionsDir: () => path.join(hydraHome(), "sessions"),
49
74
  // One directory per session id under sessions/. Co-locates the
50
75
  // session record, its transcript, and any future per-session state
@@ -149,6 +174,9 @@ async function loadConfig() {
149
174
  daemon.authToken = token;
150
175
  return HydraConfig.parse(raw);
151
176
  }
177
+ async function loadConfigReadOnly() {
178
+ return HydraConfigReadOnly.parse(await readConfigFile());
179
+ }
152
180
  async function ensureConfig() {
153
181
  if (!await loadAuthToken()) {
154
182
  const token = generateAuthToken();
@@ -181,7 +209,7 @@ function expandHome(p) {
181
209
  }
182
210
  return p;
183
211
  }
184
- var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig;
212
+ var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig, HydraConfigReadOnly;
185
213
  var init_config = __esm({
186
214
  "src/core/config.ts"() {
187
215
  "use strict";
@@ -197,7 +225,16 @@ var init_config = __esm({
197
225
  authToken: z.string().min(16),
198
226
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
199
227
  tls: TlsConfig.optional(),
200
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
228
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600),
229
+ // Cap on entries kept in a session's on-disk replay log (history.jsonl).
230
+ // Compaction trims to this many on a periodic basis; reads also slice
231
+ // to the tail at this length as a defensive measure against older
232
+ // daemons that may have written unbounded files.
233
+ sessionHistoryMaxEntries: z.number().int().positive().default(1e3),
234
+ // Bytes of trailing agent stderr buffered per AgentInstance so the
235
+ // daemon can include it in the diagnostic message when a spawn fails.
236
+ // Bump if your agents emit large tracebacks you want surfaced.
237
+ agentStderrTailBytes: z.number().int().positive().default(4096)
201
238
  });
202
239
  RegistryConfig = z.object({
203
240
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -220,7 +257,14 @@ var init_config = __esm({
220
257
  // text selection requires shift+drag to bypass mouse reporting. Set
221
258
  // false to disable capture — wheel scrollback stops working, but
222
259
  // plain click-drag selects text via the terminal emulator.
223
- mouse: z.boolean().default(true)
260
+ mouse: z.boolean().default(true),
261
+ // Size at which the TUI's session/update debug log (tui.log) rotates
262
+ // to tui.log.0 and resets. Bounds on-disk use at ~2x this value.
263
+ logMaxBytes: z.number().int().positive().default(5 * 1024 * 1024),
264
+ // Width cap on the cwd column in the `sessions list` output and the
265
+ // TUI picker. Set higher if you keep deeply-nested working directories
266
+ // and want them visible; the elastic title column shrinks to make room.
267
+ cwdColumnMaxWidth: z.number().int().positive().default(24)
224
268
  });
225
269
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
226
270
  ExtensionBody = z.object({
@@ -256,9 +300,14 @@ var init_config = __esm({
256
300
  tui: TuiConfig.default({
257
301
  repaintThrottleMs: 1e3,
258
302
  maxScrollbackLines: 1e4,
259
- mouse: true
303
+ mouse: true,
304
+ logMaxBytes: 5 * 1024 * 1024,
305
+ cwdColumnMaxWidth: 24
260
306
  })
261
307
  });
308
+ HydraConfigReadOnly = HydraConfig.extend({
309
+ daemon: DaemonConfig.omit({ authToken: true })
310
+ });
262
311
  }
263
312
  });
264
313
 
@@ -332,21 +381,33 @@ function extractHydraMeta(meta) {
332
381
  function mergeMeta(passthrough, ours) {
333
382
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
334
383
  }
335
- var JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
384
+ var ACP_PROTOCOL_VERSION, JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
336
385
  var init_types = __esm({
337
386
  "src/acp/types.ts"() {
338
387
  "use strict";
388
+ ACP_PROTOCOL_VERSION = 1;
339
389
  JsonRpcErrorCodes = {
340
390
  ParseError: -32700,
341
391
  InvalidRequest: -32600,
342
392
  MethodNotFound: -32601,
343
393
  InvalidParams: -32602,
344
394
  InternalError: -32603,
395
+ // -32001…-32003 reserved for RFD #533 attach semantics:
396
+ // -32001 Session not found
397
+ // -32002 Not authorised to attach
398
+ // -32003 Session does not support multi-client attach
399
+ // We emit -32001 (matching); the other two are reserved for spec
400
+ // alignment even though we don't currently emit them (we bearer-auth
401
+ // at WS upgrade time and always support multi-client attach).
345
402
  SessionNotFound: -32001,
346
- PermissionDenied: -32002,
347
- AlreadyAttached: -32003,
403
+ NotAuthorisedToAttach: -32002,
404
+ MultiClientNotSupported: -32003,
348
405
  AgentNotInstalled: -32005,
349
- BundleAlreadyImported: -32010
406
+ // Hydra-internal codes — outside the RFD's reserved range so they
407
+ // can't collide with future spec assignments.
408
+ BundleAlreadyImported: -32010,
409
+ PermissionDenied: -32011,
410
+ AlreadyAttached: -32012
350
411
  };
351
412
  InitializeParams = z3.object({
352
413
  protocolVersion: z3.number().optional(),
@@ -356,7 +417,12 @@ var init_types = __esm({
356
417
  version: z3.string().optional()
357
418
  }).optional()
358
419
  });
359
- HistoryPolicy = z3.enum(["full", "pending_only", "none"]);
420
+ HistoryPolicy = z3.enum([
421
+ "full",
422
+ "pending_only",
423
+ "none",
424
+ "after_message"
425
+ ]);
360
426
  SessionNewParams = z3.object({
361
427
  cwd: z3.string(),
362
428
  agentId: z3.string().optional(),
@@ -372,6 +438,18 @@ var init_types = __esm({
372
438
  SessionAttachParams = z3.object({
373
439
  sessionId: z3.string(),
374
440
  historyPolicy: HistoryPolicy.default("full"),
441
+ // Required when historyPolicy is "after_message"; ignored otherwise.
442
+ // The proxy replays history entries strictly after the entry whose
443
+ // messageId matches this value. If the id isn't found in the buffer,
444
+ // the response.historyPolicy field surfaces "full" so the caller
445
+ // knows we fell back. Per RFD #533.
446
+ afterMessageId: z3.string().optional(),
447
+ // Caller-assigned opaque id (e.g. a UUID). When provided, the proxy
448
+ // echoes it in resolvedBy/sentBy and lifecycle events so other
449
+ // clients can disambiguate multiple instances of the same
450
+ // clientInfo.name. When omitted, the proxy assigns one and returns
451
+ // it in the response. Per RFD #533.
452
+ clientId: z3.string().optional(),
375
453
  clientInfo: z3.object({
376
454
  name: z3.string(),
377
455
  version: z3.string().optional()
@@ -528,6 +606,13 @@ var init_connection = __esm({
528
606
  }
529
607
  await this.stream.close();
530
608
  }
609
+ // Force-close with an error. Rejects all pending requests and fires
610
+ // close handlers carrying `err`. Used by transports that detect a
611
+ // failure (e.g. child process crash, spawn ENOENT) the stream itself
612
+ // can't surface as a stdout/stdin error.
613
+ fail(err) {
614
+ this.handleClose(err);
615
+ }
531
616
  handleIncoming(message) {
532
617
  if ("method" in message) {
533
618
  if ("id" in message && message.id !== void 0) {
@@ -651,6 +736,9 @@ var init_hydra_commands = __esm({
651
736
 
652
737
  // src/core/session.ts
653
738
  import { customAlphabet } from "nanoid";
739
+ function generateMessageId() {
740
+ return `m_${generateHydraId()}`;
741
+ }
654
742
  function stripHydraSessionPrefix(id) {
655
743
  return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
656
744
  }
@@ -715,6 +803,97 @@ function extractAdvertisedCommands(params) {
715
803
  }
716
804
  return out;
717
805
  }
806
+ function ensureMessageIdOnUpdate(method, params) {
807
+ if (method !== "session/update" || !params || typeof params !== "object") {
808
+ return params;
809
+ }
810
+ const p = params;
811
+ if (!p.update || typeof p.update !== "object" || Array.isArray(p.update)) {
812
+ return params;
813
+ }
814
+ const u = p.update;
815
+ if (typeof u.messageId === "string") {
816
+ return params;
817
+ }
818
+ return {
819
+ ...params,
820
+ update: { ...p.update, messageId: generateMessageId() }
821
+ };
822
+ }
823
+ function findMessageIdIndex(history, target) {
824
+ for (let i = 0; i < history.length; i++) {
825
+ const entry = history[i];
826
+ if (!entry || entry.method !== "session/update") {
827
+ continue;
828
+ }
829
+ const params = entry.params;
830
+ if (params?.update?.messageId === target) {
831
+ return i;
832
+ }
833
+ }
834
+ return -1;
835
+ }
836
+ function extractToolCallId(params) {
837
+ if (!params || typeof params !== "object") {
838
+ return void 0;
839
+ }
840
+ const toolCall = params.toolCall;
841
+ if (!toolCall || typeof toolCall !== "object") {
842
+ return void 0;
843
+ }
844
+ const id = toolCall.toolCallId;
845
+ return typeof id === "string" ? id : void 0;
846
+ }
847
+ function buildPermissionResolvedUpdate(args) {
848
+ const outcome = extractOutcome(args.result);
849
+ const update = {
850
+ sessionUpdate: "permission_resolved"
851
+ };
852
+ if (args.toolCallId !== void 0) {
853
+ update.toolCallId = args.toolCallId;
854
+ }
855
+ if (outcome) {
856
+ update.outcome = outcome;
857
+ if (outcome.kind === "selected" && typeof outcome.optionId === "string") {
858
+ update.chosenOptionId = outcome.optionId;
859
+ }
860
+ }
861
+ update.resolvedBy = buildResolvedBy(args.resolver);
862
+ return update;
863
+ }
864
+ function extractOutcome(result) {
865
+ if (!result || typeof result !== "object") {
866
+ return void 0;
867
+ }
868
+ const raw = result.outcome;
869
+ if (!raw || typeof raw !== "object") {
870
+ return void 0;
871
+ }
872
+ const kind = raw.kind;
873
+ if (typeof kind !== "string") {
874
+ return void 0;
875
+ }
876
+ const out = { kind };
877
+ const optionId = raw.optionId;
878
+ if (typeof optionId === "string") {
879
+ out.optionId = optionId;
880
+ }
881
+ const reason = raw.reason;
882
+ if (typeof reason === "string") {
883
+ out.reason = reason;
884
+ }
885
+ return out;
886
+ }
887
+ function buildResolvedBy(client) {
888
+ const out = { clientId: client.clientId };
889
+ if (client.clientInfo?.name) {
890
+ out.name = client.clientInfo.name;
891
+ }
892
+ if (client.clientInfo?.version) {
893
+ out.version = client.clientInfo.version;
894
+ }
895
+ return out;
896
+ }
718
897
  function extractPromptText(prompt) {
719
898
  if (typeof prompt === "string") {
720
899
  return prompt;
@@ -739,7 +918,7 @@ function firstLine(text, max) {
739
918
  }
740
919
  return void 0;
741
920
  }
742
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, MAX_HISTORY_ENTRIES, COMPACT_EVERY, Session, STATE_UPDATE_KINDS;
921
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, DEFAULT_HISTORY_MAX_ENTRIES, Session, STATE_UPDATE_KINDS;
743
922
  var init_session = __esm({
744
923
  "src/core/session.ts"() {
745
924
  "use strict";
@@ -748,8 +927,7 @@ var init_session = __esm({
748
927
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
749
928
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
750
929
  HYDRA_SESSION_PREFIX = "hydra_session_";
751
- MAX_HISTORY_ENTRIES = 1e3;
752
- COMPACT_EVERY = 200;
930
+ DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
753
931
  Session = class {
754
932
  sessionId;
755
933
  cwd;
@@ -791,11 +969,13 @@ var init_session = __esm({
791
969
  // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
792
970
  // Drives the mid-turn elapsed counter delivered to fresh attachers.
793
971
  promptStartedAt;
794
- // Counts appends since the last compaction. When it hits COMPACT_EVERY
972
+ // Counts appends since the last compaction. When it hits compactEvery
795
973
  // we ask the history store to trim the file to the most recent
796
- // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
974
+ // historyMaxEntries. Keeps file growth bounded without per-append
797
975
  // file-size checks.
798
976
  appendCount = 0;
977
+ historyMaxEntries;
978
+ compactEvery;
799
979
  // Permission requests that have been broadcast to one or more
800
980
  // clients but have not yet resolved. Replayed to clients that
801
981
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -852,6 +1032,8 @@ var init_session = __esm({
852
1032
  this.firstPromptSeeded = true;
853
1033
  }
854
1034
  this.historyStore = init.historyStore;
1035
+ this.historyMaxEntries = init.historyMaxEntries ?? DEFAULT_HISTORY_MAX_ENTRIES;
1036
+ this.compactEvery = Math.max(1, Math.floor(this.historyMaxEntries * 0.2));
855
1037
  this.updatedAt = Date.now();
856
1038
  this.createdAt = init.createdAt ?? this.updatedAt;
857
1039
  this.lastRecordedAt = this.updatedAt;
@@ -919,6 +1101,30 @@ var init_session = __esm({
919
1101
  get attachedCount() {
920
1102
  return this.clients.size;
921
1103
  }
1104
+ // Roster of currently-attached clients, optionally excluding one
1105
+ // clientId. Used by the daemon to populate connectedClients on the
1106
+ // session/attach response (per RFD #533) — the freshly-attaching
1107
+ // client wants to see who else is on the session but not itself in
1108
+ // the list.
1109
+ connectedClients(excludeClientId) {
1110
+ const out = [];
1111
+ for (const client of this.clients.values()) {
1112
+ if (excludeClientId && client.clientId === excludeClientId) {
1113
+ continue;
1114
+ }
1115
+ const entry = {
1116
+ clientId: client.clientId
1117
+ };
1118
+ if (client.clientInfo?.name) {
1119
+ entry.name = client.clientInfo.name;
1120
+ }
1121
+ if (client.clientInfo?.version) {
1122
+ entry.version = client.clientInfo.version;
1123
+ }
1124
+ out.push(entry);
1125
+ }
1126
+ return out;
1127
+ }
922
1128
  // Wall-clock when the in-flight agent turn began, or undefined when
923
1129
  // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
924
1130
  // so the daemon can hand a fresh attacher mid-turn the right elapsed
@@ -950,10 +1156,12 @@ var init_session = __esm({
950
1156
  };
951
1157
  }
952
1158
  // Register a client and (asynchronously) load the replay slice it
953
- // should receive. Validation errors throw synchronously so callers
954
- // can rely on either the registration being in effect or having
955
- // thrown; the disk-load is the only async work.
956
- attach(client, historyPolicy) {
1159
+ // should receive. Returns both the slice to replay and the actual
1160
+ // historyPolicy applied (which may differ from the requested one
1161
+ // when after_message falls back to full). Validation errors throw
1162
+ // synchronously so callers can rely on either the registration being
1163
+ // in effect or having thrown; the disk-load is the only async work.
1164
+ attach(client, historyPolicy, opts = {}) {
957
1165
  if (this.closed) {
958
1166
  throw withCode(
959
1167
  new Error("session is closed"),
@@ -969,9 +1177,20 @@ var init_session = __esm({
969
1177
  this.clients.set(client.clientId, client);
970
1178
  this.updatedAt = Date.now();
971
1179
  if (historyPolicy === "none" || historyPolicy === "pending_only") {
972
- return Promise.resolve([]);
1180
+ return Promise.resolve({ entries: [], appliedPolicy: historyPolicy });
973
1181
  }
974
- return this.getHistorySnapshot();
1182
+ return this.loadReplay(historyPolicy, opts);
1183
+ }
1184
+ async loadReplay(historyPolicy, opts) {
1185
+ const all = await this.getHistorySnapshot();
1186
+ if (historyPolicy === "after_message") {
1187
+ const cutoff = opts.afterMessageId ? findMessageIdIndex(all, opts.afterMessageId) : -1;
1188
+ if (cutoff < 0) {
1189
+ return { entries: all, appliedPolicy: "full" };
1190
+ }
1191
+ return { entries: all.slice(cutoff + 1), appliedPolicy: "after_message" };
1192
+ }
1193
+ return { entries: all, appliedPolicy: "full" };
975
1194
  }
976
1195
  // Dispatch in-flight permission requests to a freshly-attached
977
1196
  // client. Called by the daemon's WS handler *after* it finishes
@@ -983,8 +1202,39 @@ var init_session = __esm({
983
1202
  }
984
1203
  }
985
1204
  detach(clientId) {
986
- if (this.clients.delete(clientId)) {
987
- this.updatedAt = Date.now();
1205
+ const leaving = this.clients.get(clientId);
1206
+ if (!leaving) {
1207
+ return;
1208
+ }
1209
+ this.clients.delete(clientId);
1210
+ this.updatedAt = Date.now();
1211
+ this.broadcastClientDisconnected(leaving);
1212
+ }
1213
+ // Notify remaining attached clients that a peer just left, per
1214
+ // RFD #533. Fires for both explicit session/detach and ws-close
1215
+ // teardown (acp-ws calls Session.detach() in both paths). The
1216
+ // notification is broadcast (not recorded) — peer presence is
1217
+ // transient, not part of conversation history.
1218
+ broadcastClientDisconnected(client) {
1219
+ const info = {
1220
+ clientId: client.clientId
1221
+ };
1222
+ if (client.clientInfo?.name) {
1223
+ info.name = client.clientInfo.name;
1224
+ }
1225
+ if (client.clientInfo?.version) {
1226
+ info.version = client.clientInfo.version;
1227
+ }
1228
+ const update = {
1229
+ sessionUpdate: "client_disconnected",
1230
+ client: info,
1231
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1232
+ };
1233
+ for (const peer of this.clients.values()) {
1234
+ void peer.connection.notify("session/update", {
1235
+ sessionId: this.sessionId,
1236
+ update
1237
+ }).catch(() => void 0);
988
1238
  }
989
1239
  }
990
1240
  async prompt(clientId, params) {
@@ -1037,6 +1287,7 @@ var init_session = __esm({
1037
1287
  sessionId: this.sessionId,
1038
1288
  update: {
1039
1289
  sessionUpdate: "prompt_received",
1290
+ messageId: generateMessageId(),
1040
1291
  prompt: promptParams.prompt,
1041
1292
  sentBy
1042
1293
  }
@@ -1062,7 +1313,8 @@ var init_session = __esm({
1062
1313
  broadcastTurnComplete(originatorClientId, response) {
1063
1314
  const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
1064
1315
  const update = {
1065
- sessionUpdate: "turn_complete"
1316
+ sessionUpdate: "turn_complete",
1317
+ messageId: generateMessageId()
1066
1318
  };
1067
1319
  if (stopReason !== void 0) {
1068
1320
  update.stopReason = stopReason;
@@ -1679,10 +1931,11 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1679
1931
  recordAndBroadcast(method, params, excludeClientId) {
1680
1932
  const rewritten = this.rewriteForClient(params);
1681
1933
  const recordable = !isStateUpdate(method, rewritten);
1934
+ const broadcast = recordable ? ensureMessageIdOnUpdate(method, rewritten) : rewritten;
1682
1935
  if (recordable) {
1683
1936
  const entry = {
1684
1937
  method,
1685
- params: rewritten,
1938
+ params: broadcast,
1686
1939
  recordedAt: Date.now()
1687
1940
  };
1688
1941
  this.lastRecordedAt = entry.recordedAt;
@@ -1690,9 +1943,9 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1690
1943
  if (this.historyStore) {
1691
1944
  const store = this.historyStore;
1692
1945
  void store.append(this.sessionId, entry).catch(() => void 0);
1693
- if (this.appendCount >= COMPACT_EVERY) {
1946
+ if (this.appendCount >= this.compactEvery) {
1694
1947
  this.appendCount = 0;
1695
- void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1948
+ void store.compact(this.sessionId, this.historyMaxEntries).catch(
1696
1949
  () => void 0
1697
1950
  );
1698
1951
  }
@@ -1710,7 +1963,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1710
1963
  if (excludeClientId && client.clientId === excludeClientId) {
1711
1964
  continue;
1712
1965
  }
1713
- void client.connection.notify(method, rewritten).catch(() => void 0);
1966
+ void client.connection.notify(method, broadcast).catch(() => void 0);
1714
1967
  }
1715
1968
  }
1716
1969
  async handlePermissionRequest(params) {
@@ -1722,11 +1975,13 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1722
1975
  );
1723
1976
  }
1724
1977
  const clientParams = this.rewriteForClient(params);
1978
+ const toolCallId = extractToolCallId(clientParams);
1725
1979
  return new Promise((resolve5, reject) => {
1726
1980
  let settled = false;
1727
1981
  const outbound = [];
1728
1982
  const entry = { addClient: sendTo };
1729
1983
  this.inFlightPermissions.add(entry);
1984
+ const sessionId = this.sessionId;
1730
1985
  const settle = (fn) => {
1731
1986
  if (settled) {
1732
1987
  return;
@@ -1739,22 +1994,25 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1739
1994
  if (settled) {
1740
1995
  return;
1741
1996
  }
1742
- const { id, response } = client.connection.requestWithId(
1997
+ const response = client.connection.request(
1743
1998
  "session/request_permission",
1744
1999
  clientParams
1745
2000
  );
1746
- outbound.push({ client, id });
2001
+ outbound.push({ client });
1747
2002
  void response.then((result) => {
1748
2003
  settle(() => {
2004
+ const update = buildPermissionResolvedUpdate({
2005
+ toolCallId,
2006
+ result,
2007
+ resolver: client
2008
+ });
1749
2009
  for (const o of outbound) {
1750
2010
  if (o.client.clientId === client.clientId) {
1751
2011
  continue;
1752
2012
  }
1753
- void o.client.connection.notify("session/permission_resolved", {
1754
- ...clientParams,
1755
- requestId: o.id,
1756
- resolvedBy: client.clientId,
1757
- result
2013
+ void o.client.connection.notify("session/update", {
2014
+ sessionId,
2015
+ update
1758
2016
  }).catch(() => void 0);
1759
2017
  }
1760
2018
  resolve5(result);
@@ -1809,9 +2067,187 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1809
2067
  }
1810
2068
  });
1811
2069
 
2070
+ // src/core/session-store.ts
2071
+ import * as fs5 from "fs/promises";
2072
+ import * as path4 from "path";
2073
+ import { customAlphabet as customAlphabet2 } from "nanoid";
2074
+ import { z as z4 } from "zod";
2075
+ function generateLineageId() {
2076
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
2077
+ }
2078
+ function assertSafeId(id) {
2079
+ if (!SESSION_ID_PATTERN.test(id)) {
2080
+ throw new Error(`unsafe session id: ${id}`);
2081
+ }
2082
+ }
2083
+ function recordFromMemorySession(args) {
2084
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2085
+ return {
2086
+ sessionId: args.sessionId,
2087
+ lineageId: args.lineageId,
2088
+ upstreamSessionId: args.upstreamSessionId,
2089
+ importedFromSessionId: args.importedFromSessionId,
2090
+ agentId: args.agentId,
2091
+ cwd: args.cwd,
2092
+ title: args.title,
2093
+ agentArgs: args.agentArgs,
2094
+ currentModel: args.currentModel,
2095
+ currentMode: args.currentMode,
2096
+ currentUsage: args.currentUsage,
2097
+ agentCommands: args.agentCommands,
2098
+ createdAt: args.createdAt ?? now,
2099
+ updatedAt: args.updatedAt ?? now
2100
+ };
2101
+ }
2102
+ var HYDRA_ID_ALPHABET2, generateRawId, HYDRA_LINEAGE_PREFIX, PersistedAgentCommand, PersistedUsage, SessionRecord, SESSION_ID_PATTERN, SessionStore;
2103
+ var init_session_store = __esm({
2104
+ "src/core/session-store.ts"() {
2105
+ "use strict";
2106
+ init_paths();
2107
+ HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2108
+ generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
2109
+ HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
2110
+ PersistedAgentCommand = z4.object({
2111
+ name: z4.string(),
2112
+ description: z4.string().optional()
2113
+ });
2114
+ PersistedUsage = z4.object({
2115
+ used: z4.number().optional(),
2116
+ size: z4.number().optional(),
2117
+ costAmount: z4.number().optional(),
2118
+ costCurrency: z4.string().optional()
2119
+ });
2120
+ SessionRecord = z4.object({
2121
+ version: z4.literal(1),
2122
+ sessionId: z4.string(),
2123
+ // Optional for back-compat with records written before this field
2124
+ // existed; mergeForPersistence generates one on next write so any
2125
+ // touched session converges to having a lineageId. A record that
2126
+ // never gets written again (truly cold and untouched) just won't
2127
+ // participate in lineage-based dedup, which is correct — it was
2128
+ // never exported, so no incoming bundle can claim its lineage.
2129
+ lineageId: z4.string().optional(),
2130
+ upstreamSessionId: z4.string(),
2131
+ // When non-empty, marks a session that was created by import and is
2132
+ // waiting for its first attach to bootstrap a fresh upstream agent
2133
+ // and replay the imported history as a takeover transcript. The
2134
+ // origin's local id at export time, kept for debuggability and as a
2135
+ // breadcrumb in `sessions list` (informational, not used for routing).
2136
+ importedFromSessionId: z4.string().optional(),
2137
+ agentId: z4.string(),
2138
+ cwd: z4.string(),
2139
+ title: z4.string().optional(),
2140
+ agentArgs: z4.array(z4.string()).optional(),
2141
+ // Snapshot of "what is currently true about this session" carried in
2142
+ // meta.json so a late-attaching or cold-resurrected client can be
2143
+ // told via the attach response _meta without depending on history
2144
+ // replay of a snapshot-shaped notification.
2145
+ currentModel: z4.string().optional(),
2146
+ currentMode: z4.string().optional(),
2147
+ currentUsage: PersistedUsage.optional(),
2148
+ agentCommands: z4.array(PersistedAgentCommand).optional(),
2149
+ createdAt: z4.string(),
2150
+ updatedAt: z4.string()
2151
+ });
2152
+ SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
2153
+ SessionStore = class {
2154
+ async write(record) {
2155
+ assertSafeId(record.sessionId);
2156
+ await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2157
+ const full = { version: 1, ...record };
2158
+ await fs5.writeFile(
2159
+ paths.sessionFile(record.sessionId),
2160
+ JSON.stringify(full, null, 2) + "\n",
2161
+ { encoding: "utf8", mode: 384 }
2162
+ );
2163
+ }
2164
+ async read(sessionId) {
2165
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
2166
+ return void 0;
2167
+ }
2168
+ let raw;
2169
+ try {
2170
+ raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
2171
+ } catch (err) {
2172
+ const e = err;
2173
+ if (e.code === "ENOENT") {
2174
+ return void 0;
2175
+ }
2176
+ throw err;
2177
+ }
2178
+ try {
2179
+ return SessionRecord.parse(JSON.parse(raw));
2180
+ } catch {
2181
+ return void 0;
2182
+ }
2183
+ }
2184
+ async delete(sessionId) {
2185
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
2186
+ return;
2187
+ }
2188
+ try {
2189
+ await fs5.unlink(paths.sessionFile(sessionId));
2190
+ } catch (err) {
2191
+ const e = err;
2192
+ if (e.code !== "ENOENT") {
2193
+ throw err;
2194
+ }
2195
+ }
2196
+ try {
2197
+ await fs5.rmdir(paths.sessionDir(sessionId));
2198
+ } catch (err) {
2199
+ const e = err;
2200
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
2201
+ throw err;
2202
+ }
2203
+ }
2204
+ }
2205
+ // Find a persisted session by lineageId. Used by SessionManager.import
2206
+ // to detect bundles that have already been imported (lineageId match)
2207
+ // so we can either error out or, with replace:true, overwrite.
2208
+ // Returns undefined if no record has that lineageId. Records that
2209
+ // pre-date the lineageId field simply don't match — which is
2210
+ // correct: they were never exported, so no incoming bundle can
2211
+ // legitimately claim their lineage.
2212
+ async findByLineageId(lineageId) {
2213
+ if (lineageId.length === 0) {
2214
+ return void 0;
2215
+ }
2216
+ const all = await this.list().catch(() => []);
2217
+ for (const record of all) {
2218
+ if (record.lineageId === lineageId) {
2219
+ return record;
2220
+ }
2221
+ }
2222
+ return void 0;
2223
+ }
2224
+ async list() {
2225
+ let entries;
2226
+ try {
2227
+ entries = await fs5.readdir(paths.sessionsDir());
2228
+ } catch (err) {
2229
+ const e = err;
2230
+ if (e.code === "ENOENT") {
2231
+ return [];
2232
+ }
2233
+ throw err;
2234
+ }
2235
+ const records = [];
2236
+ for (const entry of entries) {
2237
+ const record = await this.read(entry);
2238
+ if (record) {
2239
+ records.push(record);
2240
+ }
2241
+ }
2242
+ return records;
2243
+ }
2244
+ };
2245
+ }
2246
+ });
2247
+
1812
2248
  // src/tui/history.ts
1813
2249
  import { promises as fs7 } from "fs";
1814
- import * as path4 from "path";
2250
+ import * as path5 from "path";
1815
2251
  async function loadHistory(file) {
1816
2252
  let text;
1817
2253
  try {
@@ -1855,7 +2291,7 @@ function appendEntry(history, entry) {
1855
2291
  return out;
1856
2292
  }
1857
2293
  async function saveHistory(file, history) {
1858
- await fs7.mkdir(path4.dirname(file), { recursive: true });
2294
+ await fs7.mkdir(path5.dirname(file), { recursive: true });
1859
2295
  const lines = history.map((entry) => JSON.stringify(entry));
1860
2296
  await fs7.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1861
2297
  }
@@ -1867,6 +2303,113 @@ var init_history = __esm({
1867
2303
  }
1868
2304
  });
1869
2305
 
2306
+ // src/core/hydra-version.ts
2307
+ import { fileURLToPath } from "url";
2308
+ import * as path6 from "path";
2309
+ import * as fs8 from "fs";
2310
+ function resolveVersion() {
2311
+ try {
2312
+ let dir = path6.dirname(fileURLToPath(import.meta.url));
2313
+ for (let i = 0; i < 8; i += 1) {
2314
+ const candidate = path6.join(dir, "package.json");
2315
+ if (fs8.existsSync(candidate)) {
2316
+ const pkg = JSON.parse(fs8.readFileSync(candidate, "utf8"));
2317
+ if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
2318
+ return pkg.version;
2319
+ }
2320
+ }
2321
+ const parent = path6.dirname(dir);
2322
+ if (parent === dir) {
2323
+ break;
2324
+ }
2325
+ dir = parent;
2326
+ }
2327
+ } catch {
2328
+ }
2329
+ return "0.0.0";
2330
+ }
2331
+ var HYDRA_VERSION;
2332
+ var init_hydra_version = __esm({
2333
+ "src/core/hydra-version.ts"() {
2334
+ "use strict";
2335
+ HYDRA_VERSION = resolveVersion();
2336
+ }
2337
+ });
2338
+
2339
+ // src/core/bundle.ts
2340
+ import { z as z5 } from "zod";
2341
+ function encodeBundle(params) {
2342
+ const bundle = {
2343
+ version: 1,
2344
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2345
+ exportedFrom: {
2346
+ hydraVersion: params.hydraVersion,
2347
+ machine: params.machine
2348
+ },
2349
+ session: {
2350
+ sessionId: params.record.sessionId,
2351
+ lineageId: params.record.lineageId,
2352
+ agentId: params.record.agentId,
2353
+ cwd: params.record.cwd,
2354
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
2355
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
2356
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
2357
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
2358
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
2359
+ createdAt: params.record.createdAt,
2360
+ updatedAt: params.record.updatedAt
2361
+ },
2362
+ history: params.history
2363
+ };
2364
+ if (params.promptHistory !== void 0) {
2365
+ bundle.promptHistory = params.promptHistory;
2366
+ }
2367
+ return bundle;
2368
+ }
2369
+ function decodeBundle(raw) {
2370
+ return Bundle.parse(raw);
2371
+ }
2372
+ var HistoryEntrySchema, BundleSession, Bundle;
2373
+ var init_bundle = __esm({
2374
+ "src/core/bundle.ts"() {
2375
+ "use strict";
2376
+ init_session_store();
2377
+ HistoryEntrySchema = z5.object({
2378
+ method: z5.string(),
2379
+ params: z5.unknown(),
2380
+ recordedAt: z5.number()
2381
+ });
2382
+ BundleSession = z5.object({
2383
+ // The exporter's local id. Regenerated fresh on import (sessionId is
2384
+ // the local namespace; lineageId is what survives across hops).
2385
+ sessionId: z5.string(),
2386
+ // Required on bundles — the export path backfills if the source
2387
+ // record was written before lineageId existed.
2388
+ lineageId: z5.string(),
2389
+ agentId: z5.string(),
2390
+ cwd: z5.string(),
2391
+ title: z5.string().optional(),
2392
+ currentModel: z5.string().optional(),
2393
+ currentMode: z5.string().optional(),
2394
+ currentUsage: PersistedUsage.optional(),
2395
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
2396
+ createdAt: z5.string(),
2397
+ updatedAt: z5.string()
2398
+ });
2399
+ Bundle = z5.object({
2400
+ version: z5.literal(1),
2401
+ exportedAt: z5.string(),
2402
+ exportedFrom: z5.object({
2403
+ hydraVersion: z5.string(),
2404
+ machine: z5.string()
2405
+ }),
2406
+ session: BundleSession,
2407
+ history: z5.array(HistoryEntrySchema),
2408
+ promptHistory: z5.array(z5.string()).optional()
2409
+ });
2410
+ }
2411
+ });
2412
+
1870
2413
  // src/acp/ws-stream.ts
1871
2414
  function wsToMessageStream(ws) {
1872
2415
  const messageHandlers = [];
@@ -1945,7 +2488,7 @@ var init_ws_stream = __esm({
1945
2488
  });
1946
2489
 
1947
2490
  // src/core/daemon-bootstrap.ts
1948
- import { spawn as spawn4 } from "child_process";
2491
+ import { spawn as spawn5 } from "child_process";
1949
2492
  import { setTimeout as sleep } from "timers/promises";
1950
2493
  async function ensureDaemonReachable(config) {
1951
2494
  if (await pingHealth(config)) {
@@ -1972,7 +2515,7 @@ function spawnDaemonDetached() {
1972
2515
  if (!cliPath) {
1973
2516
  throw new Error("Cannot determine hydra-acp binary path to spawn daemon");
1974
2517
  }
1975
- const child = spawn4(
2518
+ const child = spawn5(
1976
2519
  process.execPath,
1977
2520
  [cliPath, "daemon", "start", "--foreground"],
1978
2521
  {
@@ -2020,8 +2563,8 @@ function formatAgentWithModel(agentId, model) {
2020
2563
  }
2021
2564
  return `${agent}${AGENT_MODEL_SEP}${short}`;
2022
2565
  }
2023
- function formatAgentCell(agentId, model, usage) {
2024
- const base = formatAgentWithModel(agentId, model);
2566
+ function formatAgentCell(agentId, usage) {
2567
+ const base = agentId ?? "?";
2025
2568
  if (!usage || typeof usage.costAmount !== "number") {
2026
2569
  return base;
2027
2570
  }
@@ -2058,10 +2601,10 @@ function toRow(s, now = Date.now()) {
2058
2601
  session: stripHydraSessionPrefix(s.sessionId),
2059
2602
  upstream: s.upstreamSessionId ?? "-",
2060
2603
  state: formatState(s.status, s.attachedClients),
2061
- agent: formatAgentCell(s.agentId, s.currentModel, s.currentUsage),
2604
+ agent: formatAgentCell(s.agentId, s.currentUsage),
2062
2605
  age: formatRelativeAge(s.updatedAt, now),
2063
2606
  title: s.title ?? "-",
2064
- cwd: s.cwd
2607
+ cwd: shortenHomePath(s.cwd)
2065
2608
  };
2066
2609
  }
2067
2610
  function formatState(status, clients) {
@@ -2077,6 +2620,7 @@ function computeWidths(rows) {
2077
2620
  state: maxLen(HEADER.state, rows.map((r) => r.state)),
2078
2621
  agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
2079
2622
  age: maxLen(HEADER.age, rows.map((r) => r.age)),
2623
+ cwd: maxLen(HEADER.cwd, rows.map((r) => r.cwd)),
2080
2624
  title: maxLen(HEADER.title, rows.map((r) => r.title))
2081
2625
  };
2082
2626
  }
@@ -2125,7 +2669,7 @@ function maxLen(headerCell, values) {
2125
2669
  }
2126
2670
  return max;
2127
2671
  }
2128
- function formatRow(r, w, maxWidth) {
2672
+ function formatRow(r, w, maxWidth, cwdMaxWidth = DEFAULT_CWD_MAX_WIDTH) {
2129
2673
  const fixed = [
2130
2674
  r.session.padEnd(w.session),
2131
2675
  r.upstream.padEnd(w.upstream),
@@ -2134,20 +2678,18 @@ function formatRow(r, w, maxWidth) {
2134
2678
  r.age.padStart(w.age)
2135
2679
  ].join(SEP);
2136
2680
  if (maxWidth === void 0) {
2137
- return [fixed, r.title.padEnd(w.title), r.cwd].join(SEP);
2681
+ return [fixed, r.cwd.padEnd(w.cwd), r.title].join(SEP);
2138
2682
  }
2139
- const titleCap = Math.min(w.title, TITLE_MAX_WIDTH);
2140
2683
  const budget = maxWidth - fixed.length - SEP.length;
2141
2684
  if (budget <= 0) {
2142
2685
  return fixed.slice(0, maxWidth);
2143
2686
  }
2144
- const titleNatural = Math.min(r.title.length, titleCap);
2145
- let titleAlloc = titleNatural + SEP.length + MIN_CWD <= budget ? titleCap : Math.max(0, budget - SEP.length - MIN_CWD);
2146
- titleAlloc = Math.min(titleAlloc, Math.max(0, budget - SEP.length - 1));
2147
- const titleCell = truncateRight(r.title, titleAlloc).padEnd(titleAlloc);
2148
- const cwdBudget = Math.max(0, budget - titleAlloc - SEP.length);
2149
- const cwdCell = truncateMiddle(r.cwd, cwdBudget);
2150
- return [fixed, titleCell, cwdCell].join(SEP);
2687
+ const cwdCap = Math.min(w.cwd, cwdMaxWidth);
2688
+ const cwdAlloc = Math.min(cwdCap, Math.max(0, budget - SEP.length - 1));
2689
+ const cwdCell = truncateMiddle(r.cwd, cwdAlloc).padEnd(cwdAlloc);
2690
+ const titleBudget = Math.max(0, budget - cwdAlloc - SEP.length);
2691
+ const titleCell = truncateRight(r.title, titleBudget);
2692
+ return [fixed, cwdCell, titleCell].join(SEP);
2151
2693
  }
2152
2694
  function truncateRight(s, max) {
2153
2695
  if (max <= 0) {
@@ -2175,11 +2717,12 @@ function truncateMiddle(s, max) {
2175
2717
  const tail = max - 1 - head;
2176
2718
  return s.slice(0, head) + "\u2026" + s.slice(s.length - tail);
2177
2719
  }
2178
- var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
2720
+ var HEADER, SEP, DEFAULT_CWD_MAX_WIDTH;
2179
2721
  var init_session_row = __esm({
2180
2722
  "src/cli/session-row.ts"() {
2181
2723
  "use strict";
2182
2724
  init_agent_display();
2725
+ init_paths();
2183
2726
  init_session();
2184
2727
  HEADER = {
2185
2728
  session: "SESSION",
@@ -2191,14 +2734,13 @@ var init_session_row = __esm({
2191
2734
  cwd: "CWD"
2192
2735
  };
2193
2736
  SEP = " ";
2194
- MIN_CWD = 8;
2195
- TITLE_MAX_WIDTH = 40;
2737
+ DEFAULT_CWD_MAX_WIDTH = 24;
2196
2738
  }
2197
2739
  });
2198
2740
 
2199
2741
  // src/cli/commands/sessions.ts
2200
- import * as fs12 from "fs/promises";
2201
- import * as path6 from "path";
2742
+ import * as fs13 from "fs/promises";
2743
+ import * as path8 from "path";
2202
2744
  async function runSessionsList(opts = {}) {
2203
2745
  const config = await loadConfig();
2204
2746
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -2237,9 +2779,10 @@ async function runSessionsList(opts = {}) {
2237
2779
  const rows = visible.map((s) => toRow(s, now));
2238
2780
  const widths = computeWidths(rows);
2239
2781
  const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
2240
- process.stdout.write(formatRow(HEADER, widths, maxWidth) + "\n");
2782
+ const cwdMax = config.tui.cwdColumnMaxWidth;
2783
+ process.stdout.write(formatRow(HEADER, widths, maxWidth, cwdMax) + "\n");
2241
2784
  for (const r of rows) {
2242
- process.stdout.write(formatRow(r, widths, maxWidth) + "\n");
2785
+ process.stdout.write(formatRow(r, widths, maxWidth, cwdMax) + "\n");
2243
2786
  }
2244
2787
  if (truncated > 0) {
2245
2788
  process.stdout.write(
@@ -2268,9 +2811,9 @@ async function runSessionsKill(id) {
2268
2811
  process.stdout.write(`Killed ${id}
2269
2812
  `);
2270
2813
  }
2271
- async function runSessionsRm(id) {
2814
+ async function runSessionsRemove(id) {
2272
2815
  if (!id) {
2273
- process.stderr.write("Usage: hydra-acp sessions rm <session-id>\n");
2816
+ process.stderr.write("Usage: hydra-acp sessions remove <session-id>\n");
2274
2817
  process.exit(2);
2275
2818
  }
2276
2819
  const config = await loadConfig();
@@ -2317,23 +2860,40 @@ async function runSessionsExport(id, outPath) {
2317
2860
  return;
2318
2861
  }
2319
2862
  const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
2320
- await fs12.mkdir(path6.dirname(path6.resolve(resolved)), { recursive: true });
2321
- await fs12.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2863
+ await fs13.mkdir(path8.dirname(path8.resolve(resolved)), { recursive: true });
2864
+ await fs13.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2322
2865
  process.stdout.write(`Wrote ${resolved}
2323
2866
  `);
2324
2867
  }
2325
2868
  async function runSessionsImport(file, opts = {}) {
2326
2869
  if (!file) {
2327
2870
  process.stderr.write(
2328
- "Usage: hydra-acp sessions import <file>|- [--replace]\n"
2871
+ "Usage: hydra-acp sessions import <file>|- [--replace] [--cwd <path>] [--info]\n"
2329
2872
  );
2330
2873
  process.exit(2);
2331
2874
  }
2875
+ let cwdOverride;
2876
+ if (opts.cwd !== void 0) {
2877
+ const resolved = path8.resolve(opts.cwd);
2878
+ try {
2879
+ const stat4 = await fs13.stat(resolved);
2880
+ if (!stat4.isDirectory()) {
2881
+ process.stderr.write(`--cwd ${resolved} is not a directory
2882
+ `);
2883
+ process.exit(1);
2884
+ }
2885
+ } catch {
2886
+ process.stderr.write(`--cwd ${resolved} does not exist
2887
+ `);
2888
+ process.exit(1);
2889
+ }
2890
+ cwdOverride = resolved;
2891
+ }
2332
2892
  let body;
2333
2893
  if (file === "-") {
2334
2894
  body = await readStdin();
2335
2895
  } else {
2336
- body = await fs12.readFile(file, "utf8");
2896
+ body = await fs13.readFile(file, "utf8");
2337
2897
  }
2338
2898
  let bundle;
2339
2899
  try {
@@ -2343,6 +2903,11 @@ async function runSessionsImport(file, opts = {}) {
2343
2903
  `);
2344
2904
  process.exit(1);
2345
2905
  }
2906
+ if (opts.info === true) {
2907
+ const inspectConfig = await loadConfigReadOnly();
2908
+ printBundleInfo(bundle, inspectConfig.tui.cwdColumnMaxWidth);
2909
+ return;
2910
+ }
2346
2911
  const config = await loadConfig();
2347
2912
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2348
2913
  const response = await fetch(`${baseUrl}/v1/sessions/import`, {
@@ -2351,7 +2916,11 @@ async function runSessionsImport(file, opts = {}) {
2351
2916
  "Content-Type": "application/json",
2352
2917
  Authorization: `Bearer ${config.daemon.authToken}`
2353
2918
  },
2354
- body: JSON.stringify({ bundle, replace: opts.replace === true })
2919
+ body: JSON.stringify({
2920
+ bundle,
2921
+ replace: opts.replace === true,
2922
+ ...cwdOverride !== void 0 ? { cwd: cwdOverride } : {}
2923
+ })
2355
2924
  });
2356
2925
  if (response.status === 409) {
2357
2926
  const detail = await response.json().catch(() => ({}));
@@ -2374,6 +2943,42 @@ async function runSessionsImport(file, opts = {}) {
2374
2943
  `
2375
2944
  );
2376
2945
  }
2946
+ function bundleToSummary(parsed) {
2947
+ return {
2948
+ sessionId: parsed.session.sessionId,
2949
+ upstreamSessionId: "-",
2950
+ cwd: parsed.session.cwd,
2951
+ agentId: parsed.session.agentId,
2952
+ currentUsage: parsed.session.currentUsage,
2953
+ title: parsed.session.title,
2954
+ attachedClients: 0,
2955
+ updatedAt: parsed.session.updatedAt,
2956
+ status: "cold"
2957
+ };
2958
+ }
2959
+ function printBundleInfo(raw, cwdColumnMaxWidth) {
2960
+ let parsed;
2961
+ try {
2962
+ parsed = decodeBundle(raw);
2963
+ } catch (err) {
2964
+ process.stderr.write(`Not a valid bundle: ${err.message}
2965
+ `);
2966
+ process.exit(1);
2967
+ }
2968
+ const summary = bundleToSummary(parsed);
2969
+ const row = toRow(summary);
2970
+ const widths = computeWidths([row]);
2971
+ const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
2972
+ process.stdout.write(formatRow(HEADER, widths, maxWidth, cwdColumnMaxWidth) + "\n");
2973
+ process.stdout.write(formatRow(row, widths, maxWidth, cwdColumnMaxWidth) + "\n");
2974
+ process.stdout.write(
2975
+ `
2976
+ lineage: ${parsed.session.lineageId}
2977
+ exported: ${parsed.exportedAt} from ${parsed.exportedFrom.machine} (hydra ${parsed.exportedFrom.hydraVersion})
2978
+ history entries: ${parsed.history.length}` + (parsed.promptHistory ? `, prompt history: ${parsed.promptHistory.length}
2979
+ ` : "\n")
2980
+ );
2981
+ }
2377
2982
  async function readStdin() {
2378
2983
  const chunks = [];
2379
2984
  for await (const chunk of process.stdin) {
@@ -2400,6 +3005,7 @@ var init_sessions = __esm({
2400
3005
  "src/cli/commands/sessions.ts"() {
2401
3006
  "use strict";
2402
3007
  init_config();
3008
+ init_bundle();
2403
3009
  init_session_row();
2404
3010
  }
2405
3011
  });
@@ -2734,12 +3340,15 @@ async function pickSession(term, opts) {
2734
3340
  return b.updatedAt.localeCompare(a.updatedAt);
2735
3341
  });
2736
3342
  };
2737
- let visible = sortSessions(opts.sessions);
3343
+ let allSessions = sortSessions(opts.sessions);
3344
+ let visible = allSessions;
2738
3345
  let rows = visible.map((s) => toRow(s, Date.now()));
2739
3346
  let widths = computeWidths(rows);
2740
3347
  let total = 1 + visible.length;
2741
3348
  let selectedIdx = 0;
2742
3349
  let scrollOffset = 0;
3350
+ let searchActive = false;
3351
+ let searchTerm = "";
2743
3352
  let mode = "normal";
2744
3353
  let pendingAction = null;
2745
3354
  let transientStatus = null;
@@ -2750,6 +3359,7 @@ async function pickSession(term, opts) {
2750
3359
  let headerLine = "";
2751
3360
  let sessionLines = [];
2752
3361
  let startRow = 1;
3362
+ const cwdMaxWidth = opts.config.tui.cwdColumnMaxWidth;
2753
3363
  const computeLayout = () => {
2754
3364
  termHeight = readTermHeight(term);
2755
3365
  termWidth = readTermWidth(term);
@@ -2757,8 +3367,8 @@ async function pickSession(term, opts) {
2757
3367
  viewportSize = Math.min(visible.length, maxViewportRows);
2758
3368
  const rowMaxWidth = Math.max(10, termWidth - ROW_PREFIX_WIDTH);
2759
3369
  newSessionLabel = formatNewSessionLabel(opts.cwd, rowMaxWidth);
2760
- headerLine = formatRow(HEADER, widths, rowMaxWidth);
2761
- sessionLines = rows.map((r) => formatRow(r, widths, rowMaxWidth));
3370
+ headerLine = formatRow(HEADER, widths, rowMaxWidth, cwdMaxWidth);
3371
+ sessionLines = rows.map((r) => formatRow(r, widths, rowMaxWidth, cwdMaxWidth));
2762
3372
  };
2763
3373
  const rebuildRows = () => {
2764
3374
  rows = visible.map((s) => toRow(s, Date.now()));
@@ -2766,6 +3376,24 @@ async function pickSession(term, opts) {
2766
3376
  total = 1 + visible.length;
2767
3377
  computeLayout();
2768
3378
  };
3379
+ const applyFilter = () => {
3380
+ if (searchActive && searchTerm.length > 0) {
3381
+ visible = allSessions.filter((s) => matchesSearch(s, searchTerm));
3382
+ } else {
3383
+ visible = allSessions;
3384
+ }
3385
+ rebuildRows();
3386
+ if (searchActive) {
3387
+ scrollOffset = 0;
3388
+ selectedIdx = visible.length > 0 ? 1 : 0;
3389
+ } else if (selectedIdx > total - 1) {
3390
+ selectedIdx = Math.max(0, total - 1);
3391
+ }
3392
+ if (scrollOffset + viewportSize > visible.length) {
3393
+ scrollOffset = Math.max(0, visible.length - viewportSize);
3394
+ }
3395
+ adjustScroll();
3396
+ };
2769
3397
  const adjustScroll = () => {
2770
3398
  if (selectedIdx === 0) {
2771
3399
  return;
@@ -2828,6 +3456,13 @@ async function pickSession(term, opts) {
2828
3456
  term.dim.noFormat(` ${transientStatus}`);
2829
3457
  return;
2830
3458
  }
3459
+ if (searchActive) {
3460
+ term.brightYellow.noFormat(` /${searchTerm}`);
3461
+ term.bgBrightYellow(" ");
3462
+ const hint = visible.length === 0 ? " no matches" : ` ${visible.length} match${visible.length === 1 ? "" : "es"}`;
3463
+ term.dim.noFormat(`${hint} \xB7 ^c clears`);
3464
+ return;
3465
+ }
2831
3466
  term.dim.noFormat(formatIndicator());
2832
3467
  };
2833
3468
  const indicatorRow = () => startRow + 3 + viewportSize;
@@ -2894,8 +3529,8 @@ async function pickSession(term, opts) {
2894
3529
  const refresh = async (preferredId) => {
2895
3530
  try {
2896
3531
  const next = await listSessions(opts.config);
2897
- visible = sortSessions(next);
2898
- rebuildRows();
3532
+ allSessions = sortSessions(next);
3533
+ applyFilter();
2899
3534
  if (preferredId !== void 0) {
2900
3535
  const idx = visible.findIndex((s) => s.sessionId === preferredId);
2901
3536
  if (idx >= 0) {
@@ -2992,7 +3627,37 @@ async function pickSession(term, opts) {
2992
3627
  return;
2993
3628
  }
2994
3629
  clearTransient();
3630
+ if (searchActive) {
3631
+ if (data?.isCharacter) {
3632
+ searchTerm += name;
3633
+ applyFilter();
3634
+ renderFromScratch();
3635
+ return;
3636
+ }
3637
+ if (name === "BACKSPACE") {
3638
+ if (searchTerm.length > 0) {
3639
+ searchTerm = searchTerm.slice(0, -1);
3640
+ applyFilter();
3641
+ renderFromScratch();
3642
+ }
3643
+ return;
3644
+ }
3645
+ if (name === "ESCAPE" || name === "CTRL_C") {
3646
+ searchActive = false;
3647
+ searchTerm = "";
3648
+ applyFilter();
3649
+ renderFromScratch();
3650
+ return;
3651
+ }
3652
+ }
2995
3653
  if (data?.isCharacter) {
3654
+ if (name === "/") {
3655
+ searchActive = true;
3656
+ searchTerm = "";
3657
+ applyFilter();
3658
+ renderFromScratch();
3659
+ return;
3660
+ }
2996
3661
  if (name === "r" || name === "R") {
2997
3662
  const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
2998
3663
  void refresh(currentId);
@@ -3097,13 +3762,34 @@ function readTermWidth(term) {
3097
3762
  function formatNewSessionLabel(cwd, maxWidth) {
3098
3763
  const prefix = "+ New session in ";
3099
3764
  const budget = Math.max(1, maxWidth - prefix.length);
3100
- return prefix + truncateMiddle(cwd, budget);
3765
+ return prefix + truncateMiddle(shortenHomePath(cwd), budget);
3766
+ }
3767
+ function matchesSearch(s, term) {
3768
+ if (term.length === 0) {
3769
+ return true;
3770
+ }
3771
+ const t = term.toLowerCase();
3772
+ const haystacks = [
3773
+ stripHydraSessionPrefix(s.sessionId),
3774
+ s.upstreamSessionId ?? "",
3775
+ s.agentId ?? "",
3776
+ s.title ?? "",
3777
+ s.cwd,
3778
+ shortenHomePath(s.cwd)
3779
+ ];
3780
+ for (const h of haystacks) {
3781
+ if (h.toLowerCase().includes(t)) {
3782
+ return true;
3783
+ }
3784
+ }
3785
+ return false;
3101
3786
  }
3102
3787
  var ROW_PREFIX_WIDTH;
3103
3788
  var init_picker = __esm({
3104
3789
  "src/tui/picker.ts"() {
3105
3790
  "use strict";
3106
3791
  init_session_row();
3792
+ init_paths();
3107
3793
  init_session();
3108
3794
  init_discovery();
3109
3795
  ROW_PREFIX_WIDTH = 2;
@@ -3111,14 +3797,14 @@ var init_picker = __esm({
3111
3797
  });
3112
3798
 
3113
3799
  // src/tui/screen.ts
3114
- import os3 from "os";
3115
3800
  import stringWidth from "string-width";
3116
3801
  import wrapAnsi from "wrap-ansi";
3117
- function formattedLineSig(zone, width, line) {
3802
+ function formattedLineSig(zone, width, line, highlight2 = null, activeCol = null) {
3803
+ const active = activeCol === null ? "" : `a${activeCol}`;
3118
3804
  if (!line) {
3119
- return `${zone}|${width}|empty`;
3805
+ return `${zone}|${width}|empty|${highlight2 ?? ""}|${active}`;
3120
3806
  }
3121
- return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}`;
3807
+ return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}|${highlight2 ?? ""}|${active}`;
3122
3808
  }
3123
3809
  function computePromptVisualRows(buffer, room) {
3124
3810
  const rows = [];
@@ -3130,9 +3816,24 @@ function computePromptVisualRows(buffer, room) {
3130
3816
  }
3131
3817
  let pos = 0;
3132
3818
  while (pos < line.length) {
3133
- const end = Math.min(line.length, pos + room);
3134
- rows.push({ bufferIdx: i, startCol: pos, endCol: end });
3135
- pos = end;
3819
+ if (line.length - pos <= room) {
3820
+ rows.push({ bufferIdx: i, startCol: pos, endCol: line.length });
3821
+ pos = line.length;
3822
+ break;
3823
+ }
3824
+ let breakAt = -1;
3825
+ for (let j = pos + room - 1; j >= pos; j--) {
3826
+ const c = line[j];
3827
+ if (c === " " || c === " ") {
3828
+ breakAt = j + 1;
3829
+ break;
3830
+ }
3831
+ }
3832
+ if (breakAt === -1) {
3833
+ breakAt = pos + room;
3834
+ }
3835
+ rows.push({ bufferIdx: i, startCol: pos, endCol: breakAt });
3836
+ pos = breakAt;
3136
3837
  }
3137
3838
  }
3138
3839
  if (rows.length === 0) {
@@ -3180,6 +3881,34 @@ function computePromptLayout(visualRows, state, maxRows) {
3180
3881
  }
3181
3882
  return { cursorVisualRow, cursorVisualCol, windowStart, rendered };
3182
3883
  }
3884
+ function writeBodyWithHighlight(termObj, text, style, term, activeCol = null, _activeLength = 0) {
3885
+ if (text.length === 0) {
3886
+ return;
3887
+ }
3888
+ if (term.length === 0) {
3889
+ writeStyled(termObj, text, style);
3890
+ return;
3891
+ }
3892
+ const haystack = text.toLowerCase();
3893
+ let i = 0;
3894
+ while (i < text.length) {
3895
+ const next = haystack.indexOf(term, i);
3896
+ if (next === -1) {
3897
+ writeStyled(termObj, text.slice(i), style);
3898
+ return;
3899
+ }
3900
+ if (next > i) {
3901
+ writeStyled(termObj, text.slice(i, next), style);
3902
+ }
3903
+ const isActive = activeCol !== null && next === activeCol;
3904
+ writeStyled(
3905
+ termObj,
3906
+ text.slice(next, next + term.length),
3907
+ isActive ? "search-highlight-active" : "search-highlight"
3908
+ );
3909
+ i = next + term.length;
3910
+ }
3911
+ }
3183
3912
  function writeStyled(term, text, style) {
3184
3913
  if (text.length === 0) {
3185
3914
  return;
@@ -3242,6 +3971,12 @@ function writeStyled(term, text, style) {
3242
3971
  case "heading-3":
3243
3972
  term.bold.noFormat(text);
3244
3973
  return;
3974
+ case "search-highlight":
3975
+ term.bgBrightYellow.black.noFormat(text);
3976
+ return;
3977
+ case "search-highlight-active":
3978
+ term.bgRed.brightWhite.noFormat(text);
3979
+ return;
3245
3980
  default:
3246
3981
  term.noFormat(text);
3247
3982
  }
@@ -3255,17 +3990,80 @@ function wrapAnsiBody(text, width) {
3255
3990
  }
3256
3991
  return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
3257
3992
  }
3258
- function wrap(text, width) {
3993
+ function matchTkMarkupAt(text, i) {
3994
+ if (text.charCodeAt(i) !== 94) {
3995
+ return null;
3996
+ }
3997
+ const c = text[i + 1];
3998
+ if (c === void 0) {
3999
+ return null;
4000
+ }
4001
+ if (c === "^") {
4002
+ return { text: "^^", width: 1 };
4003
+ }
4004
+ if (c === "[") {
4005
+ const end = text.indexOf("]", i + 2);
4006
+ if (end !== -1) {
4007
+ return { text: text.slice(i, end + 1), width: 0 };
4008
+ }
4009
+ }
4010
+ if (TK_MARKUP_STYLE_CHAR.test(c)) {
4011
+ return { text: text.slice(i, i + 2), width: 0 };
4012
+ }
4013
+ return null;
4014
+ }
4015
+ function hasTkMarkup(text) {
4016
+ if (!text.includes("^")) {
4017
+ return false;
4018
+ }
4019
+ for (let i = 0; i < text.length; i++) {
4020
+ if (matchTkMarkupAt(text, i)) {
4021
+ return true;
4022
+ }
4023
+ }
4024
+ return false;
4025
+ }
4026
+ function* segmentForWidth(text) {
4027
+ let i = 0;
4028
+ while (i < text.length) {
4029
+ const m = matchTkMarkupAt(text, i);
4030
+ if (m) {
4031
+ yield { text: m.text, width: m.width };
4032
+ i += m.text.length;
4033
+ continue;
4034
+ }
4035
+ let runEnd = text.length;
4036
+ let probe = text.indexOf("^", i);
4037
+ while (probe !== -1 && probe < text.length) {
4038
+ if (matchTkMarkupAt(text, probe)) {
4039
+ runEnd = probe;
4040
+ break;
4041
+ }
4042
+ probe = text.indexOf("^", probe + 1);
4043
+ }
4044
+ if (runEnd === i) {
4045
+ yield { text: "^", width: 1 };
4046
+ i += 1;
4047
+ continue;
4048
+ }
4049
+ for (const { segment } of SEGMENTER.segment(text.slice(i, runEnd))) {
4050
+ yield { text: segment, width: stringWidth(segment) };
4051
+ }
4052
+ i = runEnd;
4053
+ }
4054
+ }
4055
+ function wrap(text, width, opts = {}) {
3259
4056
  if (width <= 0) {
3260
4057
  return [text];
3261
4058
  }
3262
4059
  if (text.length === 0) {
3263
4060
  return [""];
3264
4061
  }
3265
- if (!NON_ASCII.test(text)) {
4062
+ const stripMarkup = opts.stripMarkup === true && hasTkMarkup(text);
4063
+ if (!stripMarkup && !NON_ASCII.test(text)) {
3266
4064
  return wrapAscii(text, width);
3267
4065
  }
3268
- return wrapVisible(text, width);
4066
+ return wrapVisible(text, width, stripMarkup);
3269
4067
  }
3270
4068
  function wrapAscii(text, width) {
3271
4069
  const out = [];
@@ -3290,32 +4088,33 @@ function wrapAscii(text, width) {
3290
4088
  out.push(remaining);
3291
4089
  return out;
3292
4090
  }
3293
- function wrapVisible(text, width) {
4091
+ function wrapVisible(text, width, stripMarkup) {
3294
4092
  const out = [];
3295
- const graphemes = [];
3296
- for (const { segment } of SEGMENTER.segment(text)) {
3297
- graphemes.push({ seg: segment, w: stringWidth(segment) });
3298
- }
4093
+ const segments = stripMarkup ? [...segmentForWidth(text)] : graphemeSegments(text);
3299
4094
  let i = 0;
3300
- while (i < graphemes.length) {
4095
+ while (i < segments.length) {
3301
4096
  let chunk = "";
3302
4097
  let chunkW = 0;
3303
4098
  let lastSpaceI = -1;
3304
4099
  let chunkAtLastSpace = "";
3305
- while (i < graphemes.length) {
3306
- const g = graphemes[i];
3307
- if (chunkW + g.w > width) {
4100
+ while (i < segments.length) {
4101
+ const s = segments[i];
4102
+ if (chunkW + s.width > width) {
4103
+ if (s.text === " " && s.width === 1) {
4104
+ lastSpaceI = i;
4105
+ chunkAtLastSpace = chunk;
4106
+ }
3308
4107
  break;
3309
4108
  }
3310
- if (g.seg === " ") {
4109
+ if (s.text === " " && s.width === 1) {
3311
4110
  lastSpaceI = i;
3312
4111
  chunkAtLastSpace = chunk;
3313
4112
  }
3314
- chunk += g.seg;
3315
- chunkW += g.w;
4113
+ chunk += s.text;
4114
+ chunkW += s.width;
3316
4115
  i += 1;
3317
4116
  }
3318
- if (i >= graphemes.length) {
4117
+ if (i >= segments.length) {
3319
4118
  out.push(chunk);
3320
4119
  break;
3321
4120
  }
@@ -3323,7 +4122,7 @@ function wrapVisible(text, width) {
3323
4122
  out.push(chunkAtLastSpace);
3324
4123
  i = lastSpaceI + 1;
3325
4124
  } else if (chunk.length === 0) {
3326
- out.push(graphemes[i].seg);
4125
+ out.push(segments[i].text);
3327
4126
  i += 1;
3328
4127
  } else {
3329
4128
  out.push(chunk);
@@ -3331,34 +4130,43 @@ function wrapVisible(text, width) {
3331
4130
  }
3332
4131
  return out;
3333
4132
  }
3334
- function shortenHomePath(p) {
3335
- const home = os3.homedir();
3336
- if (!home) {
3337
- return p;
3338
- }
3339
- if (p === home) {
3340
- return "~";
3341
- }
3342
- if (p.startsWith(home + "/")) {
3343
- return "~" + p.slice(home.length);
4133
+ function graphemeSegments(text) {
4134
+ const out = [];
4135
+ for (const { segment } of SEGMENTER.segment(text)) {
4136
+ out.push({ text: segment, width: stringWidth(segment) });
3344
4137
  }
3345
- return p;
4138
+ return out;
3346
4139
  }
3347
- function truncate(text, max) {
4140
+ function truncate(text, max, opts = {}) {
3348
4141
  if (max <= 0) {
3349
4142
  return "";
3350
4143
  }
3351
- if (text.length <= max && !NON_ASCII.test(text)) {
4144
+ const stripMarkup = opts.stripMarkup === true && hasTkMarkup(text);
4145
+ if (!stripMarkup && text.length <= max && !NON_ASCII.test(text)) {
3352
4146
  return text;
3353
4147
  }
3354
- const visible = stringWidth(text);
4148
+ if (!stripMarkup) {
4149
+ const visible2 = stringWidth(text);
4150
+ if (visible2 <= max) {
4151
+ return text;
4152
+ }
4153
+ if (max <= 1) {
4154
+ return takeByWidth(text, max);
4155
+ }
4156
+ return takeByWidth(text, max - 1) + "\u2026";
4157
+ }
4158
+ const segments = [...segmentForWidth(text)];
4159
+ let visible = 0;
4160
+ for (const s of segments) {
4161
+ visible += s.width;
4162
+ }
3355
4163
  if (visible <= max) {
3356
4164
  return text;
3357
4165
  }
3358
4166
  if (max <= 1) {
3359
- return takeByWidth(text, max);
4167
+ return takeFromSegments(segments, max);
3360
4168
  }
3361
- return takeByWidth(text, max - 1) + "\u2026";
4169
+ return takeFromSegments(segments, max - 1) + "\u2026";
3362
4170
  }
3363
4171
  function takeByWidth(text, budget) {
3364
4172
  if (budget <= 0) {
@@ -3376,6 +4184,21 @@ function takeByWidth(text, budget) {
3376
4184
  }
3377
4185
  return out;
3378
4186
  }
4187
+ function takeFromSegments(segments, budget) {
4188
+ if (budget <= 0) {
4189
+ return "";
4190
+ }
4191
+ let out = "";
4192
+ let used = 0;
4193
+ for (const s of segments) {
4194
+ if (used + s.width > budget) {
4195
+ break;
4196
+ }
4197
+ out += s.text;
4198
+ used += s.width;
4199
+ }
4200
+ return out;
4201
+ }
3379
4202
  function firstLine2(text) {
3380
4203
  const idx = text.indexOf("\n");
3381
4204
  return idx === -1 ? text : `${text.slice(0, idx)} \u21B5`;
@@ -3472,6 +4295,10 @@ function mapKeyName(name) {
3472
4295
  return "ctrl-o";
3473
4296
  case "CTRL_P":
3474
4297
  return "ctrl-p";
4298
+ case "CTRL_R":
4299
+ return "ctrl-r";
4300
+ case "CTRL_S":
4301
+ return "ctrl-s";
3475
4302
  case "CTRL_U":
3476
4303
  return "ctrl-u";
3477
4304
  case "CTRL_W":
@@ -3484,11 +4311,12 @@ function mapKeyName(name) {
3484
4311
  return null;
3485
4312
  }
3486
4313
  }
3487
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, shortId;
4314
+ var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
3488
4315
  var init_screen = __esm({
3489
4316
  "src/tui/screen.ts"() {
3490
4317
  "use strict";
3491
4318
  init_agent_display();
4319
+ init_paths();
3492
4320
  init_session();
3493
4321
  HEADER_ROWS = 2;
3494
4322
  BANNER_ROWS = 1;
@@ -3532,6 +4360,12 @@ var init_screen = __esm({
3532
4360
  lineIds = /* @__PURE__ */ new WeakMap();
3533
4361
  wrapCache = /* @__PURE__ */ new Map();
3534
4362
  wrapCacheWidth = 0;
4363
+ // For each wrapped chunk (produced by wrapOne), record the source
4364
+ // line's id and the col offset where this chunk starts in the source
4365
+ // body. Used by the active-match highlight in scrollback search to
4366
+ // map currentMatch (sourceLineId, sourceCol) onto the wrapped chunk
4367
+ // that owns it without scanning the wrap cache.
4368
+ wrapOrigin = /* @__PURE__ */ new WeakMap();
3535
4369
  // Per-row signature of what was painted to each terminal row on the
3536
4370
  // previous repaint. drawX methods funnel through paintRow(), which
3537
4371
  // skips the moveTo+eraseLineAfter+write sequence when the new
@@ -3549,10 +4383,30 @@ var init_screen = __esm({
3549
4383
  // above the bottom. Mouse wheel and PgUp/PgDn adjust this; new content
3550
4384
  // pushes the view down naturally when at 0.
3551
4385
  scrollOffset = 0;
4386
+ // Scrollback search state. While active the prompt area is taken over
4387
+ // by a single-row search input (drawSearchPrompt) and matches in the
4388
+ // visible scrollback are rendered with a background-highlight style.
4389
+ // baselineScroll captures the scrollOffset at the moment the user
4390
+ // engaged search so cancel can restore the view.
4391
+ scrollbackSearch = null;
4392
+ // Lowercased search term used by drawScrollback to drive per-row
4393
+ // highlight rendering. Mirrors scrollbackSearch?.term but cached as a
4394
+ // separate field so the per-row signature can include it cheaply.
4395
+ scrollbackHighlight = null;
4396
+ // Right-side banner slot. Three sources, in priority order:
4397
+ // 1. Active scrollback search term (auto, from this.scrollbackSearch)
4398
+ // 2. External search indicator pushed by the app while prompt-
4399
+ // history reverse-search is active (gives that mode visible
4400
+ // feedback for its otherwise-hidden query)
4401
+ // 3. Transient notification set via notify(), auto-cleared after
4402
+ // durationMs
4403
+ bannerNotification = null;
4404
+ bannerNotificationTimer = null;
4405
+ bannerSearchIndicator = null;
3552
4406
  banner = {
3553
4407
  status: "ready",
3554
4408
  planMode: false,
3555
- hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D quit",
4409
+ hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
3556
4410
  queued: 0
3557
4411
  };
3558
4412
  header = { agent: "?", cwd: "?", sessionId: "?" };
@@ -3615,6 +4469,10 @@ var init_screen = __esm({
3615
4469
  return;
3616
4470
  }
3617
4471
  this.started = false;
4472
+ if (this.bannerNotificationTimer) {
4473
+ clearTimeout(this.bannerNotificationTimer);
4474
+ this.bannerNotificationTimer = null;
4475
+ }
3618
4476
  this.uninstallBracketedPaste();
3619
4477
  this.term.off("key", this.keyHandler);
3620
4478
  if (this.mouseEnabled) {
@@ -3695,7 +4553,7 @@ var init_screen = __esm({
3695
4553
  this.streamingActive = false;
3696
4554
  this.lines.push(...lines);
3697
4555
  this.trackLines(lines);
3698
- this.adjustScrollForLineChange(lines.length);
4556
+ this.adjustScrollForRowChange(this.wrappedRowsOfMany(lines));
3699
4557
  this.trimScrollback();
3700
4558
  this.scheduleRepaint();
3701
4559
  }
@@ -3703,19 +4561,40 @@ var init_screen = __esm({
3703
4561
  this.streamingActive = false;
3704
4562
  this.lines.push(line);
3705
4563
  this.trackLine(line);
3706
- this.adjustScrollForLineChange(1);
4564
+ this.adjustScrollForRowChange(this.wrappedRowsOf(line));
3707
4565
  this.trimScrollback();
3708
4566
  this.scheduleRepaint();
3709
4567
  }
3710
4568
  // When scrolled away from the bottom, shift scrollOffset to keep the
3711
4569
  // user's visible window anchored on the same content as the lines
3712
- // array grows. Without this, every new line silently scrolls the view
3713
- // up by one row the original bug the user reported.
3714
- adjustScrollForLineChange(delta) {
4570
+ // array grows. `delta` is measured in WRAPPED ROWS the same unit
4571
+ // scrollOffset uses so a single logical line that wraps to N rows
4572
+ // contributes N, not 1. Counting logical lines here was the original
4573
+ // bug: any wrapped append would slide the view up by N−1 rows.
4574
+ adjustScrollForRowChange(delta) {
3715
4575
  if (this.scrollOffset > 0 && delta !== 0) {
3716
4576
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
3717
4577
  }
3718
4578
  }
4579
+ // Wrapped-row count for a single line at the current terminal width.
4580
+ // Reuses the wrap cache, and synchronises the cache's width with the
4581
+ // current width so a resize that hasn't yet been picked up by
4582
+ // drawScrollback can't return stale counts during an insert.
4583
+ wrappedRowsOf(line) {
4584
+ const w = this.term.width;
4585
+ if (this.wrapCacheWidth !== w) {
4586
+ this.wrapCache.clear();
4587
+ this.wrapCacheWidth = w;
4588
+ }
4589
+ return this.wrapOne(line, w).length;
4590
+ }
4591
+ wrappedRowsOfMany(lines) {
4592
+ let n = 0;
4593
+ for (const line of lines) {
4594
+ n += this.wrappedRowsOf(line);
4595
+ }
4596
+ return n;
4597
+ }
3719
4598
  trackLine(line) {
3720
4599
  this.lineIds.set(line, this.nextLineId++);
3721
4600
  }
@@ -3765,12 +4644,14 @@ var init_screen = __esm({
3765
4644
  }
3766
4645
  const existing = this.keyedBlocks.get(key);
3767
4646
  let touchesEnd = false;
3768
- let scrollDelta = 0;
4647
+ let rowDelta = 0;
3769
4648
  if (existing) {
3770
4649
  const oldEnd = existing.start + existing.count;
3771
4650
  touchesEnd = oldEnd >= this.lines.length;
4651
+ const oldRows = this.wrappedRowsOfMany(
4652
+ this.lines.slice(existing.start, oldEnd)
4653
+ );
3772
4654
  const delta = newLines.length - existing.count;
3773
- scrollDelta = delta;
3774
4655
  const removed = this.lines.splice(
3775
4656
  existing.start,
3776
4657
  existing.count,
@@ -3788,20 +4669,21 @@ var init_screen = __esm({
3788
4669
  }
3789
4670
  }
3790
4671
  }
4672
+ rowDelta = this.wrappedRowsOfMany(newLines) - oldRows;
3791
4673
  } else {
3792
4674
  touchesEnd = true;
3793
- scrollDelta = newLines.length;
3794
4675
  this.keyedBlocks.set(key, {
3795
4676
  start: this.lines.length,
3796
4677
  count: newLines.length
3797
4678
  });
3798
4679
  this.lines.push(...newLines);
3799
4680
  this.trackLines(newLines);
4681
+ rowDelta = this.wrappedRowsOfMany(newLines);
3800
4682
  }
3801
4683
  if (touchesEnd) {
3802
4684
  this.streamingActive = false;
3803
4685
  }
3804
- this.adjustScrollForLineChange(scrollDelta);
4686
+ this.adjustScrollForRowChange(rowDelta);
3805
4687
  this.trimScrollback();
3806
4688
  this.scheduleRepaint();
3807
4689
  }
@@ -3815,12 +4697,14 @@ var init_screen = __esm({
3815
4697
  }
3816
4698
  const fragments = text.split("\n");
3817
4699
  const [first, ...rest] = fragments;
3818
- let added = 0;
4700
+ let rowDelta = 0;
3819
4701
  if (this.streamingActive && this.lines.length > 0) {
3820
4702
  const last = this.lines[this.lines.length - 1];
3821
4703
  if (last) {
4704
+ const before = this.wrappedRowsOf(last);
3822
4705
  this.forgetLine(last);
3823
4706
  last.body += first ?? "";
4707
+ rowDelta += this.wrappedRowsOf(last) - before;
3824
4708
  }
3825
4709
  } else {
3826
4710
  if (this.lines.length > 0) {
@@ -3830,7 +4714,7 @@ var init_screen = __esm({
3830
4714
  const sep = { body: "" };
3831
4715
  this.lines.push(sep);
3832
4716
  this.trackLine(sep);
3833
- added += 1;
4717
+ rowDelta += this.wrappedRowsOf(sep);
3834
4718
  }
3835
4719
  }
3836
4720
  const initial = {
@@ -3843,7 +4727,7 @@ var init_screen = __esm({
3843
4727
  }
3844
4728
  this.lines.push(initial);
3845
4729
  this.trackLine(initial);
3846
- added += 1;
4730
+ rowDelta += this.wrappedRowsOf(initial);
3847
4731
  }
3848
4732
  const continuationPrefix = " ".repeat(prefix.length);
3849
4733
  for (const piece of rest) {
@@ -3854,10 +4738,10 @@ var init_screen = __esm({
3854
4738
  };
3855
4739
  this.lines.push(cont);
3856
4740
  this.trackLine(cont);
3857
- added += 1;
4741
+ rowDelta += this.wrappedRowsOf(cont);
3858
4742
  }
3859
4743
  this.streamingActive = true;
3860
- this.adjustScrollForLineChange(added);
4744
+ this.adjustScrollForRowChange(rowDelta);
3861
4745
  this.trimScrollback();
3862
4746
  this.scheduleRepaint();
3863
4747
  }
@@ -3889,6 +4773,58 @@ var init_screen = __esm({
3889
4773
  this.drawBanner();
3890
4774
  this.placeCursor();
3891
4775
  }
4776
+ // Transient right-side banner message. Cleared automatically after
4777
+ // durationMs (default 4s). Each call resets the timer, so rapid
4778
+ // successive notifications coalesce on the latest text. Active
4779
+ // scrollback / prompt-history search indicators take priority over
4780
+ // notifications, so a notification queued during search is held
4781
+ // behind it and visible once search exits — unless its timer fires
4782
+ // first, in which case it's dropped.
4783
+ notify(text, durationMs = 4e3) {
4784
+ if (this.bannerNotificationTimer) {
4785
+ clearTimeout(this.bannerNotificationTimer);
4786
+ }
4787
+ this.bannerNotification = text;
4788
+ this.bannerNotificationTimer = setTimeout(() => {
4789
+ this.bannerNotification = null;
4790
+ this.bannerNotificationTimer = null;
4791
+ this.drawBanner();
4792
+ this.placeCursor();
4793
+ }, durationMs);
4794
+ this.drawBanner();
4795
+ this.placeCursor();
4796
+ }
4797
+ // Pushed by the app each onKey tick to reflect prompt-history
4798
+ // reverse-search state in the banner — the only place that mode's
4799
+ // query is visible. Pass null when not searching.
4800
+ setBannerSearchIndicator(text) {
4801
+ if (this.bannerSearchIndicator === text) {
4802
+ return;
4803
+ }
4804
+ this.bannerSearchIndicator = text;
4805
+ this.drawBanner();
4806
+ this.placeCursor();
4807
+ }
4808
+ // Computes what (if anything) the right-side banner slot should show
4809
+ // this paint. Priority: scrollback search term > prompt-history
4810
+ // indicator > notification. Scrollback gets a "N/M" counter suffix
4811
+ // since the user can't see which match they're on from the highlight
4812
+ // alone; prompt-history's match is visible in the buffer, so no
4813
+ // counter needed there.
4814
+ bannerRightContent() {
4815
+ if (this.scrollbackSearch !== null) {
4816
+ const sb = this.scrollbackSearch;
4817
+ const counter = sb.matches.length > 0 ? ` ${sb.matchIndex + 1}/${sb.matches.length}` : sb.term.length === 0 ? "" : " 0/0";
4818
+ return { text: `\u{1F50D} ${sb.term}${counter}`, kind: "search" };
4819
+ }
4820
+ if (this.bannerSearchIndicator !== null) {
4821
+ return { text: `\u{1F50D} ${this.bannerSearchIndicator}`, kind: "search" };
4822
+ }
4823
+ if (this.bannerNotification !== null) {
4824
+ return { text: this.bannerNotification, kind: "notify" };
4825
+ }
4826
+ return null;
4827
+ }
3892
4828
  clearScrollback() {
3893
4829
  this.lines = [];
3894
4830
  this.keyedBlocks.clear();
@@ -3916,6 +4852,9 @@ var init_screen = __esm({
3916
4852
  return;
3917
4853
  }
3918
4854
  const touchesEnd = existing.start + existing.count >= this.lines.length;
4855
+ const removedRows = this.wrappedRowsOfMany(
4856
+ this.lines.slice(existing.start, existing.start + existing.count)
4857
+ );
3919
4858
  const removed = this.lines.splice(existing.start, existing.count);
3920
4859
  for (const line of removed) {
3921
4860
  this.forgetLine(line);
@@ -3929,7 +4868,7 @@ var init_screen = __esm({
3929
4868
  if (touchesEnd) {
3930
4869
  this.streamingActive = false;
3931
4870
  }
3932
- this.adjustScrollForLineChange(-existing.count);
4871
+ this.adjustScrollForRowChange(-removedRows);
3933
4872
  this.scheduleRepaint();
3934
4873
  }
3935
4874
  redraw() {
@@ -4014,7 +4953,7 @@ var init_screen = __esm({
4014
4953
  this.lines.push(sep);
4015
4954
  this.trackLine(sep);
4016
4955
  this.streamingActive = false;
4017
- this.adjustScrollForLineChange(1);
4956
+ this.adjustScrollForRowChange(this.wrappedRowsOf(sep));
4018
4957
  this.trimScrollback();
4019
4958
  this.scheduleRepaint();
4020
4959
  }
@@ -4070,6 +5009,9 @@ var init_screen = __esm({
4070
5009
  if (delta === 0) {
4071
5010
  return;
4072
5011
  }
5012
+ if (this.scrollbackSearch !== null) {
5013
+ this.acceptScrollbackSearch();
5014
+ }
4073
5015
  const max = this.maxScrollOffset();
4074
5016
  const next = Math.min(max, Math.max(0, this.scrollOffset + delta));
4075
5017
  if (next === this.scrollOffset) {
@@ -4079,6 +5021,9 @@ var init_screen = __esm({
4079
5021
  this.repaint();
4080
5022
  }
4081
5023
  scrollToBottom() {
5024
+ if (this.scrollbackSearch !== null) {
5025
+ this.acceptScrollbackSearch();
5026
+ }
4082
5027
  if (this.scrollOffset === 0) {
4083
5028
  return;
4084
5029
  }
@@ -4086,6 +5031,9 @@ var init_screen = __esm({
4086
5031
  this.repaint();
4087
5032
  }
4088
5033
  scrollToTop() {
5034
+ if (this.scrollbackSearch !== null) {
5035
+ this.acceptScrollbackSearch();
5036
+ }
4089
5037
  const max = this.maxScrollOffset();
4090
5038
  if (this.scrollOffset === max) {
4091
5039
  return;
@@ -4093,6 +5041,221 @@ var init_screen = __esm({
4093
5041
  this.scrollOffset = max;
4094
5042
  this.repaint();
4095
5043
  }
5044
+ // True iff the user is scrolled above the live tail — gates the
5045
+ // app-level decision of whether ^r engages scrollback search vs.
5046
+ // prompt-history search.
5047
+ isScrolledBack() {
5048
+ return this.scrollOffset > 0;
5049
+ }
5050
+ // True iff a scrollback search is currently active. Used by the app
5051
+ // to decide whether to keep routing keys into search vs. the prompt
5052
+ // dispatcher.
5053
+ isScrollbackSearchActive() {
5054
+ return this.scrollbackSearch !== null;
5055
+ }
5056
+ // Engage scrollback reverse-search. Captures the current scroll
5057
+ // position so cancel can restore it, and seeds an empty search term
5058
+ // (the prompt row renders the search input immediately so the user
5059
+ // sees the entry). Idempotent: no-op when already active.
5060
+ enterScrollbackSearch() {
5061
+ if (this.scrollbackSearch !== null) {
5062
+ return;
5063
+ }
5064
+ this.scrollbackSearch = {
5065
+ term: "",
5066
+ matchIndex: 0,
5067
+ matches: [],
5068
+ baselineScroll: this.scrollOffset
5069
+ };
5070
+ this.scrollbackHighlight = null;
5071
+ this.repaint();
5072
+ }
5073
+ // Update the search term and recompute matches. Walks `lines` from
5074
+ // the tail (newest) toward the head (oldest), pushing every case-
5075
+ // insensitive substring hit. Snaps the viewport to the newest match
5076
+ // when found. Called per keystroke; sub-millisecond on typical
5077
+ // scrollback sizes.
5078
+ updateScrollbackSearchTerm(term) {
5079
+ if (this.scrollbackSearch === null) {
5080
+ return;
5081
+ }
5082
+ const lowered = term.toLowerCase();
5083
+ const matches = [];
5084
+ if (lowered.length > 0) {
5085
+ for (let i = this.lines.length - 1; i >= 0; i--) {
5086
+ const line = this.lines[i];
5087
+ if (!line || line.body.length === 0) {
5088
+ continue;
5089
+ }
5090
+ if (line.ansi) {
5091
+ continue;
5092
+ }
5093
+ const hay = line.body.toLowerCase();
5094
+ const lineCols = [];
5095
+ let pos = 0;
5096
+ while (pos < hay.length) {
5097
+ const found = hay.indexOf(lowered, pos);
5098
+ if (found === -1) {
5099
+ break;
5100
+ }
5101
+ lineCols.push(found);
5102
+ pos = found + lowered.length;
5103
+ }
5104
+ for (let j = lineCols.length - 1; j >= 0; j--) {
5105
+ matches.push({ lineIdx: i, col: lineCols[j] });
5106
+ }
5107
+ }
5108
+ }
5109
+ this.scrollbackSearch.term = term;
5110
+ this.scrollbackSearch.matches = matches;
5111
+ this.scrollbackSearch.matchIndex = 0;
5112
+ this.scrollbackHighlight = lowered.length > 0 ? lowered : null;
5113
+ if (matches.length > 0) {
5114
+ this.scrollToMatch(matches[0]);
5115
+ }
5116
+ this.repaint();
5117
+ }
5118
+ // Advance to the next-older match (called for repeated ^r). Stops at
5119
+ // the oldest match (does not wrap). No-op when there are no matches
5120
+ // or search is inactive.
5121
+ advanceScrollbackSearch() {
5122
+ if (this.scrollbackSearch === null || this.scrollbackSearch.matches.length === 0) {
5123
+ return;
5124
+ }
5125
+ const nextIdx = Math.min(
5126
+ this.scrollbackSearch.matches.length - 1,
5127
+ this.scrollbackSearch.matchIndex + 1
5128
+ );
5129
+ if (nextIdx === this.scrollbackSearch.matchIndex) {
5130
+ return;
5131
+ }
5132
+ this.scrollbackSearch.matchIndex = nextIdx;
5133
+ this.scrollToMatch(this.scrollbackSearch.matches[nextIdx]);
5134
+ this.repaint();
5135
+ }
5136
+ // Retreat to the previous (newer) match — ^s forward-search. Stops
5137
+ // at the newest match (no wrap).
5138
+ retreatScrollbackSearch() {
5139
+ if (this.scrollbackSearch === null || this.scrollbackSearch.matches.length === 0) {
5140
+ return;
5141
+ }
5142
+ if (this.scrollbackSearch.matchIndex === 0) {
5143
+ return;
5144
+ }
5145
+ this.scrollbackSearch.matchIndex -= 1;
5146
+ this.scrollToMatch(this.scrollbackSearch.matches[this.scrollbackSearch.matchIndex]);
5147
+ this.repaint();
5148
+ }
5149
+ // Exit search keeping the viewport at the current match. Highlight is
5150
+ // cleared so subsequent scrollback content reads normally.
5151
+ acceptScrollbackSearch() {
5152
+ if (this.scrollbackSearch === null) {
5153
+ return;
5154
+ }
5155
+ this.scrollbackSearch = null;
5156
+ this.scrollbackHighlight = null;
5157
+ this.repaint();
5158
+ }
5159
+ // Exit search and restore the viewport to where the user was when
5160
+ // they engaged search.
5161
+ cancelScrollbackSearch() {
5162
+ if (this.scrollbackSearch === null) {
5163
+ return;
5164
+ }
5165
+ const baseline = this.scrollbackSearch.baselineScroll;
5166
+ this.scrollbackSearch = null;
5167
+ this.scrollbackHighlight = null;
5168
+ this.scrollOffset = baseline;
5169
+ this.repaint();
5170
+ }
5171
+ scrollbackSearchTerm() {
5172
+ return this.scrollbackSearch?.term ?? "";
5173
+ }
5174
+ // Source-line identity + col + term length for whichever match is
5175
+ // currently selected (advanced via ^r / retreated via ^s). Used by
5176
+ // drawScrollback to give the current match a distinct highlight
5177
+ // style without disturbing the bulk-highlight on the other matches.
5178
+ currentMatchInfo() {
5179
+ if (this.scrollbackSearch === null || this.scrollbackSearch.matches.length === 0) {
5180
+ return null;
5181
+ }
5182
+ const match = this.scrollbackSearch.matches[this.scrollbackSearch.matchIndex];
5183
+ if (!match) {
5184
+ return null;
5185
+ }
5186
+ const sourceLine = this.lines[match.lineIdx];
5187
+ if (!sourceLine) {
5188
+ return null;
5189
+ }
5190
+ const lineId = this.lineIds.get(sourceLine);
5191
+ if (lineId === void 0) {
5192
+ return null;
5193
+ }
5194
+ return {
5195
+ lineId,
5196
+ col: match.col,
5197
+ length: this.scrollbackSearch.term.length
5198
+ };
5199
+ }
5200
+ // If `line` is the wrapped chunk that contains the active match,
5201
+ // returns the col within the chunk's body where the match starts;
5202
+ // otherwise null. The chunk's source identity comes from
5203
+ // this.wrapOrigin which wrapOne populates for every wrapped chunk.
5204
+ activeMatchCol(line, info) {
5205
+ if (!line || info === null) {
5206
+ return null;
5207
+ }
5208
+ const origin = this.wrapOrigin.get(line);
5209
+ if (!origin || origin.sourceLineId !== info.lineId) {
5210
+ return null;
5211
+ }
5212
+ const colInChunk = info.col - origin.sourceColOffset;
5213
+ if (colInChunk < 0 || colInChunk >= line.body.length) {
5214
+ return null;
5215
+ }
5216
+ return colInChunk;
5217
+ }
5218
+ // Position scrollOffset so the wrapped row containing the given
5219
+ // (lineIdx, col) lands on a visible row of the scrollback viewport.
5220
+ // Walks wrapTail to count wrapped rows between the target line and
5221
+ // the tail.
5222
+ scrollToMatch(match) {
5223
+ const w = this.term.width;
5224
+ const visibleRows = this.scrollbackVisibleRows();
5225
+ if (visibleRows <= 0) {
5226
+ return;
5227
+ }
5228
+ let rowsBelowMatchLine = 0;
5229
+ for (let i = this.lines.length - 1; i > match.lineIdx; i--) {
5230
+ const line = this.lines[i];
5231
+ if (!line) {
5232
+ continue;
5233
+ }
5234
+ rowsBelowMatchLine += this.wrapOne(line, w).length;
5235
+ }
5236
+ const matchLine = this.lines[match.lineIdx];
5237
+ let rowsWithinMatchLine = 0;
5238
+ if (matchLine) {
5239
+ const wrapped = this.wrapOne(matchLine, w);
5240
+ let consumed = 0;
5241
+ for (let r = 0; r < wrapped.length; r++) {
5242
+ const piece = wrapped[r];
5243
+ if (!piece) {
5244
+ continue;
5245
+ }
5246
+ const bodyLen = piece.body.length;
5247
+ if (match.col < consumed + bodyLen) {
5248
+ rowsWithinMatchLine = wrapped.length - 1 - r;
5249
+ break;
5250
+ }
5251
+ consumed += bodyLen;
5252
+ }
5253
+ }
5254
+ const target = rowsBelowMatchLine + rowsWithinMatchLine;
5255
+ const desired = Math.max(0, target - Math.floor(visibleRows / 2));
5256
+ const max = this.maxScrollOffset();
5257
+ this.scrollOffset = Math.min(max, desired);
5258
+ }
4096
5259
  scrollPageSize() {
4097
5260
  return Math.max(1, this.scrollbackVisibleRows() - 2);
4098
5261
  }
@@ -4215,8 +5378,8 @@ var init_screen = __esm({
4215
5378
  }
4216
5379
  if (usage) {
4217
5380
  const col = Math.max(1, w - usage.length + 1);
4218
- this.term.moveTo(col, 1);
4219
- this.term.dim(usage);
5381
+ this.term.moveTo(col, 1).eraseLineAfter();
5382
+ this.term.dim.noFormat(usage);
4220
5383
  }
4221
5384
  });
4222
5385
  }
@@ -4247,14 +5410,23 @@ var init_screen = __esm({
4247
5410
  const start = Math.max(0, end - visibleRows);
4248
5411
  const slice = wrapped.slice(start, end);
4249
5412
  const padTop = Math.max(0, visibleRows - slice.length);
5413
+ const matchInfo = this.currentMatchInfo();
5414
+ const activeLength = matchInfo?.length ?? 0;
4250
5415
  for (let i = 0; i < visibleRows; i++) {
4251
5416
  const row = top + i;
4252
5417
  const sliceIdx = i - padTop;
4253
5418
  const line = sliceIdx >= 0 ? slice[sliceIdx] : void 0;
4254
- const sig = formattedLineSig("sb", w, line);
5419
+ const activeCol = this.activeMatchCol(line, matchInfo);
5420
+ const sig = formattedLineSig(
5421
+ "sb",
5422
+ w,
5423
+ line,
5424
+ this.scrollbackHighlight,
5425
+ activeCol
5426
+ );
4255
5427
  this.paintRow(row, sig, () => {
4256
5428
  if (line) {
4257
- this.writeFormattedLine(line, w);
5429
+ this.writeFormattedLine(line, w, activeCol, activeLength);
4258
5430
  }
4259
5431
  });
4260
5432
  }
@@ -4454,7 +5626,9 @@ var init_screen = __esm({
4454
5626
  const row = this.term.height;
4455
5627
  const w = this.term.width;
4456
5628
  const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
4457
- const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}`;
5629
+ const right = this.bannerRightContent();
5630
+ const rightSig = right ? `${right.kind}|${right.text}` : "";
5631
+ const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}|` + rightSig;
4458
5632
  this.paintRow(row, sig, () => {
4459
5633
  const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
4460
5634
  const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
@@ -4481,6 +5655,16 @@ var init_screen = __esm({
4481
5655
  this.term.dim(planLabel);
4482
5656
  }
4483
5657
  this.term(" \xB7 ").dim(this.banner.hint);
5658
+ if (right) {
5659
+ const visibleWidth = stringWidth(right.text);
5660
+ const col = Math.max(1, w - visibleWidth + 1);
5661
+ this.term.moveTo(col, row).eraseLineAfter();
5662
+ if (right.kind === "search") {
5663
+ this.term.brightCyan.noFormat(right.text);
5664
+ } else {
5665
+ this.term.brightYellow.noFormat(right.text);
5666
+ }
5667
+ }
4484
5668
  });
4485
5669
  }
4486
5670
  placeCursor() {
@@ -4496,6 +5680,11 @@ var init_screen = __esm({
4496
5680
  this.term.moveTo(2, top2);
4497
5681
  return;
4498
5682
  }
5683
+ if (this.scrollbackSearch) {
5684
+ this.term.hideCursor(true);
5685
+ return;
5686
+ }
5687
+ this.term.hideCursor(false);
4499
5688
  const w = this.term.width;
4500
5689
  const room = Math.max(1, w - 2);
4501
5690
  const state = this.dispatcher.state();
@@ -4582,8 +5771,10 @@ var init_screen = __esm({
4582
5771
  }
4583
5772
  const prefix = line.prefix ?? "";
4584
5773
  const room = Math.max(1, width - prefix.length);
4585
- const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
5774
+ const stripMarkup = line.bodyStyle === "agent";
5775
+ const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room, { stripMarkup });
4586
5776
  const wrapped = [];
5777
+ let scanPos = 0;
4587
5778
  for (let i = 0; i < chunks.length; i++) {
4588
5779
  const chunk = chunks[i] ?? "";
4589
5780
  const wrappedLine = {
@@ -4602,6 +5793,15 @@ var init_screen = __esm({
4602
5793
  if (line.ansi) {
4603
5794
  wrappedLine.ansi = true;
4604
5795
  }
5796
+ if (id !== void 0 && chunk.length > 0) {
5797
+ const found = line.body.indexOf(chunk, scanPos);
5798
+ const colOffset = found === -1 ? scanPos : found;
5799
+ this.wrapOrigin.set(wrappedLine, {
5800
+ sourceLineId: id,
5801
+ sourceColOffset: colOffset
5802
+ });
5803
+ scanPos = colOffset + chunk.length;
5804
+ }
4605
5805
  wrapped.push(wrappedLine);
4606
5806
  }
4607
5807
  if (id !== void 0) {
@@ -4609,13 +5809,25 @@ var init_screen = __esm({
4609
5809
  }
4610
5810
  return wrapped;
4611
5811
  }
4612
- writeFormattedLine(line, width) {
5812
+ writeFormattedLine(line, width, activeMatchCol = null, activeMatchLength = 0) {
4613
5813
  if (line.prefix) {
4614
5814
  writeStyled(this.term, line.prefix, line.prefixStyle ?? line.bodyStyle);
4615
5815
  }
4616
5816
  const remaining = Math.max(0, width - (line.prefix?.length ?? 0));
4617
- const bodyText = line.ansi ? line.body : truncate(line.body, remaining);
4618
- writeStyled(this.term, bodyText, line.bodyStyle);
5817
+ const stripMarkup = line.bodyStyle === "agent";
5818
+ const bodyText = line.ansi ? line.body : truncate(line.body, remaining, { stripMarkup });
5819
+ if (this.scrollbackHighlight !== null && !line.ansi) {
5820
+ writeBodyWithHighlight(
5821
+ this.term,
5822
+ bodyText,
5823
+ line.bodyStyle,
5824
+ this.scrollbackHighlight,
5825
+ activeMatchCol,
5826
+ activeMatchLength
5827
+ );
5828
+ } else {
5829
+ writeStyled(this.term, bodyText, line.bodyStyle);
5830
+ }
4619
5831
  if (line.fillRow) {
4620
5832
  const visible = line.ansi ? stringWidth(bodyText) : bodyText.length;
4621
5833
  const pad = remaining - visible;
@@ -4630,6 +5842,7 @@ var init_screen = __esm({
4630
5842
  };
4631
5843
  NON_ASCII = /[^\x20-\x7e]/;
4632
5844
  SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
5845
+ TK_MARKUP_STYLE_CHAR = /[a-zA-Z+\-:_!#/]/;
4633
5846
  shortId = stripHydraSessionPrefix;
4634
5847
  }
4635
5848
  });
@@ -4653,6 +5866,14 @@ var init_input = __esm({
4653
5866
  queueIndex = -1;
4654
5867
  savedDraft = null;
4655
5868
  history = [];
5869
+ // Active reverse-incremental search over `history`. Set when ^r is
5870
+ // pressed; cleared when the user accepts (Enter / typing / arrows)
5871
+ // or cancels (ESC). `query` is the lowercased substring matched
5872
+ // against history entries; `matchIndices` are history indices in
5873
+ // newest→oldest order; `cursor` is the current index into that list.
5874
+ // `savedDraft` snapshots the buffer/cursor at the moment search
5875
+ // began so ESC can restore it.
5876
+ historySearch = null;
4656
5877
  // Waiting queue snapshot (excludes the in-flight head). Newest item lives
4657
5878
  // at the end so Up walks the array right-to-left.
4658
5879
  queue = [];
@@ -4672,7 +5893,8 @@ var init_input = __esm({
4672
5893
  col: this.col,
4673
5894
  planMode: this.planMode,
4674
5895
  historyIndex: this.historyIndex,
4675
- queueIndex: this.queueIndex
5896
+ queueIndex: this.queueIndex,
5897
+ historySearchQuery: this.historySearch?.query ?? null
4676
5898
  };
4677
5899
  }
4678
5900
  setTurnRunning(running) {
@@ -4682,6 +5904,7 @@ var init_input = __esm({
4682
5904
  this.history = [...history];
4683
5905
  this.historyIndex = -1;
4684
5906
  this.savedDraft = null;
5907
+ this.historySearch = null;
4685
5908
  }
4686
5909
  // Snapshot of the waiting queue (head excluded). Called by the app after
4687
5910
  // every queue mutation so Up/Down can walk a fresh view. queueIndex is
@@ -4710,8 +5933,44 @@ var init_input = __esm({
4710
5933
  this.historyIndex = -1;
4711
5934
  this.queueIndex = -1;
4712
5935
  this.savedDraft = null;
5936
+ this.historySearch = null;
4713
5937
  }
4714
5938
  feed(event) {
5939
+ if (this.historySearch !== null) {
5940
+ if (event.type === "char") {
5941
+ return this.mutateHistorySearchQuery(
5942
+ this.historySearch.query + event.ch.toLowerCase()
5943
+ );
5944
+ }
5945
+ if (event.type === "paste") {
5946
+ return this.mutateHistorySearchQuery(
5947
+ this.historySearch.query + event.text.replace(/\n/g, " ").toLowerCase()
5948
+ );
5949
+ }
5950
+ if (event.type === "key") {
5951
+ if (event.name === "ctrl-r") {
5952
+ return this.advanceHistorySearch();
5953
+ }
5954
+ if (event.name === "ctrl-s") {
5955
+ this.retreatHistorySearch();
5956
+ return [];
5957
+ }
5958
+ if (event.name === "escape") {
5959
+ this.cancelHistorySearch();
5960
+ return [];
5961
+ }
5962
+ if (event.name === "backspace") {
5963
+ if (this.historySearch.query.length === 0) {
5964
+ this.cancelHistorySearch();
5965
+ return [];
5966
+ }
5967
+ return this.mutateHistorySearchQuery(
5968
+ this.historySearch.query.slice(0, -1)
5969
+ );
5970
+ }
5971
+ this.historySearch = null;
5972
+ }
5973
+ }
4715
5974
  if (event.type === "char") {
4716
5975
  this.insertChar(event.ch);
4717
5976
  return [];
@@ -4789,6 +6048,10 @@ var init_input = __esm({
4789
6048
  return [{ type: "redraw" }];
4790
6049
  case "ctrl-p":
4791
6050
  return [{ type: "switch-session" }];
6051
+ case "ctrl-r":
6052
+ return this.startHistorySearch();
6053
+ case "ctrl-s":
6054
+ return [];
4792
6055
  case "ctrl-u":
4793
6056
  this.killLine();
4794
6057
  return [];
@@ -4824,6 +6087,7 @@ var init_input = __esm({
4824
6087
  this.historyIndex = -1;
4825
6088
  this.queueIndex = -1;
4826
6089
  this.savedDraft = null;
6090
+ this.historySearch = null;
4827
6091
  }
4828
6092
  insertChar(ch) {
4829
6093
  if (ch.length === 0) {
@@ -5051,6 +6315,143 @@ var init_input = __esm({
5051
6315
  this.clearBuffer();
5052
6316
  }
5053
6317
  }
6318
+ // Engage reverse-incremental search over prompt history. Uses the
6319
+ // current buffer text as the search query. With an empty buffer we
6320
+ // enter search mode in an "empty query, no match shown" state — the
6321
+ // banner indicator lights up, and as the user types we extend the
6322
+ // query and load top matches. We deliberately do NOT auto-load the
6323
+ // most recent entry on an empty ^R (that's a surprise — Up-arrow
6324
+ // already walks history if that's what they wanted). With a
6325
+ // non-empty query that has no history match, escalate straight to
6326
+ // scrollback search so the typed term searches session output.
6327
+ startHistorySearch() {
6328
+ const query = this.bufferText().toLowerCase();
6329
+ if (query.length === 0) {
6330
+ this.historySearch = {
6331
+ query: "",
6332
+ matchIndices: [],
6333
+ cursor: 0,
6334
+ savedDraft: {
6335
+ buffer: [...this.buffer],
6336
+ row: this.row,
6337
+ col: this.col
6338
+ }
6339
+ };
6340
+ return [];
6341
+ }
6342
+ const matchIndices = this.findHistoryMatches(query);
6343
+ if (matchIndices.length === 0) {
6344
+ return [{ type: "escalate-search", query }];
6345
+ }
6346
+ this.historySearch = {
6347
+ query,
6348
+ matchIndices,
6349
+ cursor: 0,
6350
+ savedDraft: {
6351
+ buffer: [...this.buffer],
6352
+ row: this.row,
6353
+ col: this.col
6354
+ }
6355
+ };
6356
+ this.loadEntry(this.history[matchIndices[0]] ?? "");
6357
+ return [];
6358
+ }
6359
+ // ^R advance. At the oldest match with a non-empty query, falls
6360
+ // through to scrollback search (same escalate path as a never-
6361
+ // matched startHistorySearch). With an empty query at the oldest
6362
+ // match (i.e. the user walked all history with no filter), advance
6363
+ // is a no-op so the buffer stays on the oldest entry.
6364
+ advanceHistorySearch() {
6365
+ if (this.historySearch === null) {
6366
+ return [];
6367
+ }
6368
+ const search = this.historySearch;
6369
+ const atOldest = search.cursor >= search.matchIndices.length - 1;
6370
+ if (atOldest) {
6371
+ if (search.query.length === 0) {
6372
+ return [];
6373
+ }
6374
+ const query = search.query;
6375
+ const draft = search.savedDraft;
6376
+ this.historySearch = null;
6377
+ this.buffer = [...draft.buffer];
6378
+ this.row = draft.row;
6379
+ this.col = draft.col;
6380
+ return [{ type: "escalate-search", query }];
6381
+ }
6382
+ search.cursor += 1;
6383
+ const idx = search.matchIndices[search.cursor];
6384
+ this.loadEntry(this.history[idx] ?? "");
6385
+ return [];
6386
+ }
6387
+ // ^S retreat — walk toward newer matches. No-op at the newest match
6388
+ // (no wrap, mirroring ^R no-wrap at the oldest).
6389
+ retreatHistorySearch() {
6390
+ if (this.historySearch === null) {
6391
+ return;
6392
+ }
6393
+ if (this.historySearch.cursor === 0) {
6394
+ return;
6395
+ }
6396
+ this.historySearch.cursor -= 1;
6397
+ const idx = this.historySearch.matchIndices[this.historySearch.cursor];
6398
+ this.loadEntry(this.history[idx] ?? "");
6399
+ }
6400
+ // Backspace / typing within search mode mutates the query and
6401
+ // re-searches. When the new query is empty, restore the saved
6402
+ // draft buffer (typically empty) and stay in search mode — the
6403
+ // user can keep typing. When the new query has matches, load the
6404
+ // top one. When the new query has no matches, escalate to scrollback
6405
+ // search so the typed term applies there instead.
6406
+ mutateHistorySearchQuery(newQuery) {
6407
+ if (this.historySearch === null) {
6408
+ return [];
6409
+ }
6410
+ if (newQuery.length === 0) {
6411
+ this.historySearch.query = "";
6412
+ this.historySearch.matchIndices = [];
6413
+ this.historySearch.cursor = 0;
6414
+ const draft = this.historySearch.savedDraft;
6415
+ this.buffer = [...draft.buffer];
6416
+ this.row = draft.row;
6417
+ this.col = draft.col;
6418
+ return [];
6419
+ }
6420
+ const matchIndices = this.findHistoryMatches(newQuery);
6421
+ if (matchIndices.length === 0) {
6422
+ const draft = this.historySearch.savedDraft;
6423
+ this.historySearch = null;
6424
+ this.buffer = [...draft.buffer];
6425
+ this.row = draft.row;
6426
+ this.col = draft.col;
6427
+ return [{ type: "escalate-search", query: newQuery }];
6428
+ }
6429
+ this.historySearch.query = newQuery;
6430
+ this.historySearch.matchIndices = matchIndices;
6431
+ this.historySearch.cursor = 0;
6432
+ this.loadEntry(this.history[matchIndices[0]] ?? "");
6433
+ return [];
6434
+ }
6435
+ findHistoryMatches(query) {
6436
+ const out = [];
6437
+ for (let i = this.history.length - 1; i >= 0; i--) {
6438
+ const entry = this.history[i] ?? "";
6439
+ if (query.length === 0 || entry.toLowerCase().includes(query)) {
6440
+ out.push(i);
6441
+ }
6442
+ }
6443
+ return out;
6444
+ }
6445
+ cancelHistorySearch() {
6446
+ if (this.historySearch === null) {
6447
+ return;
6448
+ }
6449
+ const draft = this.historySearch.savedDraft;
6450
+ this.historySearch = null;
6451
+ this.buffer = [...draft.buffer];
6452
+ this.row = draft.row;
6453
+ this.col = draft.col;
6454
+ }
5054
6455
  loadEntry(text) {
5055
6456
  this.buffer = text.split("\n");
5056
6457
  if (this.buffer.length === 0) {
@@ -5790,6 +7191,7 @@ import { nanoid as nanoid3 } from "nanoid";
5790
7191
  import termkit from "terminal-kit";
5791
7192
  async function runTuiApp(opts) {
5792
7193
  const config = await ensureConfig();
7194
+ logMaxBytes = config.tui.logMaxBytes;
5793
7195
  await ensureDaemonReachable(config);
5794
7196
  const term = termkit.terminal;
5795
7197
  const exitHint = {};
@@ -5810,7 +7212,7 @@ async function runSession(term, config, opts, exitHint) {
5810
7212
  process.exit(0);
5811
7213
  }
5812
7214
  const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
5813
- term.cyan(launchLabel)("\n");
7215
+ term.brightYellow(launchLabel)("\n");
5814
7216
  const protocol = config.daemon.tls ? "wss" : "ws";
5815
7217
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
5816
7218
  const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
@@ -5897,13 +7299,25 @@ async function runSession(term, config, opts, exitHint) {
5897
7299
  } else if (event?.kind === "turn-complete") {
5898
7300
  adjustPendingTurns(-1);
5899
7301
  }
7302
+ if (rawTag === "permission_resolved") {
7303
+ handlePermissionResolved(update);
7304
+ return;
7305
+ }
5900
7306
  appendRender(event);
5901
7307
  maybeDismissPermissionByToolUpdate(update);
5902
7308
  });
5903
- conn.onNotification("session/permission_resolved", (params) => {
5904
- const p = params ?? {};
5905
- dismissPermissionExternally(p.toolCall?.toolCallId, p.result);
5906
- });
7309
+ const handlePermissionResolved = (update) => {
7310
+ const u = update ?? {};
7311
+ const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : void 0;
7312
+ let outcome;
7313
+ if (u.outcome && typeof u.outcome === "object") {
7314
+ outcome = u.outcome;
7315
+ } else if (typeof u.chosenOptionId === "string") {
7316
+ outcome = { kind: "selected", optionId: u.chosenOptionId };
7317
+ }
7318
+ const result = outcome ? { outcome } : void 0;
7319
+ dismissPermissionExternally(toolCallId, result);
7320
+ };
5907
7321
  let pendingPermission = null;
5908
7322
  const dismissPermissionExternally = (toolCallId, result) => {
5909
7323
  if (!pendingPermission) {
@@ -5997,12 +7411,12 @@ async function runSession(term, config, opts, exitHint) {
5997
7411
  let agentInfoName;
5998
7412
  try {
5999
7413
  const initResult = await conn.request("initialize", {
6000
- protocolVersion: 1,
7414
+ protocolVersion: ACP_PROTOCOL_VERSION,
6001
7415
  clientCapabilities: {
6002
7416
  fs: { readTextFile: false, writeTextFile: false },
6003
7417
  terminal: false
6004
7418
  },
6005
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
7419
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6006
7420
  });
6007
7421
  agentInfoName = initResult?.agentInfo?.name;
6008
7422
  } catch {
@@ -6051,7 +7465,7 @@ async function runSession(term, config, opts, exitHint) {
6051
7465
  const attached = await conn.request("session/attach", {
6052
7466
  sessionId: ctx.sessionId,
6053
7467
  historyPolicy: "full",
6054
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
7468
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6055
7469
  });
6056
7470
  resolvedSessionId = attached.sessionId;
6057
7471
  exitHint.sessionId = resolvedSessionId;
@@ -6096,6 +7510,9 @@ async function runSession(term, config, opts, exitHint) {
6096
7510
  if (exitConfirmation && tryHandleExitConfirmKey(ev)) {
6097
7511
  continue;
6098
7512
  }
7513
+ if (tryHandleScrollbackSearchKey(ev)) {
7514
+ continue;
7515
+ }
6099
7516
  if (tryHandleCompletionKey(ev)) {
6100
7517
  continue;
6101
7518
  }
@@ -6105,6 +7522,9 @@ async function runSession(term, config, opts, exitHint) {
6105
7522
  }
6106
7523
  }
6107
7524
  refreshCompletions();
7525
+ screen.setBannerSearchIndicator(
7526
+ dispatcher.state().historySearchQuery
7527
+ );
6108
7528
  screen.refreshPrompt();
6109
7529
  }
6110
7530
  });
@@ -6167,6 +7587,55 @@ async function runSession(term, config, opts, exitHint) {
6167
7587
  dispatcher.replaceFirstLine(next);
6168
7588
  return true;
6169
7589
  };
7590
+ const tryHandleScrollbackSearchKey = (ev) => {
7591
+ if (!screen.isScrollbackSearchActive()) {
7592
+ if (ev.type === "key" && ev.name === "ctrl-r" && screen.isScrolledBack()) {
7593
+ screen.enterScrollbackSearch();
7594
+ screen.updateScrollbackSearchTerm("");
7595
+ return true;
7596
+ }
7597
+ return false;
7598
+ }
7599
+ if (ev.type === "char") {
7600
+ const term2 = screen.scrollbackSearchTerm() + ev.ch;
7601
+ screen.updateScrollbackSearchTerm(term2);
7602
+ return true;
7603
+ }
7604
+ if (ev.type === "paste") {
7605
+ const term2 = screen.scrollbackSearchTerm() + ev.text.replace(/\n/g, " ");
7606
+ screen.updateScrollbackSearchTerm(term2);
7607
+ return true;
7608
+ }
7609
+ if (ev.type === "key") {
7610
+ switch (ev.name) {
7611
+ case "ctrl-r":
7612
+ screen.advanceScrollbackSearch();
7613
+ return true;
7614
+ case "ctrl-s":
7615
+ screen.retreatScrollbackSearch();
7616
+ return true;
7617
+ case "backspace": {
7618
+ const term2 = screen.scrollbackSearchTerm();
7619
+ if (term2.length === 0) {
7620
+ screen.cancelScrollbackSearch();
7621
+ } else {
7622
+ screen.updateScrollbackSearchTerm(term2.slice(0, -1));
7623
+ }
7624
+ return true;
7625
+ }
7626
+ case "enter":
7627
+ screen.acceptScrollbackSearch();
7628
+ return true;
7629
+ case "escape":
7630
+ case "ctrl-c":
7631
+ screen.cancelScrollbackSearch();
7632
+ return true;
7633
+ default:
7634
+ return true;
7635
+ }
7636
+ }
7637
+ return true;
7638
+ };
6170
7639
  const tryHandlePermissionKey = (ev) => {
6171
7640
  if (!pendingPermission) {
6172
7641
  return false;
@@ -6437,6 +7906,10 @@ async function runSession(term, config, opts, exitHint) {
6437
7906
  toolsExpanded = !toolsExpanded;
6438
7907
  renderToolsBlock();
6439
7908
  return;
7909
+ case "escalate-search":
7910
+ screen.enterScrollbackSearch();
7911
+ screen.updateScrollbackSearchTerm(effect.query);
7912
+ return;
6440
7913
  }
6441
7914
  };
6442
7915
  const promptQueue = [];
@@ -6482,6 +7955,7 @@ async function runSession(term, config, opts, exitHint) {
6482
7955
  toolCallOrder.length = 0;
6483
7956
  toolsBlockStartedAt = null;
6484
7957
  toolsBlockEndedAt = null;
7958
+ toolsBlockStopReason = null;
6485
7959
  toolsExpanded = false;
6486
7960
  screen.clearScrollback();
6487
7961
  return true;
@@ -6685,6 +8159,7 @@ async function runSession(term, config, opts, exitHint) {
6685
8159
  let toolsExpanded = false;
6686
8160
  let toolsBlockStartedAt = null;
6687
8161
  let toolsBlockEndedAt = null;
8162
+ let toolsBlockStopReason = null;
6688
8163
  const TOOLS_COLLAPSED_LIMIT = 5;
6689
8164
  let agentBuffer = "";
6690
8165
  let agentKey = null;
@@ -6726,12 +8201,17 @@ async function runSession(term, config, opts, exitHint) {
6726
8201
  const inProgress = toolsBlockEndedAt === null;
6727
8202
  const end = toolsBlockEndedAt ?? Date.now();
6728
8203
  const elapsed = end - toolsBlockStartedAt;
8204
+ const stoppedReason = !inProgress && toolsBlockStopReason !== null && toolsBlockStopReason !== "end_turn" ? toolsBlockStopReason : null;
6729
8205
  let summary;
6730
8206
  if (total === 0) {
6731
- summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
8207
+ if (stoppedReason !== null) {
8208
+ summary = `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}`;
8209
+ } else {
8210
+ summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
8211
+ }
6732
8212
  } else {
6733
8213
  const noun = total === 1 ? "tool" : "tools";
6734
- const timing = inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
8214
+ const timing = stoppedReason !== null ? `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}` : inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
6735
8215
  const parts = [`${total} ${noun}`, timing];
6736
8216
  if (inProgress) {
6737
8217
  if (hidden > 0) {
@@ -6743,12 +8223,14 @@ async function runSession(term, config, opts, exitHint) {
6743
8223
  summary = parts.join(" \xB7 ");
6744
8224
  }
6745
8225
  const pureThinking = total === 0 && inProgress;
8226
+ const frozenStyle = stoppedReason !== null ? "tool-status-fail" : "tool";
8227
+ const frozenBodyStyle = stoppedReason !== null ? "tool-status-fail" : "dim";
6746
8228
  const lines = [
6747
8229
  {
6748
8230
  prefix: "\u2692 ",
6749
- prefixStyle: pureThinking ? "tool-status-running" : "tool",
8231
+ prefixStyle: pureThinking ? "tool-status-running" : frozenStyle,
6750
8232
  body: summary,
6751
- bodyStyle: pureThinking ? "tool-status-running" : "dim"
8233
+ bodyStyle: pureThinking ? "tool-status-running" : frozenBodyStyle
6752
8234
  }
6753
8235
  ];
6754
8236
  for (const id of visibleIds) {
@@ -6762,6 +8244,7 @@ async function runSession(term, config, opts, exitHint) {
6762
8244
  const startToolsBlock = () => {
6763
8245
  toolsBlockStartedAt = Date.now();
6764
8246
  toolsBlockEndedAt = null;
8247
+ toolsBlockStopReason = null;
6765
8248
  renderToolsBlock();
6766
8249
  };
6767
8250
  const recordToolCall = (id, title, status) => {
@@ -6786,6 +8269,7 @@ async function runSession(term, config, opts, exitHint) {
6786
8269
  if (toolsBlockStartedAt === null) {
6787
8270
  toolsBlockStartedAt = Date.now();
6788
8271
  toolsBlockEndedAt = null;
8272
+ toolsBlockStopReason = null;
6789
8273
  }
6790
8274
  toolCallOrder.push(id);
6791
8275
  }
@@ -6887,6 +8371,7 @@ async function runSession(term, config, opts, exitHint) {
6887
8371
  screen.clearKey("plan");
6888
8372
  if (toolsBlockStartedAt !== null) {
6889
8373
  toolsBlockEndedAt = Date.now();
8374
+ toolsBlockStopReason = event.stopReason ?? null;
6890
8375
  renderToolsBlock();
6891
8376
  screen.clearKey("tools");
6892
8377
  }
@@ -6894,6 +8379,7 @@ async function runSession(term, config, opts, exitHint) {
6894
8379
  toolCallOrder.length = 0;
6895
8380
  toolsBlockStartedAt = null;
6896
8381
  toolsBlockEndedAt = null;
8382
+ toolsBlockStopReason = null;
6897
8383
  toolsExpanded = false;
6898
8384
  screen.ensureSeparator();
6899
8385
  }
@@ -6937,12 +8423,14 @@ async function runSession(term, config, opts, exitHint) {
6937
8423
  closeAgentText();
6938
8424
  if (toolsBlockStartedAt !== null) {
6939
8425
  toolsBlockEndedAt = Date.now();
8426
+ toolsBlockStopReason = null;
6940
8427
  renderToolsBlock();
6941
8428
  screen.clearKey("tools");
6942
8429
  toolStates.clear();
6943
8430
  toolCallOrder.length = 0;
6944
8431
  toolsBlockStartedAt = null;
6945
8432
  toolsBlockEndedAt = null;
8433
+ toolsBlockStopReason = null;
6946
8434
  toolsExpanded = false;
6947
8435
  }
6948
8436
  screen.clearKey("plan");
@@ -6960,12 +8448,12 @@ async function runSession(term, config, opts, exitHint) {
6960
8448
  id: `tui-reinit-${nanoid3()}`,
6961
8449
  method: "initialize",
6962
8450
  params: {
6963
- protocolVersion: 1,
8451
+ protocolVersion: ACP_PROTOCOL_VERSION,
6964
8452
  clientCapabilities: {
6965
8453
  fs: { readTextFile: false, writeTextFile: false },
6966
8454
  terminal: false
6967
8455
  },
6968
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
8456
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6969
8457
  }
6970
8458
  };
6971
8459
  try {
@@ -6979,7 +8467,7 @@ async function runSession(term, config, opts, exitHint) {
6979
8467
  params: {
6980
8468
  sessionId: resolvedSessionId,
6981
8469
  historyPolicy: "none",
6982
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" },
8470
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION },
6983
8471
  ...upstreamSessionId !== void 0 ? {
6984
8472
  _meta: {
6985
8473
  [HYDRA_META_KEY]: {
@@ -7103,15 +8591,15 @@ function writeDebugLine(payload) {
7103
8591
  }
7104
8592
  function rotateIfBig(target) {
7105
8593
  try {
7106
- const stat3 = statSync(target);
7107
- if (stat3.size < TUI_LOG_MAX_BYTES) {
8594
+ const stat4 = statSync(target);
8595
+ if (stat4.size < logMaxBytes) {
7108
8596
  return;
7109
8597
  }
7110
8598
  renameSync(target, `${target}.0`);
7111
8599
  } catch {
7112
8600
  }
7113
8601
  }
7114
- var PLAN_PREFIX_TEXT, TUI_LOG_MAX_BYTES;
8602
+ var PLAN_PREFIX_TEXT, logMaxBytes;
7115
8603
  var init_app = __esm({
7116
8604
  "src/tui/app.ts"() {
7117
8605
  "use strict";
@@ -7122,6 +8610,7 @@ var init_app = __esm({
7122
8610
  init_daemon_bootstrap();
7123
8611
  init_session();
7124
8612
  init_paths();
8613
+ init_hydra_version();
7125
8614
  init_history();
7126
8615
  init_discovery();
7127
8616
  init_picker();
@@ -7131,7 +8620,7 @@ var init_app = __esm({
7131
8620
  init_render_update();
7132
8621
  init_format();
7133
8622
  PLAN_PREFIX_TEXT = "Plan mode is on. Outline what you would do without making any changes. Do not edit files, run shell commands, or otherwise execute side effects; produce a plan only.";
7134
- TUI_LOG_MAX_BYTES = 5 * 1024 * 1024;
8623
+ logMaxBytes = 5 * 1024 * 1024;
7135
8624
  }
7136
8625
  });
7137
8626
 
@@ -7148,9 +8637,9 @@ var init_tui = __esm({
7148
8637
  });
7149
8638
 
7150
8639
  // src/cli.ts
7151
- import { readFileSync } from "fs";
7152
- import { fileURLToPath } from "url";
7153
- import { dirname as dirname4, resolve as resolve4 } from "path";
8640
+ import { readFileSync as readFileSync2 } from "fs";
8641
+ import { fileURLToPath as fileURLToPath2 } from "url";
8642
+ import { dirname as dirname6, resolve as resolve4 } from "path";
7154
8643
 
7155
8644
  // src/cli/parse-args.ts
7156
8645
  function parseArgs(argv) {
@@ -7243,13 +8732,13 @@ New token: ${newToken}
7243
8732
  // src/cli/commands/daemon.ts
7244
8733
  init_paths();
7245
8734
  init_config();
7246
- import * as fsp5 from "fs/promises";
8735
+ import * as fsp6 from "fs/promises";
7247
8736
  import { setTimeout as sleep2 } from "timers/promises";
7248
8737
 
7249
8738
  // src/daemon/server.ts
7250
8739
  init_config();
7251
- import * as fs10 from "fs";
7252
- import * as fsp3 from "fs/promises";
8740
+ import * as fs11 from "fs";
8741
+ import * as fsp4 from "fs/promises";
7253
8742
  import Fastify from "fastify";
7254
8743
  import websocketPlugin from "@fastify/websocket";
7255
8744
  import pino from "pino";
@@ -7433,31 +8922,148 @@ function run(cmd, args) {
7433
8922
  const child = spawn(cmd, args, {
7434
8923
  stdio: ["ignore", "ignore", "inherit"]
7435
8924
  });
7436
- child.on("error", reject);
8925
+ child.on("error", reject);
8926
+ child.on("exit", (code, signal) => {
8927
+ if (code === 0) {
8928
+ resolve5();
8929
+ return;
8930
+ }
8931
+ reject(
8932
+ new Error(
8933
+ `${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
8934
+ )
8935
+ );
8936
+ });
8937
+ });
8938
+ }
8939
+ async function hasCommand(name) {
8940
+ return new Promise((resolve5) => {
8941
+ const finder = process.platform === "win32" ? "where" : "which";
8942
+ const child = spawn(finder, [name], { stdio: "ignore" });
8943
+ child.on("error", () => resolve5(false));
8944
+ child.on("exit", (code) => resolve5(code === 0));
8945
+ });
8946
+ }
8947
+ async function fileExists(p) {
8948
+ try {
8949
+ await fsp.access(p);
8950
+ return true;
8951
+ } catch {
8952
+ return false;
8953
+ }
8954
+ }
8955
+
8956
+ // src/core/npm-install.ts
8957
+ init_paths();
8958
+ import * as fsp2 from "fs/promises";
8959
+ import * as path3 from "path";
8960
+ import { spawn as spawn2 } from "child_process";
8961
+ var logSink2 = (msg) => {
8962
+ process.stderr.write(msg + "\n");
8963
+ };
8964
+ function setNpmInstallLogger(log) {
8965
+ logSink2 = log ?? ((msg) => process.stderr.write(msg + "\n"));
8966
+ }
8967
+ async function ensureNpmPackage(args) {
8968
+ const platformKey = currentPlatformKey();
8969
+ if (!platformKey) {
8970
+ throw new Error(
8971
+ `Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
8972
+ );
8973
+ }
8974
+ const installDir = paths.agentNpmInstallDir(
8975
+ args.agentId,
8976
+ platformKey,
8977
+ args.version
8978
+ );
8979
+ const binPath = path3.join(installDir, "node_modules", ".bin", args.bin);
8980
+ if (await fileExists2(binPath)) {
8981
+ return binPath;
8982
+ }
8983
+ await installInto({
8984
+ agentId: args.agentId,
8985
+ packageSpec: args.packageSpec,
8986
+ installDir
8987
+ });
8988
+ if (!await fileExists2(binPath)) {
8989
+ throw new Error(
8990
+ `Agent ${args.agentId}: npm install of ${args.packageSpec} did not produce bin ${args.bin} (looked in ${installDir}/node_modules/.bin/)`
8991
+ );
8992
+ }
8993
+ return binPath;
8994
+ }
8995
+ async function installInto(args) {
8996
+ await fsp2.mkdir(path3.dirname(args.installDir), { recursive: true });
8997
+ const tempDir = await fsp2.mkdtemp(`${args.installDir}.partial-`);
8998
+ try {
8999
+ logSink2(
9000
+ `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
9001
+ );
9002
+ await runNpmInstall({
9003
+ packageSpec: args.packageSpec,
9004
+ cwd: tempDir
9005
+ });
9006
+ try {
9007
+ await fsp2.rename(tempDir, args.installDir);
9008
+ } catch (err) {
9009
+ const e = err;
9010
+ if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists2(args.installDir)) {
9011
+ await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
9012
+ () => void 0
9013
+ );
9014
+ return;
9015
+ }
9016
+ throw err;
9017
+ }
9018
+ logSink2(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
9019
+ } catch (err) {
9020
+ await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
9021
+ () => void 0
9022
+ );
9023
+ throw err;
9024
+ }
9025
+ }
9026
+ function runNpmInstall(args) {
9027
+ return new Promise((resolve5, reject) => {
9028
+ const child = spawn2(
9029
+ "npm",
9030
+ ["install", "--no-audit", "--no-fund", "--silent", args.packageSpec],
9031
+ {
9032
+ cwd: args.cwd,
9033
+ stdio: ["ignore", "pipe", "pipe"]
9034
+ }
9035
+ );
9036
+ let stderrTail = "";
9037
+ child.stdout?.on("data", (chunk) => {
9038
+ void chunk;
9039
+ });
9040
+ child.stderr?.setEncoding("utf8");
9041
+ child.stderr?.on("data", (chunk) => {
9042
+ stderrTail = (stderrTail + chunk).slice(-4096);
9043
+ });
9044
+ child.on("error", (err) => {
9045
+ const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
9046
+ reject(new Error(msg));
9047
+ });
7437
9048
  child.on("exit", (code, signal) => {
7438
9049
  if (code === 0) {
7439
9050
  resolve5();
7440
9051
  return;
7441
9052
  }
9053
+ const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
9054
+ const tail = stderrTail.trim();
7442
9055
  reject(
7443
9056
  new Error(
7444
- `${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
9057
+ tail ? `npm install ${args.packageSpec} failed (${reason})
9058
+ stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
7445
9059
  )
7446
9060
  );
7447
9061
  });
7448
9062
  });
7449
9063
  }
7450
- async function hasCommand(name) {
7451
- return new Promise((resolve5) => {
7452
- const finder = process.platform === "win32" ? "where" : "which";
7453
- const child = spawn(finder, [name], { stdio: "ignore" });
7454
- child.on("error", () => resolve5(false));
7455
- child.on("exit", (code) => resolve5(code === 0));
7456
- });
7457
- }
7458
- async function fileExists(p) {
9064
+ async function fileExists2(p) {
7459
9065
  try {
7460
- await fsp.access(p);
9066
+ await fsp2.access(p);
7461
9067
  return true;
7462
9068
  } catch {
7463
9069
  return false;
@@ -7467,6 +9073,10 @@ async function fileExists(p) {
7467
9073
  // src/core/registry.ts
7468
9074
  var NpxDistribution = z2.object({
7469
9075
  package: z2.string(),
9076
+ // The bin to invoke after install. Defaults to the package basename
9077
+ // (e.g. "claude-code" for "@anthropic-ai/claude-code"). Required when
9078
+ // the package exposes a bin name that differs from its basename.
9079
+ bin: z2.string().optional(),
7470
9080
  args: z2.array(z2.string()).optional(),
7471
9081
  env: z2.record(z2.string()).optional()
7472
9082
  });
@@ -7630,9 +9240,23 @@ async function planSpawn(agent, callerArgs = []) {
7630
9240
  if (agent.distribution.npx) {
7631
9241
  const npx = agent.distribution.npx;
7632
9242
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
9243
+ if (process.env.HYDRA_ACP_SKIP_NPM_PREFETCH) {
9244
+ return {
9245
+ command: "npx",
9246
+ args: ["-y", npx.package, ...tail],
9247
+ env: npx.env ?? {}
9248
+ };
9249
+ }
9250
+ const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
9251
+ const binPath = await ensureNpmPackage({
9252
+ agentId: agent.id,
9253
+ version: agent.version ?? "current",
9254
+ packageSpec: npx.package,
9255
+ bin
9256
+ });
7633
9257
  return {
7634
- command: "npx",
7635
- args: ["-y", npx.package, ...tail],
9258
+ command: binPath,
9259
+ args: tail,
7636
9260
  env: npx.env ?? {}
7637
9261
  };
7638
9262
  }
@@ -7667,12 +9291,8 @@ async function planSpawn(agent, callerArgs = []) {
7667
9291
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
7668
9292
  }
7669
9293
 
7670
- // src/core/session-manager.ts
7671
- import * as fs8 from "fs/promises";
7672
- import { customAlphabet as customAlphabet3 } from "nanoid";
7673
-
7674
9294
  // src/core/agent-instance.ts
7675
- import { spawn as spawn2 } from "child_process";
9295
+ import { spawn as spawn3 } from "child_process";
7676
9296
 
7677
9297
  // src/acp/framing.ts
7678
9298
  init_types();
@@ -7753,17 +9373,22 @@ function ndjsonStreamFromStdio(stdout, stdin) {
7753
9373
 
7754
9374
  // src/core/agent-instance.ts
7755
9375
  init_connection();
9376
+ var DEFAULT_STDERR_TAIL_BYTES = 4096;
7756
9377
  var AgentInstance = class _AgentInstance {
7757
9378
  agentId;
7758
9379
  cwd;
7759
9380
  connection;
7760
9381
  child;
7761
9382
  exited = false;
9383
+ killed = false;
9384
+ stderrTail = "";
9385
+ stderrTailBytes;
7762
9386
  exitHandlers = [];
7763
9387
  constructor(opts, child) {
7764
9388
  this.agentId = opts.agentId;
7765
9389
  this.cwd = opts.cwd;
7766
9390
  this.child = child;
9391
+ this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
7767
9392
  if (!child.stdout || !child.stdin) {
7768
9393
  throw new Error("agent subprocess missing stdio");
7769
9394
  }
@@ -7771,22 +9396,36 @@ var AgentInstance = class _AgentInstance {
7771
9396
  this.connection = new JsonRpcConnection(stream);
7772
9397
  child.stderr?.setEncoding("utf8");
7773
9398
  child.stderr?.on("data", (chunk) => {
9399
+ this.stderrTail = (this.stderrTail + chunk).slice(-this.stderrTailBytes);
7774
9400
  process.stderr.write(`[${opts.agentId}] ${chunk}`);
7775
9401
  });
9402
+ child.on("error", (err) => {
9403
+ const msg = this.formatFailure(err.message);
9404
+ this.connection.fail(new Error(msg));
9405
+ });
7776
9406
  child.on("exit", (code, signal) => {
7777
9407
  this.exited = true;
9408
+ if (!this.killed) {
9409
+ const reason = `agent ${opts.agentId} exited before responding (code=${code} signal=${signal})`;
9410
+ this.connection.fail(new Error(this.formatFailure(reason)));
9411
+ }
7778
9412
  for (const handler of this.exitHandlers) {
7779
9413
  handler(code, signal);
7780
9414
  }
7781
9415
  });
7782
9416
  }
9417
+ formatFailure(reason) {
9418
+ const tail = this.stderrTail.trim();
9419
+ return tail ? `${reason}
9420
+ stderr: ${tail}` : reason;
9421
+ }
7783
9422
  static spawn(opts) {
7784
9423
  const env = {
7785
9424
  ...process.env,
7786
9425
  ...opts.plan.env,
7787
9426
  ...opts.extraEnv ?? {}
7788
9427
  };
7789
- const child = spawn2(opts.plan.command, opts.plan.args, {
9428
+ const child = spawn3(opts.plan.command, opts.plan.args, {
7790
9429
  cwd: opts.cwd,
7791
9430
  env,
7792
9431
  stdio: ["pipe", "pipe", "pipe"]
@@ -7803,196 +9442,33 @@ var AgentInstance = class _AgentInstance {
7803
9442
  if (this.exited) {
7804
9443
  return;
7805
9444
  }
9445
+ this.killed = true;
7806
9446
  await this.connection.close().catch(() => void 0);
7807
9447
  this.child.kill(signal);
7808
9448
  }
7809
9449
  };
7810
9450
 
7811
9451
  // src/core/session-manager.ts
9452
+ import * as fs9 from "fs/promises";
9453
+ import * as os2 from "os";
9454
+ import { customAlphabet as customAlphabet3 } from "nanoid";
7812
9455
  init_session();
7813
-
7814
- // src/core/session-store.ts
7815
- init_paths();
7816
- import * as fs5 from "fs/promises";
7817
- import * as path3 from "path";
7818
- import { customAlphabet as customAlphabet2 } from "nanoid";
7819
- import { z as z4 } from "zod";
7820
- var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7821
- var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
7822
- var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
7823
- function generateLineageId() {
7824
- return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
7825
- }
7826
- var PersistedAgentCommand = z4.object({
7827
- name: z4.string(),
7828
- description: z4.string().optional()
7829
- });
7830
- var PersistedUsage = z4.object({
7831
- used: z4.number().optional(),
7832
- size: z4.number().optional(),
7833
- costAmount: z4.number().optional(),
7834
- costCurrency: z4.string().optional()
7835
- });
7836
- var SessionRecord = z4.object({
7837
- version: z4.literal(1),
7838
- sessionId: z4.string(),
7839
- // Optional for back-compat with records written before this field
7840
- // existed; mergeForPersistence generates one on next write so any
7841
- // touched session converges to having a lineageId. A record that
7842
- // never gets written again (truly cold and untouched) just won't
7843
- // participate in lineage-based dedup, which is correct — it was
7844
- // never exported, so no incoming bundle can claim its lineage.
7845
- lineageId: z4.string().optional(),
7846
- upstreamSessionId: z4.string(),
7847
- // When non-empty, marks a session that was created by import and is
7848
- // waiting for its first attach to bootstrap a fresh upstream agent
7849
- // and replay the imported history as a takeover transcript. The
7850
- // origin's local id at export time, kept for debuggability and as a
7851
- // breadcrumb in `sessions list` (informational, not used for routing).
7852
- importedFromSessionId: z4.string().optional(),
7853
- agentId: z4.string(),
7854
- cwd: z4.string(),
7855
- title: z4.string().optional(),
7856
- agentArgs: z4.array(z4.string()).optional(),
7857
- // Snapshot of "what is currently true about this session" carried in
7858
- // meta.json so a late-attaching or cold-resurrected client can be
7859
- // told via the attach response _meta without depending on history
7860
- // replay of a snapshot-shaped notification.
7861
- currentModel: z4.string().optional(),
7862
- currentMode: z4.string().optional(),
7863
- currentUsage: PersistedUsage.optional(),
7864
- agentCommands: z4.array(PersistedAgentCommand).optional(),
7865
- createdAt: z4.string(),
7866
- updatedAt: z4.string()
7867
- });
7868
- var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7869
- function assertSafeId(id) {
7870
- if (!SESSION_ID_PATTERN.test(id)) {
7871
- throw new Error(`unsafe session id: ${id}`);
7872
- }
7873
- }
7874
- var SessionStore = class {
7875
- async write(record) {
7876
- assertSafeId(record.sessionId);
7877
- await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
7878
- const full = { version: 1, ...record };
7879
- await fs5.writeFile(
7880
- paths.sessionFile(record.sessionId),
7881
- JSON.stringify(full, null, 2) + "\n",
7882
- { encoding: "utf8", mode: 384 }
7883
- );
7884
- }
7885
- async read(sessionId) {
7886
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7887
- return void 0;
7888
- }
7889
- let raw;
7890
- try {
7891
- raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
7892
- } catch (err) {
7893
- const e = err;
7894
- if (e.code === "ENOENT") {
7895
- return void 0;
7896
- }
7897
- throw err;
7898
- }
7899
- try {
7900
- return SessionRecord.parse(JSON.parse(raw));
7901
- } catch {
7902
- return void 0;
7903
- }
7904
- }
7905
- async delete(sessionId) {
7906
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7907
- return;
7908
- }
7909
- try {
7910
- await fs5.unlink(paths.sessionFile(sessionId));
7911
- } catch (err) {
7912
- const e = err;
7913
- if (e.code !== "ENOENT") {
7914
- throw err;
7915
- }
7916
- }
7917
- try {
7918
- await fs5.rmdir(paths.sessionDir(sessionId));
7919
- } catch (err) {
7920
- const e = err;
7921
- if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
7922
- throw err;
7923
- }
7924
- }
7925
- }
7926
- // Find a persisted session by lineageId. Used by SessionManager.import
7927
- // to detect bundles that have already been imported (lineageId match)
7928
- // so we can either error out or, with replace:true, overwrite.
7929
- // Returns undefined if no record has that lineageId. Records that
7930
- // pre-date the lineageId field simply don't match — which is
7931
- // correct: they were never exported, so no incoming bundle can
7932
- // legitimately claim their lineage.
7933
- async findByLineageId(lineageId) {
7934
- if (lineageId.length === 0) {
7935
- return void 0;
7936
- }
7937
- const all = await this.list().catch(() => []);
7938
- for (const record of all) {
7939
- if (record.lineageId === lineageId) {
7940
- return record;
7941
- }
7942
- }
7943
- return void 0;
7944
- }
7945
- async list() {
7946
- let entries;
7947
- try {
7948
- entries = await fs5.readdir(paths.sessionsDir());
7949
- } catch (err) {
7950
- const e = err;
7951
- if (e.code === "ENOENT") {
7952
- return [];
7953
- }
7954
- throw err;
7955
- }
7956
- const records = [];
7957
- for (const entry of entries) {
7958
- const record = await this.read(entry);
7959
- if (record) {
7960
- records.push(record);
7961
- }
7962
- }
7963
- return records;
7964
- }
7965
- };
7966
- function recordFromMemorySession(args) {
7967
- const now = (/* @__PURE__ */ new Date()).toISOString();
7968
- return {
7969
- sessionId: args.sessionId,
7970
- lineageId: args.lineageId,
7971
- upstreamSessionId: args.upstreamSessionId,
7972
- importedFromSessionId: args.importedFromSessionId,
7973
- agentId: args.agentId,
7974
- cwd: args.cwd,
7975
- title: args.title,
7976
- agentArgs: args.agentArgs,
7977
- currentModel: args.currentModel,
7978
- currentMode: args.currentMode,
7979
- currentUsage: args.currentUsage,
7980
- agentCommands: args.agentCommands,
7981
- createdAt: args.createdAt ?? now,
7982
- updatedAt: args.updatedAt ?? now
7983
- };
7984
- }
9456
+ init_session_store();
7985
9457
 
7986
9458
  // src/core/history-store.ts
7987
9459
  init_paths();
7988
9460
  import * as fs6 from "fs/promises";
7989
9461
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
7990
- var MAX_ENTRIES = 1e3;
9462
+ var DEFAULT_MAX_ENTRIES = 1e3;
7991
9463
  var HistoryStore = class {
7992
9464
  // Serialize writes per session id so appends and rewrites don't
7993
9465
  // interleave JSONL lines on disk. The chain swallows errors so one
7994
9466
  // failed append doesn't poison every subsequent write.
7995
9467
  writeQueues = /* @__PURE__ */ new Map();
9468
+ maxEntries;
9469
+ constructor(options = {}) {
9470
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
9471
+ }
7996
9472
  async append(sessionId, entry) {
7997
9473
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
7998
9474
  return;
@@ -8094,8 +9570,8 @@ var HistoryStore = class {
8094
9570
  recordedAt: obj.recordedAt
8095
9571
  });
8096
9572
  }
8097
- if (out.length > MAX_ENTRIES) {
8098
- return out.slice(-MAX_ENTRIES);
9573
+ if (out.length > this.maxEntries) {
9574
+ return out.slice(-this.maxEntries);
8099
9575
  }
8100
9576
  return out;
8101
9577
  }
@@ -8140,6 +9616,7 @@ var HistoryStore = class {
8140
9616
  init_paths();
8141
9617
  init_history();
8142
9618
  init_types();
9619
+ init_hydra_version();
8143
9620
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
8144
9621
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
8145
9622
  var SessionManager = class {
@@ -8147,7 +9624,8 @@ var SessionManager = class {
8147
9624
  this.registry = registry;
8148
9625
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
8149
9626
  this.store = store ?? new SessionStore();
8150
- this.histories = new HistoryStore();
9627
+ this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
9628
+ this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
8151
9629
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
8152
9630
  this.defaultModels = options.defaultModels ?? {};
8153
9631
  }
@@ -8159,6 +9637,7 @@ var SessionManager = class {
8159
9637
  histories;
8160
9638
  idleTimeoutMs;
8161
9639
  defaultModels;
9640
+ sessionHistoryMaxEntries;
8162
9641
  // Serialize meta.json read-modify-write operations per session id so
8163
9642
  // concurrent snapshot updates (e.g. an agent emitting model + mode
8164
9643
  // back-to-back) don't lose writes via interleaved reads.
@@ -8182,6 +9661,7 @@ var SessionManager = class {
8182
9661
  idleTimeoutMs: this.idleTimeoutMs,
8183
9662
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
8184
9663
  historyStore: this.histories,
9664
+ historyMaxEntries: this.sessionHistoryMaxEntries,
8185
9665
  currentModel: fresh.initialModel
8186
9666
  });
8187
9667
  await this.attachManagerHooks(session);
@@ -8233,11 +9713,16 @@ var SessionManager = class {
8233
9713
  cwd: params.cwd,
8234
9714
  plan
8235
9715
  });
8236
- await agent.connection.request("initialize", {
8237
- protocolVersion: 1,
8238
- clientCapabilities: {},
8239
- clientInfo: { name: "hydra", version: "0.1.0" }
8240
- });
9716
+ try {
9717
+ await agent.connection.request("initialize", {
9718
+ protocolVersion: ACP_PROTOCOL_VERSION,
9719
+ clientCapabilities: {},
9720
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
9721
+ });
9722
+ } catch (err) {
9723
+ await agent.kill().catch(() => void 0);
9724
+ throw err;
9725
+ }
8241
9726
  let loadResult;
8242
9727
  try {
8243
9728
  loadResult = await agent.connection.request(
@@ -8249,10 +9734,12 @@ var SessionManager = class {
8249
9734
  }
8250
9735
  );
8251
9736
  } catch (err) {
8252
- await agent.kill().catch(() => void 0);
8253
- throw new Error(
8254
- `agent ${params.agentId} failed to load upstream session ${params.upstreamSessionId}: ${err.message}`
9737
+ process.stderr.write(
9738
+ `session/load failed for upstream ${params.upstreamSessionId} on ${params.agentId} (${err.message}); recovering via import-reseed
9739
+ `
8255
9740
  );
9741
+ await agent.kill().catch(() => void 0);
9742
+ return this.doResurrectFromImport(params);
8256
9743
  }
8257
9744
  const session = new Session({
8258
9745
  sessionId: params.hydraSessionId,
@@ -8266,6 +9753,7 @@ var SessionManager = class {
8266
9753
  idleTimeoutMs: this.idleTimeoutMs,
8267
9754
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
8268
9755
  historyStore: this.histories,
9756
+ historyMaxEntries: this.sessionHistoryMaxEntries,
8269
9757
  // Prefer what we previously stored from a current_model_update; if
8270
9758
  // we never captured one (e.g. old opencode sessions on disk before
8271
9759
  // this fix), fall back to the model the agent ships in its
@@ -8292,15 +9780,16 @@ var SessionManager = class {
8292
9780
  // so subsequent resurrects of this session use the normal session/load
8293
9781
  // path.
8294
9782
  async doResurrectFromImport(params) {
9783
+ const cwd = await this.resolveImportCwd(params.cwd);
8295
9784
  const fresh = await this.bootstrapAgent({
8296
9785
  agentId: params.agentId,
8297
- cwd: params.cwd,
9786
+ cwd,
8298
9787
  agentArgs: params.agentArgs,
8299
9788
  mcpServers: []
8300
9789
  });
8301
9790
  const session = new Session({
8302
9791
  sessionId: params.hydraSessionId,
8303
- cwd: params.cwd,
9792
+ cwd,
8304
9793
  agentId: params.agentId,
8305
9794
  agent: fresh.agent,
8306
9795
  upstreamSessionId: fresh.upstreamSessionId,
@@ -8310,6 +9799,7 @@ var SessionManager = class {
8310
9799
  idleTimeoutMs: this.idleTimeoutMs,
8311
9800
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
8312
9801
  historyStore: this.histories,
9802
+ historyMaxEntries: this.sessionHistoryMaxEntries,
8313
9803
  // Prefer the stored value (set by a previous current_model_update);
8314
9804
  // fall back to whatever the agent ships in its session/new response.
8315
9805
  currentModel: params.currentModel ?? fresh.initialModel,
@@ -8323,6 +9813,16 @@ var SessionManager = class {
8323
9813
  void session.seedFromImport().catch(() => void 0);
8324
9814
  return session;
8325
9815
  }
9816
+ async resolveImportCwd(cwd) {
9817
+ try {
9818
+ const stat4 = await fs9.stat(cwd);
9819
+ if (stat4.isDirectory()) {
9820
+ return cwd;
9821
+ }
9822
+ } catch {
9823
+ }
9824
+ return os2.homedir();
9825
+ }
8326
9826
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
8327
9827
  // → session/new. Shared by create() and the /hydra agent path so both
8328
9828
  // go through the same env / capabilities / error-handling.
@@ -8343,9 +9843,9 @@ var SessionManager = class {
8343
9843
  });
8344
9844
  try {
8345
9845
  await agent.connection.request("initialize", {
8346
- protocolVersion: 1,
9846
+ protocolVersion: ACP_PROTOCOL_VERSION,
8347
9847
  clientCapabilities: {},
8348
- clientInfo: { name: "hydra", version: "0.1.0" }
9848
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
8349
9849
  });
8350
9850
  const newResult = await agent.connection.request(
8351
9851
  "session/new",
@@ -8625,7 +10125,8 @@ var SessionManager = class {
8625
10125
  await this.writeImportedRecord({
8626
10126
  sessionId: existing.sessionId,
8627
10127
  bundle,
8628
- preservedCreatedAt: existing.createdAt
10128
+ preservedCreatedAt: existing.createdAt,
10129
+ cwd: opts.cwd
8629
10130
  });
8630
10131
  return {
8631
10132
  sessionId: existing.sessionId,
@@ -8634,7 +10135,11 @@ var SessionManager = class {
8634
10135
  };
8635
10136
  }
8636
10137
  const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
8637
- await this.writeImportedRecord({ sessionId: newId, bundle });
10138
+ await this.writeImportedRecord({
10139
+ sessionId: newId,
10140
+ bundle,
10141
+ cwd: opts.cwd
10142
+ });
8638
10143
  return {
8639
10144
  sessionId: newId,
8640
10145
  importedFromSessionId: bundle.session.sessionId,
@@ -8664,7 +10169,7 @@ var SessionManager = class {
8664
10169
  upstreamSessionId: "",
8665
10170
  importedFromSessionId: args.bundle.session.sessionId,
8666
10171
  agentId: args.bundle.session.agentId,
8667
- cwd: args.bundle.session.cwd,
10172
+ cwd: args.cwd ?? args.bundle.session.cwd,
8668
10173
  title: args.bundle.session.title,
8669
10174
  currentModel: args.bundle.session.currentModel,
8670
10175
  currentMode: args.bundle.session.currentMode,
@@ -8857,7 +10362,7 @@ function asString(value) {
8857
10362
  }
8858
10363
  async function loadPromptHistorySafely(sessionId) {
8859
10364
  try {
8860
- const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
10365
+ const raw = await fs9.readFile(paths.tuiHistoryFile(sessionId), "utf8");
8861
10366
  const out = [];
8862
10367
  for (const line of raw.split("\n")) {
8863
10368
  if (line.length === 0) {
@@ -8878,7 +10383,7 @@ async function loadPromptHistorySafely(sessionId) {
8878
10383
  }
8879
10384
  async function historyMtimeIso(sessionId) {
8880
10385
  try {
8881
- const st = await fs8.stat(paths.historyFile(sessionId));
10386
+ const st = await fs9.stat(paths.historyFile(sessionId));
8882
10387
  return new Date(st.mtimeMs).toISOString();
8883
10388
  } catch {
8884
10389
  return void 0;
@@ -8887,10 +10392,10 @@ async function historyMtimeIso(sessionId) {
8887
10392
 
8888
10393
  // src/core/extensions.ts
8889
10394
  init_paths();
8890
- import { spawn as spawn3 } from "child_process";
8891
- import * as fs9 from "fs";
8892
- import * as fsp2 from "fs/promises";
8893
- import * as path5 from "path";
10395
+ import { spawn as spawn4 } from "child_process";
10396
+ import * as fs10 from "fs";
10397
+ import * as fsp3 from "fs/promises";
10398
+ import * as path7 from "path";
8894
10399
  var RESTART_BASE_MS = 1e3;
8895
10400
  var RESTART_CAP_MS = 6e4;
8896
10401
  var STOP_GRACE_MS = 3e3;
@@ -8911,7 +10416,7 @@ var ExtensionManager = class {
8911
10416
  if (!this.context) {
8912
10417
  throw new Error("ExtensionManager: setContext must be called before start");
8913
10418
  }
8914
- await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
10419
+ await fsp3.mkdir(paths.extensionsDir(), { recursive: true });
8915
10420
  await this.reapOrphans();
8916
10421
  for (const entry of this.entries.values()) {
8917
10422
  if (!entry.config.enabled) {
@@ -9120,7 +10625,7 @@ var ExtensionManager = class {
9120
10625
  async reapOrphans() {
9121
10626
  let entries;
9122
10627
  try {
9123
- entries = await fsp2.readdir(paths.extensionsDir());
10628
+ entries = await fsp3.readdir(paths.extensionsDir());
9124
10629
  } catch (err) {
9125
10630
  const e = err;
9126
10631
  if (e.code === "ENOENT") {
@@ -9132,10 +10637,10 @@ var ExtensionManager = class {
9132
10637
  if (!entry.endsWith(".pid")) {
9133
10638
  continue;
9134
10639
  }
9135
- const pidPath = path5.join(paths.extensionsDir(), entry);
10640
+ const pidPath = path7.join(paths.extensionsDir(), entry);
9136
10641
  let pid;
9137
10642
  try {
9138
- const raw = await fsp2.readFile(pidPath, "utf8");
10643
+ const raw = await fsp3.readFile(pidPath, "utf8");
9139
10644
  const parsed = Number.parseInt(raw.trim(), 10);
9140
10645
  if (Number.isInteger(parsed) && parsed > 0) {
9141
10646
  pid = parsed;
@@ -9158,7 +10663,7 @@ var ExtensionManager = class {
9158
10663
  }
9159
10664
  }
9160
10665
  }
9161
- await fsp2.unlink(pidPath).catch(() => void 0);
10666
+ await fsp3.unlink(pidPath).catch(() => void 0);
9162
10667
  }
9163
10668
  }
9164
10669
  spawn(entry, attempt) {
@@ -9171,7 +10676,7 @@ var ExtensionManager = class {
9171
10676
  }
9172
10677
  const ext = entry.config;
9173
10678
  const command = ext.command.length > 0 ? ext.command : [ext.name];
9174
- const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
10679
+ const logStream = fs10.createWriteStream(paths.extensionLogFile(ext.name), {
9175
10680
  flags: "a"
9176
10681
  });
9177
10682
  logStream.write(
@@ -9199,7 +10704,7 @@ var ExtensionManager = class {
9199
10704
  const args = [...baseArgs, ...ext.args];
9200
10705
  let child;
9201
10706
  try {
9202
- child = spawn3(cmd, args, {
10707
+ child = spawn4(cmd, args, {
9203
10708
  env,
9204
10709
  stdio: ["ignore", "pipe", "pipe"],
9205
10710
  detached: false
@@ -9221,7 +10726,7 @@ var ExtensionManager = class {
9221
10726
  }
9222
10727
  if (typeof child.pid === "number") {
9223
10728
  try {
9224
- fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
10729
+ fs10.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
9225
10730
  `, {
9226
10731
  encoding: "utf8",
9227
10732
  mode: 384
@@ -9246,7 +10751,7 @@ var ExtensionManager = class {
9246
10751
  });
9247
10752
  child.on("exit", (code, signal) => {
9248
10753
  try {
9249
- fs9.unlinkSync(paths.extensionPidFile(ext.name));
10754
+ fs10.unlinkSync(paths.extensionPidFile(ext.name));
9250
10755
  } catch {
9251
10756
  }
9252
10757
  logStream.write(
@@ -9304,6 +10809,7 @@ function withCode2(err, code) {
9304
10809
 
9305
10810
  // src/daemon/server.ts
9306
10811
  init_paths();
10812
+ init_hydra_version();
9307
10813
 
9308
10814
  // src/daemon/auth.ts
9309
10815
  var BEARER_PREFIX = "Bearer ";
@@ -9359,78 +10865,10 @@ function constantTimeEqual(a, b) {
9359
10865
 
9360
10866
  // src/daemon/routes/sessions.ts
9361
10867
  init_config();
9362
- import * as os2 from "os";
9363
-
9364
- // src/core/bundle.ts
9365
- import { z as z5 } from "zod";
9366
- var HistoryEntrySchema = z5.object({
9367
- method: z5.string(),
9368
- params: z5.unknown(),
9369
- recordedAt: z5.number()
9370
- });
9371
- var BundleSession = z5.object({
9372
- // The exporter's local id. Regenerated fresh on import (sessionId is
9373
- // the local namespace; lineageId is what survives across hops).
9374
- sessionId: z5.string(),
9375
- // Required on bundles — the export path backfills if the source
9376
- // record was written before lineageId existed.
9377
- lineageId: z5.string(),
9378
- agentId: z5.string(),
9379
- cwd: z5.string(),
9380
- title: z5.string().optional(),
9381
- currentModel: z5.string().optional(),
9382
- currentMode: z5.string().optional(),
9383
- currentUsage: PersistedUsage.optional(),
9384
- agentCommands: z5.array(PersistedAgentCommand).optional(),
9385
- createdAt: z5.string(),
9386
- updatedAt: z5.string()
9387
- });
9388
- var Bundle = z5.object({
9389
- version: z5.literal(1),
9390
- exportedAt: z5.string(),
9391
- exportedFrom: z5.object({
9392
- hydraVersion: z5.string(),
9393
- machine: z5.string()
9394
- }),
9395
- session: BundleSession,
9396
- history: z5.array(HistoryEntrySchema),
9397
- promptHistory: z5.array(z5.string()).optional()
9398
- });
9399
- function encodeBundle(params) {
9400
- const bundle = {
9401
- version: 1,
9402
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9403
- exportedFrom: {
9404
- hydraVersion: params.hydraVersion,
9405
- machine: params.machine
9406
- },
9407
- session: {
9408
- sessionId: params.record.sessionId,
9409
- lineageId: params.record.lineageId,
9410
- agentId: params.record.agentId,
9411
- cwd: params.record.cwd,
9412
- ...params.record.title !== void 0 ? { title: params.record.title } : {},
9413
- ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
9414
- ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
9415
- ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
9416
- ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
9417
- createdAt: params.record.createdAt,
9418
- updatedAt: params.record.updatedAt
9419
- },
9420
- history: params.history
9421
- };
9422
- if (params.promptHistory !== void 0) {
9423
- bundle.promptHistory = params.promptHistory;
9424
- }
9425
- return bundle;
9426
- }
9427
- function decodeBundle(raw) {
9428
- return Bundle.parse(raw);
9429
- }
9430
-
9431
- // src/daemon/routes/sessions.ts
10868
+ init_bundle();
9432
10869
  init_types();
9433
- var HYDRA_VERSION = "0.1.0";
10870
+ init_hydra_version();
10871
+ import * as os3 from "os";
9434
10872
  function registerSessionRoutes(app, manager, defaults) {
9435
10873
  app.get("/v1/sessions", async (request) => {
9436
10874
  const query = request.query;
@@ -9501,12 +10939,12 @@ function registerSessionRoutes(app, manager, defaults) {
9501
10939
  history: exported.history,
9502
10940
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
9503
10941
  hydraVersion: HYDRA_VERSION,
9504
- machine: os2.hostname()
10942
+ machine: os3.hostname()
9505
10943
  });
9506
10944
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9507
10945
  reply.header(
9508
10946
  "Content-Disposition",
9509
- `attachment; filename="hydra-${id}-${stamp}.hydra"`
10947
+ `attachment; filename="${id}-${stamp}.hydra"`
9510
10948
  );
9511
10949
  reply.code(200).send(bundle);
9512
10950
  });
@@ -9516,6 +10954,14 @@ function registerSessionRoutes(app, manager, defaults) {
9516
10954
  reply.code(400).send({ error: "missing bundle" });
9517
10955
  return;
9518
10956
  }
10957
+ let cwdOverride;
10958
+ if (body.cwd !== void 0) {
10959
+ if (typeof body.cwd !== "string" || body.cwd.length === 0) {
10960
+ reply.code(400).send({ error: "cwd must be a non-empty string" });
10961
+ return;
10962
+ }
10963
+ cwdOverride = body.cwd;
10964
+ }
9519
10965
  let bundle;
9520
10966
  try {
9521
10967
  bundle = decodeBundle(body.bundle);
@@ -9528,7 +10974,8 @@ function registerSessionRoutes(app, manager, defaults) {
9528
10974
  }
9529
10975
  try {
9530
10976
  const result = await manager.importBundle(bundle, {
9531
- replace: body.replace === true
10977
+ replace: body.replace === true,
10978
+ ...cwdOverride !== void 0 ? { cwd: cwdOverride } : {}
9532
10979
  });
9533
10980
  reply.code(201).send(result);
9534
10981
  } catch (err) {
@@ -9769,8 +11216,7 @@ init_connection();
9769
11216
  init_ws_stream();
9770
11217
  init_types();
9771
11218
  import { nanoid as nanoid2 } from "nanoid";
9772
- var HYDRA_VERSION2 = "0.1.0";
9773
- var HYDRA_PROTOCOL_VERSION = 1;
11219
+ init_hydra_version();
9774
11220
  function registerAcpWsEndpoint(app, deps) {
9775
11221
  app.get("/acp", { websocket: true }, (socket, request) => {
9776
11222
  const token = tokenFromUpgradeRequest({
@@ -9864,15 +11310,20 @@ function registerAcpWsEndpoint(app, deps) {
9864
11310
  connection,
9865
11311
  session,
9866
11312
  state,
9867
- params.clientInfo
11313
+ params.clientInfo,
11314
+ params.clientId
11315
+ );
11316
+ const { entries: replay, appliedPolicy } = await session.attach(
11317
+ client,
11318
+ params.historyPolicy,
11319
+ { afterMessageId: params.afterMessageId }
9868
11320
  );
9869
- const replay = await session.attach(client, params.historyPolicy);
9870
11321
  state.attached.set(session.sessionId, {
9871
11322
  sessionId: session.sessionId,
9872
11323
  clientId: client.clientId
9873
11324
  });
9874
11325
  app.log.info(
9875
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
11326
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
9876
11327
  );
9877
11328
  for (const note of replay) {
9878
11329
  await connection.notify(note.method, note.params);
@@ -9880,6 +11331,13 @@ function registerAcpWsEndpoint(app, deps) {
9880
11331
  session.replayPendingPermissions(client);
9881
11332
  return {
9882
11333
  sessionId: session.sessionId,
11334
+ clientId: client.clientId,
11335
+ connectedClients: session.connectedClients(client.clientId),
11336
+ // appliedPolicy surfaces whether after_message fell back to full
11337
+ // (because afterMessageId wasn't found in history) — RFD #533
11338
+ // says the response.historyPolicy should reflect what actually
11339
+ // ran, not what was asked for.
11340
+ historyPolicy: appliedPolicy,
9883
11341
  replayed: replay.length,
9884
11342
  _meta: buildResponseMeta(session)
9885
11343
  };
@@ -9895,7 +11353,7 @@ function registerAcpWsEndpoint(app, deps) {
9895
11353
  const session = deps.manager.get(params.sessionId);
9896
11354
  session?.detach(att.clientId);
9897
11355
  state.attached.delete(params.sessionId);
9898
- return { detached: true };
11356
+ return { sessionId: params.sessionId, status: "detached" };
9899
11357
  });
9900
11358
  connection.onRequest("session/list", async (raw) => {
9901
11359
  const params = SessionListParams.parse(raw ?? {});
@@ -9968,7 +11426,7 @@ function registerAcpWsEndpoint(app, deps) {
9968
11426
  session = await deps.manager.resurrect(fromDisk);
9969
11427
  }
9970
11428
  const client = bindClientToSession(connection, session, state);
9971
- const replay = await session.attach(client, "pending_only");
11429
+ const { entries: replay } = await session.attach(client, "pending_only");
9972
11430
  state.attached.set(session.sessionId, {
9973
11431
  sessionId: session.sessionId,
9974
11432
  clientId: client.clientId
@@ -10033,8 +11491,8 @@ function buildResponseMeta(session) {
10033
11491
  }
10034
11492
  function buildInitializeResult() {
10035
11493
  return {
10036
- protocolVersion: HYDRA_PROTOCOL_VERSION,
10037
- agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
11494
+ protocolVersion: ACP_PROTOCOL_VERSION,
11495
+ agentInfo: { name: "hydra", version: HYDRA_VERSION },
10038
11496
  agentCapabilities: {
10039
11497
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
10040
11498
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -10062,25 +11520,24 @@ function buildInitializeResult() {
10062
11520
  ]
10063
11521
  };
10064
11522
  }
10065
- function bindClientToSession(connection, session, state, clientInfo) {
11523
+ function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
10066
11524
  void state;
10067
11525
  void session;
10068
11526
  return {
10069
- clientId: `cli_${nanoid2(8)}`,
11527
+ clientId: callerClientId ?? `cli_${nanoid2(8)}`,
10070
11528
  connection,
10071
11529
  clientInfo
10072
11530
  };
10073
11531
  }
10074
11532
 
10075
11533
  // src/daemon/server.ts
10076
- var HYDRA_VERSION3 = "0.1.0";
10077
11534
  async function startDaemon(config) {
10078
11535
  ensureLoopbackOrTls(config);
10079
11536
  const httpsOptions = config.daemon.tls ? {
10080
- key: await fsp3.readFile(config.daemon.tls.key),
10081
- cert: await fsp3.readFile(config.daemon.tls.cert)
11537
+ key: await fsp4.readFile(config.daemon.tls.key),
11538
+ cert: await fsp4.readFile(config.daemon.tls.cert)
10082
11539
  } : void 0;
10083
- await fsp3.mkdir(paths.home(), { recursive: true });
11540
+ await fsp4.mkdir(paths.home(), { recursive: true });
10084
11541
  const { stream: logStream, fileStream } = await buildLogStream(
10085
11542
  config.daemon.logLevel
10086
11543
  );
@@ -10089,12 +11546,18 @@ async function startDaemon(config) {
10089
11546
  level: config.daemon.logLevel,
10090
11547
  stream: logStream
10091
11548
  },
10092
- https: httpsOptions ?? null
11549
+ https: httpsOptions ?? null,
11550
+ // Session bundles can be large (full history + tool output);
11551
+ // the 1MB Fastify default rejects ordinary imports.
11552
+ bodyLimit: 256 * 1024 * 1024
10093
11553
  });
10094
11554
  await app.register(websocketPlugin);
10095
11555
  setBinaryInstallLogger((msg) => {
10096
11556
  app.log.info(msg);
10097
11557
  });
11558
+ setNpmInstallLogger((msg) => {
11559
+ app.log.info(msg);
11560
+ });
10098
11561
  const auth = bearerAuth({ config });
10099
11562
  app.addHook("onRequest", async (request, reply) => {
10100
11563
  if (request.routeOptions.config?.skipAuth) {
@@ -10106,12 +11569,14 @@ async function startDaemon(config) {
10106
11569
  await auth(request, reply);
10107
11570
  });
10108
11571
  const registry = new Registry(config);
10109
- const manager = new SessionManager(registry, void 0, void 0, {
11572
+ const spawner = (opts) => AgentInstance.spawn({ ...opts, stderrTailBytes: config.daemon.agentStderrTailBytes });
11573
+ const manager = new SessionManager(registry, spawner, void 0, {
10110
11574
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
10111
- defaultModels: config.defaultModels
11575
+ defaultModels: config.defaultModels,
11576
+ sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries
10112
11577
  });
10113
11578
  const extensions = new ExtensionManager(extensionList(config));
10114
- registerHealthRoutes(app, HYDRA_VERSION3);
11579
+ registerHealthRoutes(app, HYDRA_VERSION);
10115
11580
  registerSessionRoutes(app, manager, {
10116
11581
  agentId: config.defaultAgent,
10117
11582
  cwd: config.defaultCwd
@@ -10130,8 +11595,8 @@ async function startDaemon(config) {
10130
11595
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
10131
11596
  const address = app.server.address();
10132
11597
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
10133
- await fsp3.mkdir(paths.home(), { recursive: true });
10134
- await fsp3.writeFile(
11598
+ await fsp4.mkdir(paths.home(), { recursive: true });
11599
+ await fsp4.writeFile(
10135
11600
  paths.pidFile(),
10136
11601
  JSON.stringify({
10137
11602
  pid: process.pid,
@@ -10157,9 +11622,10 @@ async function startDaemon(config) {
10157
11622
  await manager.closeAll();
10158
11623
  await manager.flushMetaWrites();
10159
11624
  setBinaryInstallLogger(null);
11625
+ setNpmInstallLogger(null);
10160
11626
  await app.close();
10161
11627
  try {
10162
- fs10.unlinkSync(paths.pidFile());
11628
+ fs11.unlinkSync(paths.pidFile());
10163
11629
  } catch {
10164
11630
  }
10165
11631
  try {
@@ -10198,13 +11664,13 @@ function ensureLoopbackOrTls(config) {
10198
11664
  init_daemon_bootstrap();
10199
11665
 
10200
11666
  // src/cli/commands/log-tail.ts
10201
- import * as fs11 from "fs";
10202
- import * as fsp4 from "fs/promises";
11667
+ import * as fs12 from "fs";
11668
+ import * as fsp5 from "fs/promises";
10203
11669
  async function runLogTail(logPath, argv, notFoundMessage) {
10204
11670
  const opts = parseLogTailFlags(argv);
10205
- let stat3;
11671
+ let stat4;
10206
11672
  try {
10207
- stat3 = await fsp4.stat(logPath);
11673
+ stat4 = await fsp5.stat(logPath);
10208
11674
  } catch (err) {
10209
11675
  const e = err;
10210
11676
  if (e.code === "ENOENT") {
@@ -10215,14 +11681,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
10215
11681
  }
10216
11682
  throw err;
10217
11683
  }
10218
- let position = await printTail(logPath, stat3.size, opts.tail);
11684
+ let position = await printTail(logPath, stat4.size, opts.tail);
10219
11685
  if (!opts.follow) {
10220
11686
  return;
10221
11687
  }
10222
11688
  process.stdout.write(`-- following ${logPath} --
10223
11689
  `);
10224
11690
  let pending = false;
10225
- const watcher = fs11.watch(logPath, () => {
11691
+ const watcher = fs12.watch(logPath, () => {
10226
11692
  if (pending) {
10227
11693
  return;
10228
11694
  }
@@ -10230,14 +11696,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
10230
11696
  setImmediate(async () => {
10231
11697
  pending = false;
10232
11698
  try {
10233
- const s = await fsp4.stat(logPath);
11699
+ const s = await fsp5.stat(logPath);
10234
11700
  if (s.size <= position) {
10235
11701
  if (s.size < position) {
10236
11702
  position = s.size;
10237
11703
  }
10238
11704
  return;
10239
11705
  }
10240
- const fd = await fsp4.open(logPath, "r");
11706
+ const fd = await fsp5.open(logPath, "r");
10241
11707
  try {
10242
11708
  const buf = Buffer.alloc(s.size - position);
10243
11709
  await fd.read(buf, 0, buf.length, position);
@@ -10264,7 +11730,7 @@ async function printTail(logPath, fileSize, lines) {
10264
11730
  return fileSize;
10265
11731
  }
10266
11732
  const CHUNK = 64 * 1024;
10267
- const fd = await fsp4.open(logPath, "r");
11733
+ const fd = await fsp5.open(logPath, "r");
10268
11734
  try {
10269
11735
  let position = fileSize;
10270
11736
  let collected = "";
@@ -10433,7 +11899,7 @@ async function runDaemonStatus() {
10433
11899
  }
10434
11900
  async function readPidFile() {
10435
11901
  try {
10436
- const raw = await fsp5.readFile(paths.pidFile(), "utf8");
11902
+ const raw = await fsp6.readFile(paths.pidFile(), "utf8");
10437
11903
  return JSON.parse(raw);
10438
11904
  } catch (err) {
10439
11905
  const e = err;
@@ -10458,7 +11924,7 @@ init_sessions();
10458
11924
  // src/cli/commands/extensions.ts
10459
11925
  init_config();
10460
11926
  init_paths();
10461
- import * as fsp6 from "fs/promises";
11927
+ import * as fsp7 from "fs/promises";
10462
11928
  init_sessions();
10463
11929
  async function runExtensionsList() {
10464
11930
  const config = await loadConfig();
@@ -10654,11 +12120,11 @@ async function runExtensionsRemove(name) {
10654
12120
  }
10655
12121
  }
10656
12122
  async function readRawConfig() {
10657
- const raw = await fsp6.readFile(paths.config(), "utf8");
12123
+ const raw = await fsp7.readFile(paths.config(), "utf8");
10658
12124
  return JSON.parse(raw);
10659
12125
  }
10660
12126
  async function writeRawConfig(raw) {
10661
- await fsp6.writeFile(
12127
+ await fsp7.writeFile(
10662
12128
  paths.config(),
10663
12129
  JSON.stringify(raw, null, 2) + "\n",
10664
12130
  { encoding: "utf8", mode: 384 }
@@ -11001,10 +12467,22 @@ var SessionTracker = class {
11001
12467
  contexts = /* @__PURE__ */ new Map();
11002
12468
  pending = /* @__PURE__ */ new Map();
11003
12469
  pendingPermissions = /* @__PURE__ */ new Map();
12470
+ // Secondary index — same entries as `pendingPermissions`, keyed by the
12471
+ // tool call id from the request_permission params. Used to correlate
12472
+ // the daemon's `session/update`/`permission_resolved` events back to the
12473
+ // pending downstream request, since per-recipient JSON-RPC ids are no
12474
+ // longer carried on the wire.
12475
+ pendingPermissionsByToolCall = /* @__PURE__ */ new Map();
12476
+ // Most recent messageId observed on a session/update from the daemon
12477
+ // (prompt_received / turn_complete), keyed by sessionId. Used by the
12478
+ // reconnect-replay path to send historyPolicy:"after_message" with
12479
+ // afterMessageId so the daemon only replays the delta we missed.
12480
+ lastMessageIds = /* @__PURE__ */ new Map();
11004
12481
  observeFromClient(msg) {
11005
12482
  if (isResponse2(msg)) {
11006
- if (this.pendingPermissions.has(msg.id)) {
11007
- this.pendingPermissions.delete(msg.id);
12483
+ const existing = this.pendingPermissions.get(msg.id);
12484
+ if (existing) {
12485
+ this.deletePendingPermission(existing);
11008
12486
  }
11009
12487
  return;
11010
12488
  }
@@ -11031,16 +12509,34 @@ var SessionTracker = class {
11031
12509
  }
11032
12510
  }
11033
12511
  observeFromServer(msg) {
12512
+ if (!isRequest(msg) && !isResponse2(msg) && "method" in msg) {
12513
+ if (msg.method === "session/update") {
12514
+ const params = msg.params ?? {};
12515
+ const sessionId2 = typeof params.sessionId === "string" ? params.sessionId : void 0;
12516
+ const messageId = typeof params.update?.messageId === "string" ? params.update.messageId : void 0;
12517
+ if (sessionId2 && messageId) {
12518
+ this.lastMessageIds.set(sessionId2, messageId);
12519
+ }
12520
+ }
12521
+ return;
12522
+ }
11034
12523
  if (isRequest(msg)) {
11035
12524
  if (msg.method === "session/request_permission") {
11036
12525
  const params = msg.params ?? {};
11037
12526
  const sessionId2 = typeof params.sessionId === "string" ? params.sessionId : void 0;
11038
12527
  if (sessionId2) {
11039
- this.pendingPermissions.set(msg.id, {
12528
+ const toolCall = params.toolCall;
12529
+ const toolCallId = toolCall && typeof toolCall.toolCallId === "string" ? toolCall.toolCallId : void 0;
12530
+ const entry = {
11040
12531
  requestId: msg.id,
11041
12532
  sessionId: sessionId2,
12533
+ toolCallId,
11042
12534
  params
11043
- });
12535
+ };
12536
+ this.pendingPermissions.set(msg.id, entry);
12537
+ if (toolCallId) {
12538
+ this.pendingPermissionsByToolCall.set(toolCallId, entry);
12539
+ }
11044
12540
  }
11045
12541
  }
11046
12542
  return;
@@ -11088,6 +12584,14 @@ var SessionTracker = class {
11088
12584
  }
11089
12585
  forget(sessionId) {
11090
12586
  this.contexts.delete(sessionId);
12587
+ this.lastMessageIds.delete(sessionId);
12588
+ }
12589
+ // Latest messageId observed for `sessionId`, or undefined if we
12590
+ // haven't seen one (no prompt_received/turn_complete has flowed
12591
+ // through yet). Used by reconnect-replay to issue
12592
+ // historyPolicy:"after_message" with afterMessageId.
12593
+ lastMessageId(sessionId) {
12594
+ return this.lastMessageIds.get(sessionId);
11091
12595
  }
11092
12596
  clearPending() {
11093
12597
  this.pending.clear();
@@ -11095,15 +12599,29 @@ var SessionTracker = class {
11095
12599
  takePendingPermissions() {
11096
12600
  const out = [...this.pendingPermissions.values()];
11097
12601
  this.pendingPermissions.clear();
12602
+ this.pendingPermissionsByToolCall.clear();
11098
12603
  return out;
11099
12604
  }
11100
12605
  takePendingPermission(requestId) {
11101
12606
  const found = this.pendingPermissions.get(requestId);
11102
12607
  if (found) {
11103
- this.pendingPermissions.delete(requestId);
12608
+ this.deletePendingPermission(found);
12609
+ }
12610
+ return found;
12611
+ }
12612
+ takePendingPermissionByToolCall(toolCallId) {
12613
+ const found = this.pendingPermissionsByToolCall.get(toolCallId);
12614
+ if (found) {
12615
+ this.deletePendingPermission(found);
11104
12616
  }
11105
12617
  return found;
11106
12618
  }
12619
+ deletePendingPermission(entry) {
12620
+ this.pendingPermissions.delete(entry.requestId);
12621
+ if (entry.toolCallId) {
12622
+ this.pendingPermissionsByToolCall.delete(entry.toolCallId);
12623
+ }
12624
+ }
11107
12625
  };
11108
12626
  function isRequest(msg) {
11109
12627
  return "method" in msg && "id" in msg && msg.id !== void 0;
@@ -11139,7 +12657,7 @@ async function runShim(opts) {
11139
12657
  `
11140
12658
  );
11141
12659
  for (const ctx of contexts) {
11142
- await replayAttach(upstream, ctx);
12660
+ await replayAttach(upstream, ctx, tracker.lastMessageId(ctx.sessionId));
11143
12661
  }
11144
12662
  }
11145
12663
  });
@@ -11198,25 +12716,47 @@ function wireShim({
11198
12716
  });
11199
12717
  }
11200
12718
  function maybeReplyToResolvedPermission(msg, tracker, downstream) {
11201
- if (!isPermissionResolvedNotification(msg)) {
12719
+ const update = extractPermissionResolvedUpdate(msg);
12720
+ if (!update) {
11202
12721
  return;
11203
12722
  }
11204
- const params = msg.params ?? {};
11205
- if (params.requestId === void 0) {
12723
+ const toolCallId = typeof update.toolCallId === "string" ? update.toolCallId : void 0;
12724
+ if (!toolCallId) {
11206
12725
  return;
11207
12726
  }
11208
- const pending = tracker.takePendingPermission(params.requestId);
12727
+ const pending = tracker.takePendingPermissionByToolCall(toolCallId);
11209
12728
  if (!pending) {
11210
12729
  return;
11211
12730
  }
12731
+ const outcome = reconstructOutcome(update);
11212
12732
  void downstream.send({
11213
12733
  jsonrpc: "2.0",
11214
12734
  id: pending.requestId,
11215
- result: params.result ?? null
12735
+ result: outcome ? { outcome } : null
11216
12736
  }).catch(() => void 0);
11217
12737
  }
11218
- function isPermissionResolvedNotification(msg) {
11219
- return "method" in msg && msg.method === "session/permission_resolved" && !("id" in msg && msg.id !== void 0);
12738
+ function extractPermissionResolvedUpdate(msg) {
12739
+ if (!isSessionUpdateNotification(msg)) {
12740
+ return void 0;
12741
+ }
12742
+ const params = msg.params ?? {};
12743
+ const update = params.update;
12744
+ if (!update || typeof update !== "object" || update.sessionUpdate !== "permission_resolved") {
12745
+ return void 0;
12746
+ }
12747
+ return update;
12748
+ }
12749
+ function isSessionUpdateNotification(msg) {
12750
+ return "method" in msg && msg.method === "session/update" && !("id" in msg && msg.id !== void 0);
12751
+ }
12752
+ function reconstructOutcome(update) {
12753
+ if (update.outcome && typeof update.outcome === "object") {
12754
+ return update.outcome;
12755
+ }
12756
+ if (typeof update.chosenOptionId === "string") {
12757
+ return { kind: "selected", optionId: update.chosenOptionId };
12758
+ }
12759
+ return void 0;
11220
12760
  }
11221
12761
  async function cancelPendingPermissions(tracker, downstream) {
11222
12762
  const pendings = tracker.takePendingPermissions();
@@ -11228,21 +12768,26 @@ async function cancelPendingPermissions(tracker, downstream) {
11228
12768
  `
11229
12769
  );
11230
12770
  for (const pending of pendings) {
11231
- const params = {
11232
- ...pending.params,
11233
- resolvedBy: "hydra-acp",
11234
- result: {
11235
- outcome: { kind: "cancelled", reason: "daemon-disconnected" }
11236
- }
12771
+ const sessionId = typeof pending.params.sessionId === "string" ? pending.params.sessionId : void 0;
12772
+ if (!sessionId) {
12773
+ continue;
12774
+ }
12775
+ const update = {
12776
+ sessionUpdate: "permission_resolved",
12777
+ outcome: { kind: "cancelled", reason: "daemon-disconnected" },
12778
+ resolvedBy: { clientId: "hydra-acp" }
11237
12779
  };
12780
+ if (pending.toolCallId) {
12781
+ update.toolCallId = pending.toolCallId;
12782
+ }
11238
12783
  await downstream.send({
11239
12784
  jsonrpc: "2.0",
11240
- method: "session/permission_resolved",
11241
- params
12785
+ method: "session/update",
12786
+ params: { sessionId, update }
11242
12787
  }).catch(() => void 0);
11243
12788
  }
11244
12789
  }
11245
- async function replayAttach(stream, ctx) {
12790
+ async function replayAttach(stream, ctx, afterMessageId) {
11246
12791
  const resumeHints = {
11247
12792
  upstreamSessionId: ctx.upstreamSessionId,
11248
12793
  agentId: ctx.agentId,
@@ -11254,19 +12799,21 @@ async function replayAttach(stream, ctx) {
11254
12799
  if (ctx.agentArgs && ctx.agentArgs.length > 0) {
11255
12800
  resumeHints.agentArgs = ctx.agentArgs;
11256
12801
  }
12802
+ const params = {
12803
+ sessionId: ctx.sessionId,
12804
+ _meta: { "hydra-acp": { resume: resumeHints } }
12805
+ };
12806
+ if (afterMessageId) {
12807
+ params.historyPolicy = "after_message";
12808
+ params.afterMessageId = afterMessageId;
12809
+ } else {
12810
+ params.historyPolicy = "pending_only";
12811
+ }
11257
12812
  const request = {
11258
12813
  jsonrpc: "2.0",
11259
12814
  id: `resume-${ctx.sessionId}-${Date.now()}`,
11260
12815
  method: "session/attach",
11261
- params: {
11262
- sessionId: ctx.sessionId,
11263
- historyPolicy: "pending_only",
11264
- _meta: {
11265
- "hydra-acp": {
11266
- resume: resumeHints
11267
- }
11268
- }
11269
- }
12816
+ params
11270
12817
  };
11271
12818
  try {
11272
12819
  const resp = await stream.request(request);
@@ -11423,8 +12970,8 @@ async function main() {
11423
12970
  await runSessionsKill(positional[2]);
11424
12971
  return;
11425
12972
  }
11426
- if (sub === "rm") {
11427
- await runSessionsRm(positional[2]);
12973
+ if (sub === "remove") {
12974
+ await runSessionsRemove(positional[2]);
11428
12975
  return;
11429
12976
  }
11430
12977
  if (sub === "export") {
@@ -11433,8 +12980,11 @@ async function main() {
11433
12980
  return;
11434
12981
  }
11435
12982
  if (sub === "import") {
12983
+ const cwd = resolveOption(flags, "cwd");
11436
12984
  await runSessionsImport(positional[2], {
11437
- replace: flags.replace === true
12985
+ replace: flags.replace === true,
12986
+ info: flags.info === true,
12987
+ ...cwd !== void 0 ? { cwd } : {}
11438
12988
  });
11439
12989
  return;
11440
12990
  }
@@ -11537,9 +13087,9 @@ async function dispatchTui(flags, base) {
11537
13087
  }
11538
13088
  function readVersion() {
11539
13089
  try {
11540
- const here = dirname4(fileURLToPath(import.meta.url));
13090
+ const here = dirname6(fileURLToPath2(import.meta.url));
11541
13091
  const pkg = JSON.parse(
11542
- readFileSync(resolve4(here, "../package.json"), "utf8")
13092
+ readFileSync2(resolve4(here, "../package.json"), "utf8")
11543
13093
  );
11544
13094
  return pkg.version ?? "unknown";
11545
13095
  } catch {
@@ -11566,11 +13116,11 @@ function printHelp() {
11566
13116
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
11567
13117
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
11568
13118
  " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
11569
- " hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
13119
+ " hydra-acp sessions remove <id> Remove a session entirely (live or cold)",
11570
13120
  " hydra-acp sessions export <id> [--out <file>|.]",
11571
13121
  " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
11572
- " hydra-acp sessions import <file>|- [--replace]",
11573
- " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live)",
13122
+ " hydra-acp sessions import <file>|- [--replace] [--cwd <path>] [--info]",
13123
+ " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live); --cwd overrides the bundle's recorded working directory; --info prints the bundle's meta without importing",
11574
13124
  " hydra-acp extensions list List configured extensions and live state",
11575
13125
  " hydra-acp extensions add <name> [opts] Add an extension to config",
11576
13126
  " hydra-acp extensions remove <name> Remove an extension from config",