@hydra-acp/cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -17,6 +17,11 @@ function hydraHome() {
17
17
  if (override && override.length > 0) {
18
18
  return path.resolve(override);
19
19
  }
20
+ if (process.env.VITEST) {
21
+ throw new Error(
22
+ "HYDRA_ACP_HOME is unset under VITEST; vitest.setup.ts must run first"
23
+ );
24
+ }
20
25
  return path.join(os.homedir(), ".hydra-acp");
21
26
  }
22
27
  var ROOT_ENV, paths;
@@ -43,7 +48,8 @@ var init_paths = __esm({
43
48
  extensionsDir: () => path.join(hydraHome(), "extensions"),
44
49
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
45
50
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
46
- tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
51
+ tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
52
+ tuiLogFile: () => path.join(hydraHome(), "tui.log")
47
53
  };
48
54
  }
49
55
  });
@@ -83,8 +89,7 @@ async function ensureConfig() {
83
89
  if (e.code !== "ENOENT") {
84
90
  throw err;
85
91
  }
86
- const config = defaultConfig();
87
- await writeConfig(config);
92
+ const config = await writeMinimalInitConfig();
88
93
  process.stderr.write(
89
94
  `hydra-acp: initialized ${paths.config()} with a fresh auth token.
90
95
  `
@@ -93,9 +98,23 @@ async function ensureConfig() {
93
98
  }
94
99
  return loadConfig();
95
100
  }
96
- async function writeConfig(config) {
101
+ async function writeMinimalInitConfig(authToken) {
102
+ const token = authToken ?? generateAuthToken();
103
+ const minimal = { daemon: { authToken: token } };
97
104
  await fs.mkdir(paths.home(), { recursive: true });
98
- await fs.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
105
+ await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
106
+ encoding: "utf8",
107
+ mode: 384
108
+ });
109
+ return HydraConfig.parse(minimal);
110
+ }
111
+ async function updateConfigField(mutate) {
112
+ const path6 = paths.config();
113
+ const text = await fs.readFile(path6, "utf8");
114
+ const raw = JSON.parse(text);
115
+ mutate(raw);
116
+ HydraConfig.parse(raw);
117
+ await fs.writeFile(path6, JSON.stringify(raw, null, 2) + "\n", {
99
118
  encoding: "utf8",
100
119
  mode: 384
101
120
  });
@@ -109,13 +128,6 @@ function generateAuthToken() {
109
128
  }
110
129
  return `hydra_token_${hex}`;
111
130
  }
112
- function defaultConfig() {
113
- return HydraConfig.parse({
114
- daemon: {
115
- authToken: generateAuthToken()
116
- }
117
- });
118
- }
119
131
  function expandHome(p) {
120
132
  if (p === "~" || p === "$HOME") {
121
133
  return homedir2();
@@ -144,7 +156,7 @@ var init_config = __esm({
144
156
  authToken: z.string().min(16),
145
157
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
146
158
  tls: TlsConfig.optional(),
147
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
159
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
148
160
  });
149
161
  RegistryConfig = z.object({
150
162
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -157,7 +169,11 @@ var init_config = __esm({
157
169
  // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
158
170
  // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
159
171
  // or 0 to disable throttling entirely.
160
- repaintThrottleMs: z.number().int().nonnegative().default(1e3)
172
+ repaintThrottleMs: z.number().int().nonnegative().default(1e3),
173
+ // Cap on logical lines retained in the in-memory scrollback render
174
+ // buffer. Oldest lines are dropped on overflow. The on-disk session
175
+ // history is unaffected; this only bounds the TUI's local view buffer.
176
+ maxScrollbackLines: z.number().int().positive().default(1e4)
161
177
  });
162
178
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
163
179
  ExtensionBody = z.object({
@@ -182,7 +198,7 @@ var init_config = __esm({
182
198
  // recency and truncated to this count. `--all` overrides in the CLI.
183
199
  sessionListColdLimit: z.number().int().nonnegative().default(20),
184
200
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
185
- tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
201
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
186
202
  });
187
203
  }
188
204
  });
@@ -226,6 +242,9 @@ function extractHydraMeta(meta) {
226
242
  if (typeof obj.currentMode === "string") {
227
243
  out.currentMode = obj.currentMode;
228
244
  }
245
+ if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
246
+ out.turnStartedAt = obj.turnStartedAt;
247
+ }
229
248
  if (Array.isArray(obj.availableCommands)) {
230
249
  const cmds = [];
231
250
  for (const raw of obj.availableCommands) {
@@ -264,7 +283,8 @@ var init_types = __esm({
264
283
  SessionNotFound: -32001,
265
284
  PermissionDenied: -32002,
266
285
  AlreadyAttached: -32003,
267
- AgentNotInstalled: -32005
286
+ AgentNotInstalled: -32005,
287
+ BundleAlreadyImported: -32010
268
288
  };
269
289
  InitializeParams = z3.object({
270
290
  protocolVersion: z3.number().optional(),
@@ -388,9 +408,9 @@ var init_connection = __esm({
388
408
  }
389
409
  const id = nanoid();
390
410
  const message = { jsonrpc: "2.0", id, method, params };
391
- const response = new Promise((resolve2, reject) => {
411
+ const response = new Promise((resolve4, reject) => {
392
412
  this.pending.set(id, {
393
- resolve: (result) => resolve2(result),
413
+ resolve: (result) => resolve4(result),
394
414
  reject
395
415
  });
396
416
  this.stream.send(message).catch((err) => {
@@ -614,7 +634,7 @@ function firstLine(text, max) {
614
634
  }
615
635
  return void 0;
616
636
  }
617
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
637
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, MAX_HISTORY_ENTRIES, COMPACT_EVERY, Session, STATE_UPDATE_KINDS;
618
638
  var init_session = __esm({
619
639
  "src/core/session.ts"() {
620
640
  "use strict";
@@ -623,6 +643,8 @@ var init_session = __esm({
623
643
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
624
644
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
625
645
  HYDRA_SESSION_PREFIX = "hydra_session_";
646
+ MAX_HISTORY_ENTRIES = 1e3;
647
+ COMPACT_EVERY = 200;
626
648
  Session = class {
627
649
  sessionId;
628
650
  cwd;
@@ -642,8 +664,8 @@ var init_session = __esm({
642
664
  currentModel;
643
665
  currentMode;
644
666
  updatedAt;
667
+ createdAt;
645
668
  clients = /* @__PURE__ */ new Map();
646
- history = [];
647
669
  historyStore;
648
670
  promptQueue = [];
649
671
  promptInFlight = false;
@@ -659,6 +681,15 @@ var init_session = __esm({
659
681
  // True once we've observed our first session/prompt; gates the
660
682
  // first-prompt-seeded title so subsequent prompts don't churn it.
661
683
  firstPromptSeeded = false;
684
+ // Wall-clock when the active prompt started, undefined when idle.
685
+ // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
686
+ // Drives the mid-turn elapsed counter delivered to fresh attachers.
687
+ promptStartedAt;
688
+ // Counts appends since the last compaction. When it hits COMPACT_EVERY
689
+ // we ask the history store to trim the file to the most recent
690
+ // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
691
+ // file-size checks.
692
+ appendCount = 0;
662
693
  // Permission requests that have been broadcast to one or more
663
694
  // clients but have not yet resolved. Replayed to clients that
664
695
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -673,6 +704,12 @@ var init_session = __esm({
673
704
  internalPromptCapture;
674
705
  idleTimeoutMs;
675
706
  idleTimer;
707
+ // Time of the last recordable broadcast (or session creation, if
708
+ // none yet). Drives the inactivity-based idle close; deliberately
709
+ // does NOT include snapshot state pings (model/mode/title/commands)
710
+ // or attach/detach, which would otherwise let passive observers
711
+ // and noisy state churn keep a quiet session alive forever.
712
+ lastRecordedAt;
676
713
  spawnReplacementAgent;
677
714
  agentChangeHandlers = [];
678
715
  // Last available_commands_update we observed from the agent. Stored
@@ -703,12 +740,15 @@ var init_session = __esm({
703
740
  }
704
741
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
705
742
  this.spawnReplacementAgent = init.spawnReplacementAgent;
706
- this.historyStore = init.historyStore;
707
- if (init.seedHistory && init.seedHistory.length > 0) {
708
- this.history = [...init.seedHistory];
743
+ if (init.firstPromptSeeded) {
744
+ this.firstPromptSeeded = true;
709
745
  }
746
+ this.historyStore = init.historyStore;
710
747
  this.updatedAt = Date.now();
748
+ this.createdAt = init.createdAt ?? this.updatedAt;
749
+ this.lastRecordedAt = this.updatedAt;
711
750
  this.wireAgent(this.agent);
751
+ this.scheduleIdleCheck();
712
752
  }
713
753
  broadcastMergedCommands() {
714
754
  const merged = [
@@ -767,12 +807,21 @@ var init_session = __esm({
767
807
  get attachedCount() {
768
808
  return this.clients.size;
769
809
  }
770
- // Snapshot of the current in-memory replay history. Used by the
771
- // HTTP history endpoint to deliver the "what's accumulated so far"
772
- // prefix before optionally tailing with onBroadcast. Returns a copy
773
- // so callers can't mutate our cache.
774
- getHistorySnapshot() {
775
- return [...this.history];
810
+ // Wall-clock when the in-flight agent turn began, or undefined when
811
+ // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
812
+ // so the daemon can hand a fresh attacher mid-turn the right elapsed
813
+ // time without scanning history.
814
+ get turnStartedAt() {
815
+ return this.promptStartedAt;
816
+ }
817
+ // Read the persisted history from disk. Returns [] if no history
818
+ // file exists (fresh session, never prompted). Used by attach() and
819
+ // the HTTP /history endpoint.
820
+ async getHistorySnapshot() {
821
+ if (!this.historyStore) {
822
+ return [];
823
+ }
824
+ return this.historyStore.load(this.sessionId).catch(() => []);
776
825
  }
777
826
  // Subscribe to recordable broadcast entries — fires once per entry
778
827
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -788,6 +837,10 @@ var init_session = __esm({
788
837
  }
789
838
  };
790
839
  }
840
+ // Register a client and (asynchronously) load the replay slice it
841
+ // should receive. Validation errors throw synchronously so callers
842
+ // can rely on either the registration being in effect or having
843
+ // thrown; the disk-load is the only async work.
791
844
  attach(client, historyPolicy) {
792
845
  if (this.closed) {
793
846
  throw withCode(
@@ -803,14 +856,10 @@ var init_session = __esm({
803
856
  }
804
857
  this.clients.set(client.clientId, client);
805
858
  this.updatedAt = Date.now();
806
- this.cancelIdleTimer();
807
- if (historyPolicy === "none") {
808
- return [];
859
+ if (historyPolicy === "none" || historyPolicy === "pending_only") {
860
+ return Promise.resolve([]);
809
861
  }
810
- if (historyPolicy === "pending_only") {
811
- return [];
812
- }
813
- return [...this.history];
862
+ return this.getHistorySnapshot();
814
863
  }
815
864
  // Dispatch in-flight permission requests to a freshly-attached
816
865
  // client. Called by the daemon's WS handler *after* it finishes
@@ -824,7 +873,6 @@ var init_session = __esm({
824
873
  detach(clientId) {
825
874
  if (this.clients.delete(clientId)) {
826
875
  this.updatedAt = Date.now();
827
- this.maybeStartIdleTimer();
828
876
  }
829
877
  }
830
878
  async prompt(clientId, params) {
@@ -844,13 +892,19 @@ var init_session = __esm({
844
892
  this.broadcastPromptReceived(client, params);
845
893
  this.maybeSeedTitleFromPrompt(params);
846
894
  return this.enqueuePrompt(async () => {
847
- const response = await this.agent.connection.request(
848
- "session/prompt",
849
- {
850
- ...params,
851
- sessionId: this.upstreamSessionId
852
- }
853
- );
895
+ let response;
896
+ try {
897
+ response = await this.agent.connection.request(
898
+ "session/prompt",
899
+ {
900
+ ...params,
901
+ sessionId: this.upstreamSessionId
902
+ }
903
+ );
904
+ } catch (err) {
905
+ this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
906
+ throw err;
907
+ }
854
908
  this.broadcastTurnComplete(client.clientId, response);
855
909
  return response;
856
910
  });
@@ -864,6 +918,7 @@ var init_session = __esm({
864
918
  if (client.clientInfo?.version) {
865
919
  sentBy.version = client.clientInfo.version;
866
920
  }
921
+ this.promptStartedAt = Date.now();
867
922
  this.recordAndBroadcast(
868
923
  "session/update",
869
924
  {
@@ -900,6 +955,7 @@ var init_session = __esm({
900
955
  if (stopReason !== void 0) {
901
956
  update.stopReason = stopReason;
902
957
  }
958
+ this.promptStartedAt = void 0;
903
959
  this.recordAndBroadcast(
904
960
  "session/update",
905
961
  {
@@ -938,6 +994,13 @@ var init_session = __esm({
938
994
  return;
939
995
  }
940
996
  this.cancelIdleTimer();
997
+ if (opts.regenTitle && this.firstPromptSeeded) {
998
+ const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
999
+ await Promise.race([
1000
+ this.runTitleRegen().catch(() => void 0),
1001
+ new Promise((r) => setTimeout(r, timeoutMs).unref?.())
1002
+ ]);
1003
+ }
941
1004
  await this.agent.kill().catch(() => void 0);
942
1005
  this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
943
1006
  }
@@ -986,7 +1049,7 @@ var init_session = __esm({
986
1049
  }
987
1050
  const promptParams = params ?? {};
988
1051
  const text = extractPromptText(promptParams.prompt);
989
- const seed = firstLine(text, 80);
1052
+ const seed = firstLine(text, 200);
990
1053
  if (!seed) {
991
1054
  return;
992
1055
  }
@@ -1078,6 +1141,12 @@ var init_session = __esm({
1078
1141
  mergedAvailableCommands() {
1079
1142
  return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1080
1143
  }
1144
+ // The agent's own advertised commands (not merged with hydra verbs).
1145
+ // Used by SessionManager to persist into meta.json so cold resurrect
1146
+ // can re-deliver via the attach response _meta.
1147
+ agentOnlyAdvertisedCommands() {
1148
+ return [...this.agentAdvertisedCommands];
1149
+ }
1081
1150
  // Pick up an agent-emitted session_info_update and store its title
1082
1151
  // as our canonical record. The notification is also forwarded to
1083
1152
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1209,7 +1278,7 @@ var init_session = __esm({
1209
1278
  const spawnAgent = this.spawnReplacementAgent;
1210
1279
  return this.enqueuePrompt(async () => {
1211
1280
  const oldAgentId = this.agentId;
1212
- const transcript = this.buildSwitchTranscript(oldAgentId);
1281
+ const transcript = await this.buildSwitchTranscript(oldAgentId);
1213
1282
  const fresh = await spawnAgent({
1214
1283
  agentId: newAgentId,
1215
1284
  cwd: this.cwd,
@@ -1241,15 +1310,20 @@ var init_session = __esm({
1241
1310
  return { stopReason: "end_turn" };
1242
1311
  });
1243
1312
  }
1244
- // Walk this.history (rewritten-for-clients notification cache) and
1245
- // produce a labeled transcript suitable for handing to a fresh agent.
1246
- // Includes user prompts, agent replies, and tool-call outcomes; skips
1247
- // hydra-synthesized markers (so multi-hop switches don't accumulate
1248
- // banners) and other update kinds we don't think the next agent
1249
- // benefits from re-seeing (plans, thoughts, mode/model/usage).
1250
- buildSwitchTranscript(prevAgentId) {
1313
+ // Walk the persisted history and produce a labeled transcript suitable
1314
+ // for handing to a fresh agent. Includes user prompts, agent replies,
1315
+ // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
1316
+ // switches don't accumulate banners) and other update kinds we don't
1317
+ // think the next agent benefits from re-seeing (plans, thoughts,
1318
+ // mode/model/usage).
1319
+ //
1320
+ // The header text defaults to the agent-swap framing; callers like
1321
+ // seedFromImport pass a custom header when the new agent is taking
1322
+ // over an imported session rather than swapping mid-conversation.
1323
+ async buildSwitchTranscript(prevAgentId, headerOverride) {
1251
1324
  const lines = [];
1252
- for (const note of this.history) {
1325
+ const history = await this.getHistorySnapshot();
1326
+ for (const note of history) {
1253
1327
  if (note.method !== "session/update") {
1254
1328
  continue;
1255
1329
  }
@@ -1303,29 +1377,53 @@ var init_session = __esm({
1303
1377
  if (current) {
1304
1378
  coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1305
1379
  }
1380
+ const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
1381
+ const followup = headerOverride?.followup ?? `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`;
1306
1382
  return [
1307
- `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1308
- `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`,
1383
+ intro,
1384
+ followup,
1309
1385
  "",
1310
1386
  "--- begin transcript ---",
1311
1387
  ...coalesced,
1312
1388
  "--- end transcript ---"
1313
1389
  ].join("\n");
1314
1390
  }
1391
+ // Replay the persisted history into a freshly-spawned agent so an
1392
+ // imported session has context. Called by SessionManager.doResurrect
1393
+ // on the first wake-up of a session whose meta.json has an empty
1394
+ // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1395
+ // any user prompts arriving mid-seed queue behind it (mirrors the
1396
+ // /hydra switch path so the agent isn't asked to respond to a user
1397
+ // turn before it has absorbed the imported transcript). Best-effort:
1398
+ // if the agent fails to absorb the transcript we still leave the
1399
+ // session usable — the user just continues without context.
1400
+ async seedFromImport() {
1401
+ await this.enqueuePrompt(async () => {
1402
+ const transcript = await this.buildSwitchTranscript(this.agentId, {
1403
+ intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
1404
+ followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
1405
+ });
1406
+ if (!transcript) {
1407
+ return void 0;
1408
+ }
1409
+ await this.runInternalPrompt(transcript).catch(() => void 0);
1410
+ return void 0;
1411
+ });
1412
+ }
1315
1413
  // Tell every attached client (a) the agent identity has changed
1316
- // (session_info_update with an agentId field clients that already
1317
- // listen for title updates pick this up; older clients ignore unknown
1318
- // fields harmlessly) and (b) drop a visible banner into the transcript
1319
- // so users see the switch rather than just suddenly getting answers
1320
- // from a different agent. Both updates carry _meta["hydra-acp"].synthetic
1414
+ // (session_info_update carrying agentId inside _meta["hydra-acp"]
1415
+ // the ACP schema for session_info_update is just title/updatedAt/_meta,
1416
+ // so non-hydra clients harmlessly ignore the extension; hydra-aware
1417
+ // ones read it and relabel) and (b) drop a visible banner into the
1418
+ // transcript so users see the switch rather than just suddenly getting
1419
+ // answers from a different agent. Both updates carry synthetic=true
1321
1420
  // so a future /hydra switch's transcript builder filters them out.
1322
1421
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1323
1422
  this.recordAndBroadcast("session/update", {
1324
1423
  sessionId: this.sessionId,
1325
1424
  update: {
1326
1425
  sessionUpdate: "session_info_update",
1327
- agentId: newAgentId,
1328
- _meta: { "hydra-acp": { synthetic: true } }
1426
+ _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
1329
1427
  }
1330
1428
  });
1331
1429
  this.recordAndBroadcast("session/update", {
@@ -1356,21 +1454,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1356
1454
  handler(opts);
1357
1455
  }
1358
1456
  }
1359
- maybeStartIdleTimer() {
1360
- if (this.closed || this.clients.size > 0 || this.idleTimeoutMs <= 0) {
1457
+ // Last meaningful activity timestamp. Bumped only by recordable
1458
+ // broadcasts in recordAndBroadcast the same signal historyMtimeIso
1459
+ // uses for the picker. Initialized at construction (and seeded from
1460
+ // the newest entry on resurrect) so the inactivity window starts
1461
+ // ticking from a sensible floor when there's no history yet.
1462
+ get lastActivityAt() {
1463
+ return this.lastRecordedAt;
1464
+ }
1465
+ // (Re-)arm the idle timer to fire when the inactivity window
1466
+ // elapses past lastActivityAt. Called once at construction and after
1467
+ // every recorded broadcast. The previous design gated on
1468
+ // clients.size === 0; we drop that gate because extensions
1469
+ // (slack/notifier/approver/browser) hold persistent attaches that
1470
+ // would otherwise keep a quiet session alive forever.
1471
+ scheduleIdleCheck() {
1472
+ if (this.closed || this.idleTimeoutMs <= 0) {
1361
1473
  return;
1362
1474
  }
1475
+ const dueAt = this.lastActivityAt + this.idleTimeoutMs;
1476
+ this.armIdleTimer(Math.max(0, dueAt - Date.now()));
1477
+ }
1478
+ armIdleTimer(delay) {
1363
1479
  if (this.idleTimer) {
1364
- return;
1480
+ clearTimeout(this.idleTimer);
1365
1481
  }
1366
1482
  this.idleTimer = setTimeout(() => {
1367
1483
  this.idleTimer = void 0;
1368
- void this.close({ deleteRecord: false }).catch(() => void 0);
1369
- }, this.idleTimeoutMs);
1484
+ this.checkIdle();
1485
+ }, delay);
1370
1486
  if (typeof this.idleTimer.unref === "function") {
1371
1487
  this.idleTimer.unref();
1372
1488
  }
1373
1489
  }
1490
+ checkIdle() {
1491
+ if (this.closed || this.idleTimeoutMs <= 0) {
1492
+ return;
1493
+ }
1494
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
1495
+ this.armIdleTimer(this.idleTimeoutMs);
1496
+ return;
1497
+ }
1498
+ const idle = Date.now() - this.lastActivityAt;
1499
+ if (idle < this.idleTimeoutMs) {
1500
+ this.armIdleTimer(this.idleTimeoutMs - idle);
1501
+ return;
1502
+ }
1503
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1504
+ void this.close(opts).catch(() => void 0);
1505
+ }
1374
1506
  cancelIdleTimer() {
1375
1507
  if (this.idleTimer) {
1376
1508
  clearTimeout(this.idleTimer);
@@ -1395,17 +1527,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1395
1527
  params: rewritten,
1396
1528
  recordedAt: Date.now()
1397
1529
  };
1398
- this.history.push(entry);
1399
- let trimmed = false;
1400
- if (this.history.length > 1e3) {
1401
- this.history = this.history.slice(-500);
1402
- trimmed = true;
1403
- }
1530
+ this.lastRecordedAt = entry.recordedAt;
1531
+ this.appendCount += 1;
1404
1532
  if (this.historyStore) {
1405
- if (trimmed) {
1406
- void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1407
- } else {
1408
- void this.historyStore.append(this.sessionId, entry).catch(
1533
+ const store = this.historyStore;
1534
+ void store.append(this.sessionId, entry).catch(() => void 0);
1535
+ if (this.appendCount >= COMPACT_EVERY) {
1536
+ this.appendCount = 0;
1537
+ void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1409
1538
  () => void 0
1410
1539
  );
1411
1540
  }
@@ -1416,6 +1545,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1416
1545
  } catch {
1417
1546
  }
1418
1547
  }
1548
+ this.scheduleIdleCheck();
1419
1549
  }
1420
1550
  this.updatedAt = Date.now();
1421
1551
  for (const client of this.clients.values()) {
@@ -1434,7 +1564,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1434
1564
  );
1435
1565
  }
1436
1566
  const clientParams = this.rewriteForClient(params);
1437
- return new Promise((resolve2, reject) => {
1567
+ return new Promise((resolve4, reject) => {
1438
1568
  let settled = false;
1439
1569
  const outbound = [];
1440
1570
  const entry = { addClient: sendTo };
@@ -1469,7 +1599,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1469
1599
  result
1470
1600
  }).catch(() => void 0);
1471
1601
  }
1472
- resolve2(result);
1602
+ resolve4(result);
1473
1603
  });
1474
1604
  }).catch((err) => {
1475
1605
  settle(() => reject(err));
@@ -1481,11 +1611,11 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1481
1611
  });
1482
1612
  }
1483
1613
  async enqueuePrompt(task) {
1484
- return new Promise((resolve2, reject) => {
1614
+ return new Promise((resolve4, reject) => {
1485
1615
  const run = async () => {
1486
1616
  try {
1487
1617
  const result = await task();
1488
- resolve2(result);
1618
+ resolve4(result);
1489
1619
  } catch (err) {
1490
1620
  reject(err);
1491
1621
  }
@@ -1520,6 +1650,64 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1520
1650
  }
1521
1651
  });
1522
1652
 
1653
+ // src/tui/history.ts
1654
+ import { promises as fs6 } from "fs";
1655
+ import * as path3 from "path";
1656
+ async function loadHistory(file) {
1657
+ let text;
1658
+ try {
1659
+ text = await fs6.readFile(file, "utf8");
1660
+ } catch (err) {
1661
+ if (err.code === "ENOENT") {
1662
+ return [];
1663
+ }
1664
+ throw err;
1665
+ }
1666
+ return parseHistory(text);
1667
+ }
1668
+ function parseHistory(text) {
1669
+ const out = [];
1670
+ for (const rawLine of text.split("\n")) {
1671
+ if (rawLine.length === 0) {
1672
+ continue;
1673
+ }
1674
+ try {
1675
+ const decoded = JSON.parse(rawLine);
1676
+ if (typeof decoded === "string") {
1677
+ out.push(decoded);
1678
+ }
1679
+ } catch {
1680
+ }
1681
+ }
1682
+ return out;
1683
+ }
1684
+ function appendEntry(history, entry) {
1685
+ const trimmed = entry.replace(/\n+$/, "");
1686
+ if (trimmed.length === 0) {
1687
+ return history;
1688
+ }
1689
+ if (history.length > 0 && history[history.length - 1] === trimmed) {
1690
+ return history;
1691
+ }
1692
+ const out = history.concat(trimmed);
1693
+ if (out.length > HISTORY_CAP) {
1694
+ return out.slice(out.length - HISTORY_CAP);
1695
+ }
1696
+ return out;
1697
+ }
1698
+ async function saveHistory(file, history) {
1699
+ await fs6.mkdir(path3.dirname(file), { recursive: true });
1700
+ const lines = history.map((entry) => JSON.stringify(entry));
1701
+ await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1702
+ }
1703
+ var HISTORY_CAP;
1704
+ var init_history = __esm({
1705
+ "src/tui/history.ts"() {
1706
+ "use strict";
1707
+ HISTORY_CAP = 500;
1708
+ }
1709
+ });
1710
+
1523
1711
  // src/acp/ws-stream.ts
1524
1712
  function wsToMessageStream(ws) {
1525
1713
  const messageHandlers = [];
@@ -1565,13 +1753,13 @@ function wsToMessageStream(ws) {
1565
1753
  throw new Error("ws is closed");
1566
1754
  }
1567
1755
  const text = JSON.stringify(message);
1568
- await new Promise((resolve2, reject) => {
1756
+ await new Promise((resolve4, reject) => {
1569
1757
  ws.send(text, (err) => {
1570
1758
  if (err) {
1571
1759
  reject(err);
1572
1760
  return;
1573
1761
  }
1574
- resolve2();
1762
+ resolve4();
1575
1763
  });
1576
1764
  });
1577
1765
  },
@@ -1650,7 +1838,150 @@ var init_daemon_bootstrap = __esm({
1650
1838
  }
1651
1839
  });
1652
1840
 
1841
+ // src/cli/session-row.ts
1842
+ function toRow(s, now = Date.now()) {
1843
+ return {
1844
+ session: stripHydraSessionPrefix(s.sessionId),
1845
+ upstream: s.upstreamSessionId ?? "-",
1846
+ status: (s.status ?? "live").toUpperCase(),
1847
+ clients: s.status === "cold" ? "-" : String(s.attachedClients),
1848
+ agent: s.agentId ?? "?",
1849
+ age: formatRelativeAge(s.updatedAt, now),
1850
+ title: s.title ?? "-",
1851
+ cwd: s.cwd
1852
+ };
1853
+ }
1854
+ function computeWidths(rows) {
1855
+ return {
1856
+ session: maxLen(HEADER.session, rows.map((r) => r.session)),
1857
+ upstream: maxLen(HEADER.upstream, rows.map((r) => r.upstream)),
1858
+ status: maxLen(HEADER.status, rows.map((r) => r.status)),
1859
+ clients: maxLen(HEADER.clients, rows.map((r) => r.clients)),
1860
+ agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
1861
+ age: maxLen(HEADER.age, rows.map((r) => r.age)),
1862
+ title: maxLen(HEADER.title, rows.map((r) => r.title))
1863
+ };
1864
+ }
1865
+ function formatRelativeAge(iso, now) {
1866
+ if (!iso) {
1867
+ return "?";
1868
+ }
1869
+ const t = Date.parse(iso);
1870
+ if (Number.isNaN(t)) {
1871
+ return "?";
1872
+ }
1873
+ const diff = Math.max(0, now - t);
1874
+ const sec = Math.floor(diff / 1e3);
1875
+ if (sec < 60) {
1876
+ return "<1m";
1877
+ }
1878
+ const min = Math.floor(sec / 60);
1879
+ if (min < 60) {
1880
+ return `${min}m`;
1881
+ }
1882
+ const hr = Math.floor(min / 60);
1883
+ if (hr < 24) {
1884
+ return `${hr}h`;
1885
+ }
1886
+ const day = Math.floor(hr / 24);
1887
+ if (day < 14) {
1888
+ return `${day}d`;
1889
+ }
1890
+ const week = Math.floor(day / 7);
1891
+ if (week < 9) {
1892
+ return `${week}w`;
1893
+ }
1894
+ const month = Math.floor(day / 30);
1895
+ if (month < 12) {
1896
+ return `${month}mo`;
1897
+ }
1898
+ const year = Math.floor(day / 365);
1899
+ return `${year}y`;
1900
+ }
1901
+ function maxLen(headerCell, values) {
1902
+ let max = headerCell.length;
1903
+ for (const v of values) {
1904
+ if (v.length > max) {
1905
+ max = v.length;
1906
+ }
1907
+ }
1908
+ return max;
1909
+ }
1910
+ function formatRow(r, w, maxWidth) {
1911
+ const fixed = [
1912
+ r.session.padEnd(w.session),
1913
+ r.upstream.padEnd(w.upstream),
1914
+ r.status.padEnd(w.status),
1915
+ r.clients.padStart(w.clients),
1916
+ r.agent.padEnd(w.agent),
1917
+ r.age.padStart(w.age)
1918
+ ].join(SEP);
1919
+ if (maxWidth === void 0) {
1920
+ return [fixed, r.title.padEnd(w.title), r.cwd].join(SEP);
1921
+ }
1922
+ const titleCap = Math.min(w.title, TITLE_MAX_WIDTH);
1923
+ const budget = maxWidth - fixed.length - SEP.length;
1924
+ if (budget <= 0) {
1925
+ return fixed.slice(0, maxWidth);
1926
+ }
1927
+ const titleNatural = Math.min(r.title.length, titleCap);
1928
+ let titleAlloc = titleNatural + SEP.length + MIN_CWD <= budget ? titleCap : Math.max(0, budget - SEP.length - MIN_CWD);
1929
+ titleAlloc = Math.min(titleAlloc, Math.max(0, budget - SEP.length - 1));
1930
+ const titleCell = truncateRight(r.title, titleAlloc).padEnd(titleAlloc);
1931
+ const cwdBudget = Math.max(0, budget - titleAlloc - SEP.length);
1932
+ const cwdCell = truncateMiddle(r.cwd, cwdBudget);
1933
+ return [fixed, titleCell, cwdCell].join(SEP);
1934
+ }
1935
+ function truncateRight(s, max) {
1936
+ if (max <= 0) {
1937
+ return "";
1938
+ }
1939
+ if (s.length <= max) {
1940
+ return s;
1941
+ }
1942
+ if (max === 1) {
1943
+ return "\u2026";
1944
+ }
1945
+ return s.slice(0, max - 1) + "\u2026";
1946
+ }
1947
+ function truncateMiddle(s, max) {
1948
+ if (max <= 0) {
1949
+ return "";
1950
+ }
1951
+ if (s.length <= max) {
1952
+ return s;
1953
+ }
1954
+ if (max === 1) {
1955
+ return "\u2026";
1956
+ }
1957
+ const head = Math.ceil((max - 1) / 2);
1958
+ const tail = max - 1 - head;
1959
+ return s.slice(0, head) + "\u2026" + s.slice(s.length - tail);
1960
+ }
1961
+ var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
1962
+ var init_session_row = __esm({
1963
+ "src/cli/session-row.ts"() {
1964
+ "use strict";
1965
+ init_session();
1966
+ HEADER = {
1967
+ session: "SESSION",
1968
+ upstream: "UPSTREAM",
1969
+ status: "STATUS",
1970
+ clients: "CLIENTS",
1971
+ agent: "AGENT",
1972
+ age: "AGE",
1973
+ title: "TITLE",
1974
+ cwd: "CWD"
1975
+ };
1976
+ SEP = " ";
1977
+ MIN_CWD = 8;
1978
+ TITLE_MAX_WIDTH = 40;
1979
+ }
1980
+ });
1981
+
1653
1982
  // src/cli/commands/sessions.ts
1983
+ import * as fs11 from "fs/promises";
1984
+ import * as path5 from "path";
1654
1985
  async function runSessionsList(opts = {}) {
1655
1986
  const config = await loadConfig();
1656
1987
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -1685,44 +2016,13 @@ async function runSessionsList(opts = {}) {
1685
2016
  visible = [...sorted.slice(0, liveCount), ...coldSlice];
1686
2017
  truncated = hiddenCold;
1687
2018
  }
1688
- const rows = visible.map((s) => ({
1689
- session: stripHydraSessionPrefix(s.sessionId),
1690
- upstream: s.upstreamSessionId ?? "-",
1691
- status: (s.status ?? "live").toUpperCase(),
1692
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
1693
- agent: s.agentId ?? "?",
1694
- title: s.title ?? "-",
1695
- cwd: s.cwd
1696
- }));
1697
- const header = {
1698
- session: "SESSION",
1699
- upstream: "UPSTREAM",
1700
- status: "STATUS",
1701
- clients: "CLIENTS",
1702
- agent: "AGENT",
1703
- title: "TITLE",
1704
- cwd: "CWD"
1705
- };
1706
- const widths = {
1707
- session: maxLen(header.session, rows.map((r) => r.session)),
1708
- upstream: maxLen(header.upstream, rows.map((r) => r.upstream)),
1709
- status: maxLen(header.status, rows.map((r) => r.status)),
1710
- clients: maxLen(header.clients, rows.map((r) => r.clients)),
1711
- agent: maxLen(header.agent, rows.map((r) => r.agent)),
1712
- title: maxLen(header.title, rows.map((r) => r.title))
1713
- };
1714
- const formatRow2 = (r) => [
1715
- r.session.padEnd(widths.session),
1716
- r.upstream.padEnd(widths.upstream),
1717
- r.status.padEnd(widths.status),
1718
- r.clients.padStart(widths.clients),
1719
- r.agent.padEnd(widths.agent),
1720
- r.title.padEnd(widths.title),
1721
- r.cwd
1722
- ].join(" ");
1723
- process.stdout.write(formatRow2(header) + "\n");
2019
+ const now = Date.now();
2020
+ const rows = visible.map((s) => toRow(s, now));
2021
+ const widths = computeWidths(rows);
2022
+ const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
2023
+ process.stdout.write(formatRow(HEADER, widths, maxWidth) + "\n");
1724
2024
  for (const r of rows) {
1725
- process.stdout.write(formatRow2(r) + "\n");
2025
+ process.stdout.write(formatRow(r, widths, maxWidth) + "\n");
1726
2026
  }
1727
2027
  if (truncated > 0) {
1728
2028
  process.stdout.write(
@@ -1732,15 +2032,6 @@ async function runSessionsList(opts = {}) {
1732
2032
  );
1733
2033
  }
1734
2034
  }
1735
- function maxLen(headerCell, values) {
1736
- let max = headerCell.length;
1737
- for (const v of values) {
1738
- if (v.length > max) {
1739
- max = v.length;
1740
- }
1741
- }
1742
- return max;
1743
- }
1744
2035
  async function runSessionsKill(id) {
1745
2036
  if (!id) {
1746
2037
  process.stderr.write("Usage: hydra-acp sessions kill <session-id>\n");
@@ -1748,6 +2039,25 @@ async function runSessionsKill(id) {
1748
2039
  }
1749
2040
  const config = await loadConfig();
1750
2041
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2042
+ const response = await fetch(`${baseUrl}/v1/sessions/${id}/kill`, {
2043
+ method: "POST",
2044
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2045
+ });
2046
+ if (!response.ok && response.status !== 204) {
2047
+ process.stderr.write(`Daemon returned HTTP ${response.status}
2048
+ `);
2049
+ process.exit(1);
2050
+ }
2051
+ process.stdout.write(`Killed ${id}
2052
+ `);
2053
+ }
2054
+ async function runSessionsRm(id) {
2055
+ if (!id) {
2056
+ process.stderr.write("Usage: hydra-acp sessions rm <session-id>\n");
2057
+ process.exit(2);
2058
+ }
2059
+ const config = await loadConfig();
2060
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
1751
2061
  const response = await fetch(`${baseUrl}/v1/sessions/${id}`, {
1752
2062
  method: "DELETE",
1753
2063
  headers: { Authorization: `Bearer ${config.daemon.authToken}` }
@@ -1757,9 +2067,114 @@ async function runSessionsKill(id) {
1757
2067
  `);
1758
2068
  process.exit(1);
1759
2069
  }
1760
- process.stdout.write(`Killed ${id}
2070
+ process.stdout.write(`Removed ${id}
1761
2071
  `);
1762
2072
  }
2073
+ async function runSessionsExport(id, outPath) {
2074
+ if (!id) {
2075
+ process.stderr.write(
2076
+ "Usage: hydra-acp sessions export <session-id> [--out <file>]\n"
2077
+ );
2078
+ process.exit(2);
2079
+ }
2080
+ const config = await loadConfig();
2081
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2082
+ const response = await fetch(
2083
+ `${baseUrl}/v1/sessions/${encodeURIComponent(id)}/export`,
2084
+ {
2085
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2086
+ }
2087
+ );
2088
+ if (!response.ok) {
2089
+ const text = await response.text().catch(() => "");
2090
+ process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
2091
+ `);
2092
+ process.exit(1);
2093
+ }
2094
+ const body = await response.text();
2095
+ if (!outPath) {
2096
+ process.stdout.write(body);
2097
+ if (!body.endsWith("\n")) {
2098
+ process.stdout.write("\n");
2099
+ }
2100
+ return;
2101
+ }
2102
+ const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
2103
+ await fs11.mkdir(path5.dirname(path5.resolve(resolved)), { recursive: true });
2104
+ await fs11.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2105
+ process.stdout.write(`Wrote ${resolved}
2106
+ `);
2107
+ }
2108
+ async function runSessionsImport(file, opts = {}) {
2109
+ if (!file) {
2110
+ process.stderr.write(
2111
+ "Usage: hydra-acp sessions import <file>|- [--replace]\n"
2112
+ );
2113
+ process.exit(2);
2114
+ }
2115
+ let body;
2116
+ if (file === "-") {
2117
+ body = await readStdin();
2118
+ } else {
2119
+ body = await fs11.readFile(file, "utf8");
2120
+ }
2121
+ let bundle;
2122
+ try {
2123
+ bundle = JSON.parse(body);
2124
+ } catch (err) {
2125
+ process.stderr.write(`Failed to parse bundle: ${err.message}
2126
+ `);
2127
+ process.exit(1);
2128
+ }
2129
+ const config = await loadConfig();
2130
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2131
+ const response = await fetch(`${baseUrl}/v1/sessions/import`, {
2132
+ method: "POST",
2133
+ headers: {
2134
+ "Content-Type": "application/json",
2135
+ Authorization: `Bearer ${config.daemon.authToken}`
2136
+ },
2137
+ body: JSON.stringify({ bundle, replace: opts.replace === true })
2138
+ });
2139
+ if (response.status === 409) {
2140
+ const detail = await response.json().catch(() => ({}));
2141
+ process.stderr.write(
2142
+ `Bundle already imported as ${detail.existingSessionId ?? "unknown"}. Use --replace to overwrite.
2143
+ `
2144
+ );
2145
+ process.exit(1);
2146
+ }
2147
+ if (!response.ok) {
2148
+ const text = await response.text().catch(() => "");
2149
+ process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
2150
+ `);
2151
+ process.exit(1);
2152
+ }
2153
+ const result = await response.json();
2154
+ process.stdout.write(
2155
+ result.replaced ? `Replaced ${result.sessionId} (from ${result.importedFromSessionId})
2156
+ ` : `Imported as ${result.sessionId} (from ${result.importedFromSessionId})
2157
+ `
2158
+ );
2159
+ }
2160
+ async function readStdin() {
2161
+ const chunks = [];
2162
+ for await (const chunk of process.stdin) {
2163
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
2164
+ }
2165
+ return Buffer.concat(chunks).toString("utf8");
2166
+ }
2167
+ function deriveFilenameFrom(response, id) {
2168
+ const cd = response.headers.get("content-disposition");
2169
+ if (cd) {
2170
+ const match = cd.match(/filename="([^"]+)"/);
2171
+ if (match) {
2172
+ return match[1];
2173
+ }
2174
+ }
2175
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2176
+ return `hydra-${id}-${stamp}.hydra`;
2177
+ }
1763
2178
  function httpBase(host, port, tls) {
1764
2179
  const protocol = tls ? "https" : "http";
1765
2180
  return `${protocol}://${host}:${port}`;
@@ -1768,7 +2183,7 @@ var init_sessions = __esm({
1768
2183
  "src/cli/commands/sessions.ts"() {
1769
2184
  "use strict";
1770
2185
  init_config();
1771
- init_session();
2186
+ init_session_row();
1772
2187
  }
1773
2188
  });
1774
2189
 
@@ -1779,11 +2194,11 @@ function isResponse(msg) {
1779
2194
  return !("method" in msg) && "id" in msg && msg.id !== void 0;
1780
2195
  }
1781
2196
  async function openWs(url, subprotocols) {
1782
- return new Promise((resolve2, reject) => {
2197
+ return new Promise((resolve4, reject) => {
1783
2198
  const ws = new WebSocket(url, subprotocols);
1784
2199
  const onOpen = () => {
1785
2200
  ws.off("error", onError);
1786
- resolve2(wsToMessageStream(ws));
2201
+ resolve4(wsToMessageStream(ws));
1787
2202
  };
1788
2203
  const onError = (err) => {
1789
2204
  ws.off("open", onOpen);
@@ -1854,8 +2269,8 @@ var init_resilient_ws = __esm({
1854
2269
  throw new Error("resilient ws stream not connected");
1855
2270
  }
1856
2271
  const id = message.id;
1857
- const promise = new Promise((resolve2, reject) => {
1858
- this.pendingRequests.set(id, { resolve: resolve2, reject });
2272
+ const promise = new Promise((resolve4, reject) => {
2273
+ this.pendingRequests.set(id, { resolve: resolve4, reject });
1859
2274
  });
1860
2275
  try {
1861
2276
  await this.current.send(message);
@@ -1883,8 +2298,8 @@ var init_resilient_ws = __esm({
1883
2298
  this.bindStream(stream);
1884
2299
  const wasFirst = this.firstConnect;
1885
2300
  this.firstConnect = false;
1886
- this.connectGate = new Promise((resolve2) => {
1887
- this.releaseConnectGate = resolve2;
2301
+ this.connectGate = new Promise((resolve4) => {
2302
+ this.releaseConnectGate = resolve4;
1888
2303
  });
1889
2304
  try {
1890
2305
  if (this.opts.onConnect) {
@@ -2007,64 +2422,6 @@ var init_resilient_ws = __esm({
2007
2422
  }
2008
2423
  });
2009
2424
 
2010
- // src/tui/history.ts
2011
- import { promises as fs9 } from "fs";
2012
- import * as path4 from "path";
2013
- async function loadHistory(file) {
2014
- let text;
2015
- try {
2016
- text = await fs9.readFile(file, "utf8");
2017
- } catch (err) {
2018
- if (err.code === "ENOENT") {
2019
- return [];
2020
- }
2021
- throw err;
2022
- }
2023
- return parseHistory(text);
2024
- }
2025
- function parseHistory(text) {
2026
- const out = [];
2027
- for (const rawLine of text.split("\n")) {
2028
- if (rawLine.length === 0) {
2029
- continue;
2030
- }
2031
- try {
2032
- const decoded = JSON.parse(rawLine);
2033
- if (typeof decoded === "string") {
2034
- out.push(decoded);
2035
- }
2036
- } catch {
2037
- }
2038
- }
2039
- return out;
2040
- }
2041
- function appendEntry(history, entry) {
2042
- const trimmed = entry.replace(/\n+$/, "");
2043
- if (trimmed.length === 0) {
2044
- return history;
2045
- }
2046
- if (history.length > 0 && history[history.length - 1] === trimmed) {
2047
- return history;
2048
- }
2049
- const out = history.concat(trimmed);
2050
- if (out.length > HISTORY_CAP) {
2051
- return out.slice(out.length - HISTORY_CAP);
2052
- }
2053
- return out;
2054
- }
2055
- async function saveHistory(file, history) {
2056
- await fs9.mkdir(path4.dirname(file), { recursive: true });
2057
- const lines = history.map((entry) => JSON.stringify(entry));
2058
- await fs9.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2059
- }
2060
- var HISTORY_CAP;
2061
- var init_history = __esm({
2062
- "src/tui/history.ts"() {
2063
- "use strict";
2064
- HISTORY_CAP = 500;
2065
- }
2066
- });
2067
-
2068
2425
  // src/tui/discovery.ts
2069
2426
  async function listSessions(config, opts = {}, fetchImpl = fetch) {
2070
2427
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -2096,6 +2453,26 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
2096
2453
  title: s.title
2097
2454
  }));
2098
2455
  }
2456
+ async function killSession(config, id, fetchImpl = fetch) {
2457
+ const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2458
+ const response = await fetchImpl(`${base}/v1/sessions/${id}/kill`, {
2459
+ method: "POST",
2460
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2461
+ });
2462
+ if (!response.ok && response.status !== 204 && response.status !== 404) {
2463
+ throw new Error(`daemon returned HTTP ${response.status}`);
2464
+ }
2465
+ }
2466
+ async function deleteSession(config, id, fetchImpl = fetch) {
2467
+ const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2468
+ const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
2469
+ method: "DELETE",
2470
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2471
+ });
2472
+ if (!response.ok && response.status !== 204 && response.status !== 404) {
2473
+ throw new Error(`daemon returned HTTP ${response.status}`);
2474
+ }
2475
+ }
2099
2476
  function pickMostRecent(sessions, cwd) {
2100
2477
  const matching = sessions.filter((s) => s.cwd === cwd);
2101
2478
  if (matching.length === 0) {
@@ -2123,147 +2500,406 @@ async function pickSession(term, opts) {
2123
2500
  if (opts.sessions.length === 0) {
2124
2501
  return { kind: "new" };
2125
2502
  }
2126
- const score = (s) => {
2127
- if (s.status !== "live") {
2128
- return 0;
2503
+ const sortSessions = (sessions) => {
2504
+ const score = (s) => {
2505
+ if (s.status !== "live") {
2506
+ return 0;
2507
+ }
2508
+ return s.cwd === opts.cwd ? 2 : 1;
2509
+ };
2510
+ return [...sessions].sort((a, b) => {
2511
+ const tier = score(b) - score(a);
2512
+ if (tier !== 0) {
2513
+ return tier;
2514
+ }
2515
+ return b.updatedAt.localeCompare(a.updatedAt);
2516
+ });
2517
+ };
2518
+ let visible = sortSessions(opts.sessions);
2519
+ let rows = visible.map((s) => toRow(s, Date.now()));
2520
+ let widths = computeWidths(rows);
2521
+ let total = 1 + visible.length;
2522
+ let selectedIdx = 0;
2523
+ let scrollOffset = 0;
2524
+ let mode = "normal";
2525
+ let pendingAction = null;
2526
+ let transientStatus = null;
2527
+ let termHeight = readTermHeight(term);
2528
+ let termWidth = readTermWidth(term);
2529
+ let viewportSize = 0;
2530
+ let newSessionLabel = "";
2531
+ let headerLine = "";
2532
+ let sessionLines = [];
2533
+ let startRow = 1;
2534
+ const computeLayout = () => {
2535
+ termHeight = readTermHeight(term);
2536
+ termWidth = readTermWidth(term);
2537
+ const maxViewportRows = Math.max(3, termHeight - 6);
2538
+ viewportSize = Math.min(visible.length, maxViewportRows);
2539
+ const rowMaxWidth = Math.max(10, termWidth - ROW_PREFIX_WIDTH);
2540
+ newSessionLabel = formatNewSessionLabel(opts.cwd, rowMaxWidth);
2541
+ headerLine = formatRow(HEADER, widths, rowMaxWidth);
2542
+ sessionLines = rows.map((r) => formatRow(r, widths, rowMaxWidth));
2543
+ };
2544
+ const rebuildRows = () => {
2545
+ rows = visible.map((s) => toRow(s, Date.now()));
2546
+ widths = computeWidths(rows);
2547
+ total = 1 + visible.length;
2548
+ computeLayout();
2549
+ };
2550
+ const adjustScroll = () => {
2551
+ if (selectedIdx === 0) {
2552
+ return;
2553
+ }
2554
+ const sessionIdx = selectedIdx - 1;
2555
+ if (sessionIdx < scrollOffset) {
2556
+ scrollOffset = sessionIdx;
2557
+ } else if (sessionIdx >= scrollOffset + viewportSize) {
2558
+ scrollOffset = sessionIdx - viewportSize + 1;
2559
+ } else if (scrollOffset + viewportSize > visible.length) {
2560
+ scrollOffset = Math.max(0, visible.length - viewportSize);
2129
2561
  }
2130
- return s.cwd === opts.cwd ? 2 : 1;
2131
2562
  };
2132
- const sorted = [...opts.sessions].sort((a, b) => {
2133
- const tier = score(b) - score(a);
2134
- if (tier !== 0) {
2135
- return tier;
2563
+ const paintNewItem = () => {
2564
+ if (selectedIdx === 0) {
2565
+ term.brightWhite.bgBlue.noFormat(`\u276F ${newSessionLabel}`);
2566
+ } else {
2567
+ term.noFormat(` ${newSessionLabel}`);
2136
2568
  }
2137
- return b.updatedAt.localeCompare(a.updatedAt);
2569
+ };
2570
+ const paintSessionRow = (sessionIdx) => {
2571
+ const label = sessionLines[sessionIdx] ?? "";
2572
+ if (selectedIdx === sessionIdx + 1) {
2573
+ term.brightWhite.bgBlue.noFormat(`\u276F ${label}`);
2574
+ } else {
2575
+ term.noFormat(` ${label}`);
2576
+ }
2577
+ };
2578
+ const formatIndicator = () => {
2579
+ const above = scrollOffset;
2580
+ const below = Math.max(0, visible.length - scrollOffset - viewportSize);
2581
+ if (above === 0 && below === 0) {
2582
+ return "";
2583
+ }
2584
+ const parts = [];
2585
+ if (above > 0) {
2586
+ parts.push(`\u2191 ${above} above`);
2587
+ }
2588
+ if (below > 0) {
2589
+ parts.push(`\u2193 ${below} below`);
2590
+ }
2591
+ return ` ${parts.join(" \xB7 ")}`;
2592
+ };
2593
+ const shortId2 = (sessionId) => stripHydraSessionPrefix(sessionId);
2594
+ const paintIndicator = () => {
2595
+ term.moveTo(1, indicatorRow()).eraseLineAfter();
2596
+ if (mode === "confirm-kill" && pendingAction) {
2597
+ term.brightYellow.noFormat(` kill ${shortId2(pendingAction.sessionId)}? [y/N]`);
2598
+ return;
2599
+ }
2600
+ if (mode === "confirm-delete" && pendingAction) {
2601
+ term.brightRed.noFormat(` delete ${shortId2(pendingAction.sessionId)}? [y/N]`);
2602
+ return;
2603
+ }
2604
+ if (mode === "busy" && pendingAction) {
2605
+ term.dim.noFormat(` working on ${shortId2(pendingAction.sessionId)}\u2026`);
2606
+ return;
2607
+ }
2608
+ if (transientStatus !== null) {
2609
+ term.dim.noFormat(` ${transientStatus}`);
2610
+ return;
2611
+ }
2612
+ term.dim.noFormat(formatIndicator());
2613
+ };
2614
+ const indicatorRow = () => startRow + 3 + viewportSize;
2615
+ const sessionRow = (sessionIdx) => startRow + 3 + (sessionIdx - scrollOffset);
2616
+ const renderFromScratch = () => {
2617
+ computeLayout();
2618
+ adjustScroll();
2619
+ startRow = 1;
2620
+ term.moveTo(1, 1).eraseDisplayBelow();
2621
+ paintNewItem();
2622
+ term("\n\n");
2623
+ term.dim.noFormat(` ${headerLine}`)("\n");
2624
+ for (let v = 0; v < viewportSize; v++) {
2625
+ paintSessionRow(scrollOffset + v);
2626
+ term("\n");
2627
+ }
2628
+ paintIndicator();
2629
+ term("\n");
2630
+ };
2631
+ const repaintNewItem = () => {
2632
+ term.moveTo(1, startRow).eraseLineAfter();
2633
+ paintNewItem();
2634
+ };
2635
+ const repaintSessionRow = (sessionIdx) => {
2636
+ if (sessionIdx < scrollOffset || sessionIdx >= scrollOffset + viewportSize) {
2637
+ return;
2638
+ }
2639
+ term.moveTo(1, sessionRow(sessionIdx)).eraseLineAfter();
2640
+ paintSessionRow(sessionIdx);
2641
+ };
2642
+ const repaintViewport = () => {
2643
+ for (let v = 0; v < viewportSize; v++) {
2644
+ const row = startRow + 3 + v;
2645
+ term.moveTo(1, row).eraseLineAfter();
2646
+ const sessionIdx = scrollOffset + v;
2647
+ if (sessionIdx < visible.length) {
2648
+ paintSessionRow(sessionIdx);
2649
+ }
2650
+ }
2651
+ paintIndicator();
2652
+ };
2653
+ renderFromScratch();
2654
+ term.hideCursor();
2655
+ return await new Promise((resolve4) => {
2656
+ let resolved = false;
2657
+ const onResize = () => {
2658
+ if (resolved) {
2659
+ return;
2660
+ }
2661
+ renderFromScratch();
2662
+ };
2663
+ const cleanup = () => {
2664
+ if (resolved) {
2665
+ return;
2666
+ }
2667
+ resolved = true;
2668
+ term.off("key", onKey);
2669
+ term.off("resize", onResize);
2670
+ term.grabInput(false);
2671
+ term.hideCursor(false);
2672
+ term.moveTo(1, indicatorRow() + 1);
2673
+ term("\n");
2674
+ };
2675
+ const refresh = async (preferredId) => {
2676
+ try {
2677
+ const next = await listSessions(opts.config);
2678
+ visible = sortSessions(next);
2679
+ rebuildRows();
2680
+ if (preferredId !== void 0) {
2681
+ const idx = visible.findIndex((s) => s.sessionId === preferredId);
2682
+ if (idx >= 0) {
2683
+ selectedIdx = idx + 1;
2684
+ }
2685
+ }
2686
+ if (selectedIdx > total - 1) {
2687
+ selectedIdx = Math.max(0, total - 1);
2688
+ }
2689
+ if (scrollOffset + viewportSize > visible.length) {
2690
+ scrollOffset = Math.max(0, visible.length - viewportSize);
2691
+ }
2692
+ adjustScroll();
2693
+ renderFromScratch();
2694
+ } catch (err) {
2695
+ transientStatus = `refresh failed: ${err.message}`;
2696
+ renderFromScratch();
2697
+ }
2698
+ };
2699
+ const performAction = async (kind) => {
2700
+ if (!pendingAction) {
2701
+ return;
2702
+ }
2703
+ const target = pendingAction;
2704
+ mode = "busy";
2705
+ paintIndicator();
2706
+ try {
2707
+ if (kind === "kill") {
2708
+ await killSession(opts.config, target.sessionId);
2709
+ } else {
2710
+ await deleteSession(opts.config, target.sessionId);
2711
+ }
2712
+ mode = "normal";
2713
+ pendingAction = null;
2714
+ await refresh(kind === "kill" ? target.sessionId : void 0);
2715
+ } catch (err) {
2716
+ mode = "normal";
2717
+ pendingAction = null;
2718
+ transientStatus = `${kind} failed: ${err.message}`;
2719
+ paintIndicator();
2720
+ }
2721
+ };
2722
+ const move = (delta) => {
2723
+ const next = Math.min(total - 1, Math.max(0, selectedIdx + delta));
2724
+ if (next === selectedIdx) {
2725
+ return;
2726
+ }
2727
+ const old = selectedIdx;
2728
+ const oldScroll = scrollOffset;
2729
+ selectedIdx = next;
2730
+ adjustScroll();
2731
+ if (scrollOffset !== oldScroll) {
2732
+ repaintViewport();
2733
+ if (old === 0 || selectedIdx === 0) {
2734
+ repaintNewItem();
2735
+ }
2736
+ return;
2737
+ }
2738
+ if (old === 0) {
2739
+ repaintNewItem();
2740
+ } else {
2741
+ repaintSessionRow(old - 1);
2742
+ }
2743
+ if (selectedIdx === 0) {
2744
+ repaintNewItem();
2745
+ } else {
2746
+ repaintSessionRow(selectedIdx - 1);
2747
+ }
2748
+ };
2749
+ const clearTransient = () => {
2750
+ if (transientStatus === null) {
2751
+ return false;
2752
+ }
2753
+ transientStatus = null;
2754
+ paintIndicator();
2755
+ return true;
2756
+ };
2757
+ const onKey = (name, _matches, data) => {
2758
+ if (mode === "busy") {
2759
+ return;
2760
+ }
2761
+ if (mode === "confirm-kill" || mode === "confirm-delete") {
2762
+ if (data?.isCharacter && (name === "y" || name === "Y")) {
2763
+ const kind = mode === "confirm-kill" ? "kill" : "delete";
2764
+ void performAction(kind);
2765
+ return;
2766
+ }
2767
+ if (name === "ESCAPE" || name === "CTRL_C" || name === "ENTER" || name === "KP_ENTER" || data?.isCharacter && (name === "n" || name === "N")) {
2768
+ mode = "normal";
2769
+ pendingAction = null;
2770
+ paintIndicator();
2771
+ return;
2772
+ }
2773
+ return;
2774
+ }
2775
+ clearTransient();
2776
+ if (data?.isCharacter) {
2777
+ if (name === "r" || name === "R") {
2778
+ const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
2779
+ void refresh(currentId);
2780
+ return;
2781
+ }
2782
+ if ((name === "k" || name === "K") && selectedIdx > 0) {
2783
+ const session = visible[selectedIdx - 1];
2784
+ if (!session) {
2785
+ return;
2786
+ }
2787
+ pendingAction = {
2788
+ sessionId: session.sessionId,
2789
+ cwd: session.cwd,
2790
+ status: session.status
2791
+ };
2792
+ mode = "confirm-kill";
2793
+ paintIndicator();
2794
+ return;
2795
+ }
2796
+ if ((name === "d" || name === "D") && selectedIdx > 0) {
2797
+ const session = visible[selectedIdx - 1];
2798
+ if (!session) {
2799
+ return;
2800
+ }
2801
+ if (session.status === "live") {
2802
+ transientStatus = "session is live \u2014 press k to kill it first";
2803
+ paintIndicator();
2804
+ return;
2805
+ }
2806
+ pendingAction = {
2807
+ sessionId: session.sessionId,
2808
+ cwd: session.cwd,
2809
+ status: session.status
2810
+ };
2811
+ mode = "confirm-delete";
2812
+ paintIndicator();
2813
+ return;
2814
+ }
2815
+ return;
2816
+ }
2817
+ switch (name) {
2818
+ case "UP":
2819
+ case "SHIFT_TAB":
2820
+ move(-1);
2821
+ return;
2822
+ case "DOWN":
2823
+ case "TAB":
2824
+ move(1);
2825
+ return;
2826
+ case "PAGE_UP":
2827
+ move(-viewportSize);
2828
+ return;
2829
+ case "PAGE_DOWN":
2830
+ move(viewportSize);
2831
+ return;
2832
+ case "HOME":
2833
+ move(-total);
2834
+ return;
2835
+ case "END":
2836
+ move(total);
2837
+ return;
2838
+ case "ENTER":
2839
+ case "KP_ENTER": {
2840
+ cleanup();
2841
+ if (selectedIdx === 0) {
2842
+ resolve4({ kind: "new" });
2843
+ return;
2844
+ }
2845
+ const session = visible[selectedIdx - 1];
2846
+ if (!session) {
2847
+ resolve4({ kind: "abort" });
2848
+ return;
2849
+ }
2850
+ const result = {
2851
+ kind: "attach",
2852
+ sessionId: session.sessionId
2853
+ };
2854
+ if (session.agentId !== void 0) {
2855
+ result.agentId = session.agentId;
2856
+ }
2857
+ resolve4(result);
2858
+ return;
2859
+ }
2860
+ case "ESCAPE":
2861
+ case "CTRL_C":
2862
+ cleanup();
2863
+ resolve4({ kind: "abort" });
2864
+ return;
2865
+ }
2866
+ };
2867
+ term.grabInput({});
2868
+ term.on("key", onKey);
2869
+ term.on("resize", onResize);
2138
2870
  });
2139
- const liveCount = sorted.filter((s) => s.status !== "cold").length;
2140
- const coldSlice = sorted.slice(liveCount, liveCount + opts.coldLimit);
2141
- const hiddenCold = sorted.length - liveCount - coldSlice.length;
2142
- const visible = [...sorted.slice(0, liveCount), ...coldSlice];
2143
- const rows = visible.map(toRow);
2144
- const widths = computeWidths(rows);
2145
- const newSessionLabel = `+ New session in ${opts.cwd}`;
2146
- const items = [newSessionLabel, ...rows.map((r) => formatRow(r, widths))];
2147
- term("\n");
2148
- term.bold("Select a session")("\n");
2149
- if (hiddenCold > 0) {
2150
- term.dim(`(${hiddenCold} older cold session${hiddenCold === 1 ? "" : "s"} hidden; use \`hydra-acp sessions --all\` to view)
2151
- `);
2152
- }
2153
- term.dim(formatRow(HEADER, widths))("\n");
2154
- const onCtrlC = (name) => {
2155
- if (name === "CTRL_C") {
2156
- term.grabInput(false);
2157
- term("\n");
2158
- process.exit(130);
2159
- }
2160
- };
2161
- term.on("key", onCtrlC);
2162
- let response;
2163
- try {
2164
- response = await term.singleColumnMenu(items, {
2165
- cancelable: true,
2166
- exitOnUnexpectedKey: false,
2167
- selectedIndex: 0,
2168
- style: term.brightWhite,
2169
- selectedStyle: term.brightWhite.bgBlue,
2170
- keyBindings: {
2171
- ENTER: "submit",
2172
- KP_ENTER: "submit",
2173
- UP: "previous",
2174
- DOWN: "next",
2175
- TAB: "next",
2176
- SHIFT_TAB: "previous",
2177
- HOME: "first",
2178
- END: "last",
2179
- ESCAPE: "cancel",
2180
- CTRL_C: "cancel"
2181
- }
2182
- }).promise;
2183
- } finally {
2184
- term.off("key", onCtrlC);
2185
- }
2186
- term("\n");
2187
- if (response.canceled || response.selectedIndex === void 0) {
2188
- return { kind: "abort" };
2189
- }
2190
- if (response.selectedIndex === 0) {
2191
- return { kind: "new" };
2192
- }
2193
- const session = visible[response.selectedIndex - 1];
2194
- if (!session) {
2195
- return { kind: "abort" };
2196
- }
2197
- const result = {
2198
- kind: "attach",
2199
- sessionId: session.sessionId
2200
- };
2201
- if (session.agentId !== void 0) {
2202
- result.agentId = session.agentId;
2203
- }
2204
- return result;
2205
- }
2206
- function toRow(s) {
2207
- return {
2208
- session: stripHydraSessionPrefix(s.sessionId),
2209
- upstream: s.upstreamSessionId ?? "-",
2210
- status: s.status.toUpperCase(),
2211
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
2212
- agent: s.agentId ?? "?",
2213
- title: s.title ?? "-",
2214
- cwd: s.cwd
2215
- };
2216
2871
  }
2217
- function computeWidths(rows) {
2218
- return {
2219
- session: maxLen4(HEADER.session, rows.map((r) => r.session)),
2220
- upstream: maxLen4(HEADER.upstream, rows.map((r) => r.upstream)),
2221
- status: maxLen4(HEADER.status, rows.map((r) => r.status)),
2222
- clients: maxLen4(HEADER.clients, rows.map((r) => r.clients)),
2223
- agent: maxLen4(HEADER.agent, rows.map((r) => r.agent)),
2224
- title: maxLen4(HEADER.title, rows.map((r) => r.title))
2225
- };
2872
+ function readTermHeight(term) {
2873
+ return term.height ?? 24;
2226
2874
  }
2227
- function maxLen4(headerCell, values) {
2228
- let max = headerCell.length;
2229
- for (const v of values) {
2230
- if (v.length > max) {
2231
- max = v.length;
2232
- }
2233
- }
2234
- return max;
2875
+ function readTermWidth(term) {
2876
+ return term.width ?? 80;
2235
2877
  }
2236
- function formatRow(r, w) {
2237
- return [
2238
- r.session.padEnd(w.session),
2239
- r.upstream.padEnd(w.upstream),
2240
- r.status.padEnd(w.status),
2241
- r.clients.padStart(w.clients),
2242
- r.agent.padEnd(w.agent),
2243
- r.title.padEnd(w.title),
2244
- r.cwd
2245
- ].join(" ");
2878
+ function formatNewSessionLabel(cwd, maxWidth) {
2879
+ const prefix = "+ New session in ";
2880
+ const budget = Math.max(1, maxWidth - prefix.length);
2881
+ return prefix + truncateMiddle(cwd, budget);
2246
2882
  }
2247
- var HEADER;
2883
+ var ROW_PREFIX_WIDTH;
2248
2884
  var init_picker = __esm({
2249
2885
  "src/tui/picker.ts"() {
2250
2886
  "use strict";
2887
+ init_session_row();
2251
2888
  init_session();
2252
- HEADER = {
2253
- session: "SESSION",
2254
- upstream: "UPSTREAM",
2255
- status: "STATUS",
2256
- clients: "CLIENTS",
2257
- agent: "AGENT",
2258
- title: "TITLE",
2259
- cwd: "CWD"
2260
- };
2889
+ init_discovery();
2890
+ ROW_PREFIX_WIDTH = 2;
2261
2891
  }
2262
2892
  });
2263
2893
 
2264
2894
  // src/tui/screen.ts
2265
2895
  import stringWidth from "string-width";
2266
2896
  import wrapAnsi from "wrap-ansi";
2897
+ function formattedLineSig(zone, width, line) {
2898
+ if (!line) {
2899
+ return `${zone}|${width}|empty`;
2900
+ }
2901
+ return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}`;
2902
+ }
2267
2903
  function computePromptVisualRows(buffer, room) {
2268
2904
  const rows = [];
2269
2905
  for (let i = 0; i < buffer.length; i++) {
@@ -2348,16 +2984,16 @@ function writeStyled(term, text, style) {
2348
2984
  term.bold.red.noFormat(text);
2349
2985
  return;
2350
2986
  case "tool-status-pending":
2351
- term.dim.yellow.noFormat(text);
2987
+ term.dim.noFormat(text);
2352
2988
  return;
2353
2989
  case "tool-status-running":
2354
- term.bold.yellow.noFormat(text);
2990
+ term.brightYellow.noFormat(text);
2355
2991
  return;
2356
2992
  case "tool-status-cancelled":
2357
2993
  term.dim.noFormat(text);
2358
2994
  return;
2359
2995
  case "plan":
2360
- term.magenta.noFormat(text);
2996
+ term.brightYellow.noFormat(text);
2361
2997
  return;
2362
2998
  case "plan-done":
2363
2999
  term.green.noFormat(text);
@@ -2406,6 +3042,12 @@ function wrap(text, width) {
2406
3042
  if (text.length === 0) {
2407
3043
  return [""];
2408
3044
  }
3045
+ if (!NON_ASCII.test(text)) {
3046
+ return wrapAscii(text, width);
3047
+ }
3048
+ return wrapVisible(text, width);
3049
+ }
3050
+ function wrapAscii(text, width) {
2409
3051
  const out = [];
2410
3052
  let remaining = text;
2411
3053
  while (remaining.length > width) {
@@ -2428,14 +3070,78 @@ function wrap(text, width) {
2428
3070
  out.push(remaining);
2429
3071
  return out;
2430
3072
  }
3073
+ function wrapVisible(text, width) {
3074
+ const out = [];
3075
+ const graphemes = [];
3076
+ for (const { segment } of SEGMENTER.segment(text)) {
3077
+ graphemes.push({ seg: segment, w: stringWidth(segment) });
3078
+ }
3079
+ let i = 0;
3080
+ while (i < graphemes.length) {
3081
+ let chunk = "";
3082
+ let chunkW = 0;
3083
+ let lastSpaceI = -1;
3084
+ let chunkAtLastSpace = "";
3085
+ while (i < graphemes.length) {
3086
+ const g = graphemes[i];
3087
+ if (chunkW + g.w > width) {
3088
+ break;
3089
+ }
3090
+ if (g.seg === " ") {
3091
+ lastSpaceI = i;
3092
+ chunkAtLastSpace = chunk;
3093
+ }
3094
+ chunk += g.seg;
3095
+ chunkW += g.w;
3096
+ i += 1;
3097
+ }
3098
+ if (i >= graphemes.length) {
3099
+ out.push(chunk);
3100
+ break;
3101
+ }
3102
+ if (lastSpaceI >= 0) {
3103
+ out.push(chunkAtLastSpace);
3104
+ i = lastSpaceI + 1;
3105
+ } else if (chunk.length === 0) {
3106
+ out.push(graphemes[i].seg);
3107
+ i += 1;
3108
+ } else {
3109
+ out.push(chunk);
3110
+ }
3111
+ }
3112
+ return out;
3113
+ }
2431
3114
  function truncate(text, max) {
2432
- if (text.length <= max) {
3115
+ if (max <= 0) {
3116
+ return "";
3117
+ }
3118
+ if (text.length <= max && !NON_ASCII.test(text)) {
3119
+ return text;
3120
+ }
3121
+ const visible = stringWidth(text);
3122
+ if (visible <= max) {
2433
3123
  return text;
2434
3124
  }
2435
3125
  if (max <= 1) {
2436
- return text.slice(0, max);
3126
+ return takeByWidth(text, max);
2437
3127
  }
2438
- return text.slice(0, max - 1) + "\u2026";
3128
+ return takeByWidth(text, max - 1) + "\u2026";
3129
+ }
3130
+ function takeByWidth(text, budget) {
3131
+ if (budget <= 0) {
3132
+ return "";
3133
+ }
3134
+ let out = "";
3135
+ let used = 0;
3136
+ for (const { segment } of SEGMENTER.segment(text)) {
3137
+ const w = stringWidth(segment);
3138
+ if (used + w > budget) {
3139
+ break;
3140
+ }
3141
+ out += segment;
3142
+ used += w;
3143
+ }
3144
+ return out;
2439
3145
  }
2440
3146
  function firstLine2(text) {
2441
3147
  const idx = text.indexOf("\n");
@@ -2542,13 +3248,15 @@ function mapKeyName(name) {
2542
3248
  return "ctrl-u";
2543
3249
  case "CTRL_W":
2544
3250
  return "ctrl-w";
3251
+ case "CTRL_Y":
3252
+ return "ctrl-y";
2545
3253
  case "ESCAPE":
2546
3254
  return "escape";
2547
3255
  default:
2548
3256
  return null;
2549
3257
  }
2550
3258
  }
2551
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, Screen, shortId;
3259
+ 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;
2552
3260
  var init_screen = __esm({
2553
3261
  "src/tui/screen.ts"() {
2554
3262
  "use strict";
@@ -2562,6 +3270,7 @@ var init_screen = __esm({
2562
3270
  MAX_COMPLETION_ROWS = 6;
2563
3271
  CONFIRM_PROMPT_ROWS = 2;
2564
3272
  DEFAULT_CONTENT_REPAINT_THROTTLE_MS = 1e3;
3273
+ DEFAULT_MAX_SCROLLBACK_LINES = 1e4;
2565
3274
  Screen = class {
2566
3275
  term;
2567
3276
  dispatcher;
@@ -2582,6 +3291,27 @@ var init_screen = __esm({
2582
3291
  lastRepaintAt = 0;
2583
3292
  throttledRepaintTimer = null;
2584
3293
  contentRepaintThrottleMs;
3294
+ maxScrollbackLines;
3295
+ // Wrap memoization: each FormattedLine that lands in this.lines gets a
3296
+ // monotonic id assigned via trackLine(); wrapCache holds the pre-wrapped
3297
+ // FormattedLine[] for that id at wrapCacheWidth. Width changes flush the
3298
+ // whole cache; in-place body mutation (streaming) and splices invalidate
3299
+ // affected ids. Result: steady-state repaints only wrap newly-appended
3300
+ // lines, not the entire history.
3301
+ nextLineId = 1;
3302
+ lineIds = /* @__PURE__ */ new WeakMap();
3303
+ wrapCache = /* @__PURE__ */ new Map();
3304
+ wrapCacheWidth = 0;
3305
+ // Per-row signature of what was painted to each terminal row on the
3306
+ // previous repaint. drawX methods funnel through paintRow(), which
3307
+ // skips the moveTo+eraseLineAfter+write sequence when the new
3308
+ // signature matches the previous frame. Eliminates flicker during
3309
+ // the 1Hz busy-tick: only rows whose content actually changed
3310
+ // (banner elapsed, tools-block summary) get re-emitted instead of
3311
+ // every visible row. Cleared on dimension change.
3312
+ lastFrameRows = /* @__PURE__ */ new Map();
3313
+ lastFrameW = 0;
3314
+ lastFrameH = 0;
2585
3315
  permissionPrompt = null;
2586
3316
  confirmPrompt = null;
2587
3317
  completions = [];
@@ -2596,6 +3326,7 @@ var init_screen = __esm({
2596
3326
  queued: 0
2597
3327
  };
2598
3328
  header = { agent: "?", cwd: "?", sessionId: "?" };
3329
+ lastWindowTitle = null;
2599
3330
  resizeHandler;
2600
3331
  keyHandler;
2601
3332
  mouseHandler;
@@ -2616,6 +3347,7 @@ var init_screen = __esm({
2616
3347
  this.dispatcher = opts.dispatcher;
2617
3348
  this.onKey = opts.onKey;
2618
3349
  this.contentRepaintThrottleMs = opts.repaintThrottleMs ?? DEFAULT_CONTENT_REPAINT_THROTTLE_MS;
3350
+ this.maxScrollbackLines = opts.maxScrollbackLines ?? DEFAULT_MAX_SCROLLBACK_LINES;
2619
3351
  this.resizeHandler = () => this.repaint();
2620
3352
  this.keyHandler = (name, _matches, data) => this.handleKey(name, data);
2621
3353
  this.mouseHandler = (name) => this.handleMouse(name);
@@ -2627,6 +3359,11 @@ var init_screen = __esm({
2627
3359
  }
2628
3360
  this.started = true;
2629
3361
  this.term.fullscreen(true);
3362
+ this.lastFrameRows.clear();
3363
+ this.lastFrameW = 0;
3364
+ this.lastFrameH = 0;
3365
+ this.lastWindowTitle = null;
3366
+ process.stdout.write("\x1B[?7l");
2630
3367
  this.term.grabInput({ mouse: "button" });
2631
3368
  this.term.hideCursor(false);
2632
3369
  this.term.on("key", this.keyHandler);
@@ -2646,6 +3383,7 @@ var init_screen = __esm({
2646
3383
  this.term.off("resize", this.resizeHandler);
2647
3384
  this.term.grabInput(false);
2648
3385
  this.term.hideCursor(false);
3386
+ process.stdout.write("\x1B[?7h");
2649
3387
  this.term.fullscreen(false);
2650
3388
  this.term("\n");
2651
3389
  }
@@ -2716,13 +3454,17 @@ var init_screen = __esm({
2716
3454
  }
2717
3455
  this.streamingActive = false;
2718
3456
  this.lines.push(...lines);
3457
+ this.trackLines(lines);
2719
3458
  this.adjustScrollForLineChange(lines.length);
3459
+ this.trimScrollback();
2720
3460
  this.scheduleRepaint();
2721
3461
  }
2722
3462
  appendLine(line) {
2723
3463
  this.streamingActive = false;
2724
3464
  this.lines.push(line);
3465
+ this.trackLine(line);
2725
3466
  this.adjustScrollForLineChange(1);
3467
+ this.trimScrollback();
2726
3468
  this.scheduleRepaint();
2727
3469
  }
2728
3470
  // When scrolled away from the bottom, shift scrollOffset to keep the
@@ -2734,6 +3476,40 @@ var init_screen = __esm({
2734
3476
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
2735
3477
  }
2736
3478
  }
3479
+ trackLine(line) {
3480
+ this.lineIds.set(line, this.nextLineId++);
3481
+ }
3482
+ trackLines(lines) {
3483
+ for (const line of lines) {
3484
+ this.trackLine(line);
3485
+ }
3486
+ }
3487
+ forgetLine(line) {
3488
+ const id = this.lineIds.get(line);
3489
+ if (id !== void 0) {
3490
+ this.wrapCache.delete(id);
3491
+ }
3492
+ }
3493
+ // Drop oldest lines once scrollback exceeds the configured cap. Removes
3494
+ // their wrap-cache entries and shifts keyedBlocks indices in sync;
3495
+ // blocks whose lines fully fell off the head are dropped (a later
3496
+ // upsert for that key will start a fresh block at the bottom).
3497
+ trimScrollback() {
3498
+ const overflow = this.lines.length - this.maxScrollbackLines;
3499
+ if (overflow <= 0) {
3500
+ return;
3501
+ }
3502
+ const removed = this.lines.splice(0, overflow);
3503
+ for (const line of removed) {
3504
+ this.forgetLine(line);
3505
+ }
3506
+ for (const [key, range] of [...this.keyedBlocks.entries()]) {
3507
+ range.start -= overflow;
3508
+ if (range.start < 0) {
3509
+ this.keyedBlocks.delete(key);
3510
+ }
3511
+ }
3512
+ }
2737
3513
  // Append-or-replace a single-line block keyed by `key`. Thin wrapper
2738
3514
  // around upsertLines for the common one-row case (tool calls).
2739
3515
  upsertLine(key, line) {
@@ -2755,7 +3531,15 @@ var init_screen = __esm({
2755
3531
  touchesEnd = oldEnd >= this.lines.length;
2756
3532
  const delta = newLines.length - existing.count;
2757
3533
  scrollDelta = delta;
2758
- this.lines.splice(existing.start, existing.count, ...newLines);
3534
+ const removed = this.lines.splice(
3535
+ existing.start,
3536
+ existing.count,
3537
+ ...newLines
3538
+ );
3539
+ for (const line of removed) {
3540
+ this.forgetLine(line);
3541
+ }
3542
+ this.trackLines(newLines);
2759
3543
  existing.count = newLines.length;
2760
3544
  if (delta !== 0) {
2761
3545
  for (const [k, range] of this.keyedBlocks) {
@@ -2772,11 +3556,13 @@ var init_screen = __esm({
2772
3556
  count: newLines.length
2773
3557
  });
2774
3558
  this.lines.push(...newLines);
3559
+ this.trackLines(newLines);
2775
3560
  }
2776
3561
  if (touchesEnd) {
2777
3562
  this.streamingActive = false;
2778
3563
  }
2779
3564
  this.adjustScrollForLineChange(scrollDelta);
3565
+ this.trimScrollback();
2780
3566
  this.scheduleRepaint();
2781
3567
  }
2782
3568
  // Append fragments of a streaming message (e.g. agent_message_chunk). The
@@ -2793,6 +3579,7 @@ var init_screen = __esm({
2793
3579
  if (this.streamingActive && this.lines.length > 0) {
2794
3580
  const last = this.lines[this.lines.length - 1];
2795
3581
  if (last) {
3582
+ this.forgetLine(last);
2796
3583
  last.body += first ?? "";
2797
3584
  }
2798
3585
  } else {
@@ -2800,7 +3587,9 @@ var init_screen = __esm({
2800
3587
  const last = this.lines[this.lines.length - 1];
2801
3588
  const isBlank = last && last.body === "" && (!last.prefix || last.prefix === "");
2802
3589
  if (!isBlank) {
2803
- this.lines.push({ body: "" });
3590
+ const sep = { body: "" };
3591
+ this.lines.push(sep);
3592
+ this.trackLine(sep);
2804
3593
  added += 1;
2805
3594
  }
2806
3595
  }
@@ -2813,25 +3602,48 @@ var init_screen = __esm({
2813
3602
  initial.prefixStyle = prefixStyle;
2814
3603
  }
2815
3604
  this.lines.push(initial);
3605
+ this.trackLine(initial);
2816
3606
  added += 1;
2817
3607
  }
2818
3608
  const continuationPrefix = " ".repeat(prefix.length);
2819
3609
  for (const piece of rest) {
2820
- this.lines.push({
3610
+ const cont = {
2821
3611
  prefix: continuationPrefix,
2822
3612
  body: piece,
2823
3613
  bodyStyle
2824
- });
3614
+ };
3615
+ this.lines.push(cont);
3616
+ this.trackLine(cont);
2825
3617
  added += 1;
2826
3618
  }
2827
3619
  this.streamingActive = true;
2828
3620
  this.adjustScrollForLineChange(added);
3621
+ this.trimScrollback();
2829
3622
  this.scheduleRepaint();
2830
3623
  }
2831
3624
  setHeader(header) {
2832
3625
  this.header = { ...this.header, ...header };
3626
+ this.syncWindowTitle();
2833
3627
  this.repaint();
2834
3628
  }
3629
+ // Push the current session title (or short session id, as fallback) to
3630
+ // the host terminal via OSC 2. Supported by xterm/foot/iTerm2/Alacritty/
3631
+ // most modern emulators; ignored harmlessly elsewhere.
3632
+ syncWindowTitle() {
3633
+ const title = this.header.title?.trim();
3634
+ const fallback = shortId(this.header.sessionId) || "hydra";
3635
+ const raw = title && title.length > 0 ? title : fallback;
3636
+ const clean = raw.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
3637
+ if (clean === this.lastWindowTitle) {
3638
+ return;
3639
+ }
3640
+ this.lastWindowTitle = clean;
3641
+ process.stdout.write(`\x1B]2;${clean}\x1B\\`);
3642
+ }
3643
+ clearWindowTitle() {
3644
+ this.lastWindowTitle = null;
3645
+ process.stdout.write("\x1B]2;\x1B\\");
3646
+ }
2835
3647
  setBanner(banner) {
2836
3648
  this.banner = { ...this.banner, ...banner };
2837
3649
  this.drawBanner();
@@ -2840,6 +3652,8 @@ var init_screen = __esm({
2840
3652
  clearScrollback() {
2841
3653
  this.lines = [];
2842
3654
  this.keyedBlocks.clear();
3655
+ this.wrapCache.clear();
3656
+ this.wrapCacheWidth = 0;
2843
3657
  this.streamingActive = false;
2844
3658
  this.scrollOffset = 0;
2845
3659
  this.repaint();
@@ -2862,7 +3676,10 @@ var init_screen = __esm({
2862
3676
  return;
2863
3677
  }
2864
3678
  const touchesEnd = existing.start + existing.count >= this.lines.length;
2865
- this.lines.splice(existing.start, existing.count);
3679
+ const removed = this.lines.splice(existing.start, existing.count);
3680
+ for (const line of removed) {
3681
+ this.forgetLine(line);
3682
+ }
2866
3683
  this.keyedBlocks.delete(key);
2867
3684
  for (const [, range] of this.keyedBlocks) {
2868
3685
  if (range.start > existing.start) {
@@ -2878,6 +3695,22 @@ var init_screen = __esm({
2878
3695
  redraw() {
2879
3696
  this.repaint();
2880
3697
  }
3698
+ // Forced clean-slate repaint. Drops the per-row signature cache, the
3699
+ // window-title cache, the wrap cache, and clears the terminal before
3700
+ // painting. Wired to ^L so the user can recover when something has
3701
+ // corrupted the visible state and the per-row sig check otherwise
3702
+ // short-circuits the re-emit.
3703
+ fullRedraw() {
3704
+ this.lastFrameRows.clear();
3705
+ this.lastFrameW = 0;
3706
+ this.lastFrameH = 0;
3707
+ this.lastWindowTitle = null;
3708
+ this.wrapCache.clear();
3709
+ this.wrapCacheWidth = 0;
3710
+ process.stdout.write("\x1B[?7l");
3711
+ this.term.clear();
3712
+ this.repaint();
3713
+ }
2881
3714
  // While paused, append* methods buffer state but don't repaint. Calls are
2882
3715
  // counter-based so they nest safely. Resume triggers exactly one repaint
2883
3716
  // if any was requested while paused.
@@ -2936,9 +3769,12 @@ var init_screen = __esm({
2936
3769
  if (last && last.body === "" && (last.prefix === void 0 || last.prefix === "")) {
2937
3770
  return;
2938
3771
  }
2939
- this.lines.push({ body: "" });
3772
+ const sep = { body: "" };
3773
+ this.lines.push(sep);
3774
+ this.trackLine(sep);
2940
3775
  this.streamingActive = false;
2941
3776
  this.adjustScrollForLineChange(1);
3777
+ this.trimScrollback();
2942
3778
  this.scheduleRepaint();
2943
3779
  }
2944
3780
  // The dispatcher is the source of truth for prompt state. If the prompt
@@ -3010,9 +3846,11 @@ var init_screen = __esm({
3010
3846
  return Math.max(0, bottom - top + 1);
3011
3847
  }
3012
3848
  maxScrollOffset() {
3013
- const wrapped = this.wrapLines(this.lines, this.term.width);
3014
- const visible = this.scrollbackVisibleRows();
3015
- return Math.max(0, wrapped.length - visible);
3849
+ const { rows } = this.wrapTail(
3850
+ this.term.width,
3851
+ Number.POSITIVE_INFINITY
3852
+ );
3853
+ return Math.max(0, rows.length - this.scrollbackVisibleRows());
3016
3854
  }
3017
3855
  // Used by content mutators to coalesce rapid updates. Repaints fire
3018
3856
  // at most once per contentRepaintThrottleMs; if a paint happened
@@ -3045,6 +3883,22 @@ var init_screen = __esm({
3045
3883
  this.repaint();
3046
3884
  }, this.contentRepaintThrottleMs - elapsed);
3047
3885
  }
3886
+ // Funnel for every row that any drawX method renders. Skips emitting
3887
+ // moveTo+eraseLineAfter+paint when the row's signature matches the
3888
+ // previous frame's. The signature must capture everything that affects
3889
+ // visible output for that row (width, FormattedLine fields, banner
3890
+ // state, etc.) so identical sigs guarantee identical bytes.
3891
+ paintRow(row, signature, paint) {
3892
+ if (row < 1 || row > this.term.height) {
3893
+ return;
3894
+ }
3895
+ if (this.lastFrameRows.get(row) === signature) {
3896
+ return;
3897
+ }
3898
+ this.lastFrameRows.set(row, signature);
3899
+ this.term.moveTo(1, row).eraseLineAfter();
3900
+ paint();
3901
+ }
3048
3902
  repaint() {
3049
3903
  if (this.repaintPaused > 0) {
3050
3904
  this.repaintPending = true;
@@ -3060,6 +3914,11 @@ var init_screen = __esm({
3060
3914
  if (w < 20 || h < 8) {
3061
3915
  return;
3062
3916
  }
3917
+ if (w !== this.lastFrameW || h !== this.lastFrameH) {
3918
+ this.lastFrameRows.clear();
3919
+ this.lastFrameW = w;
3920
+ this.lastFrameH = h;
3921
+ }
3063
3922
  this.drawHeader();
3064
3923
  this.drawSeparator(HEADER_ROWS);
3065
3924
  this.drawScrollback();
@@ -3075,38 +3934,40 @@ var init_screen = __esm({
3075
3934
  }
3076
3935
  drawHeader() {
3077
3936
  const w = this.term.width;
3078
- this.term.moveTo(1, 1).eraseLineAfter();
3079
3937
  const usage = formatUsage(this.header.usage);
3080
3938
  const sid = shortId(this.header.sessionId);
3081
3939
  const title = this.header.title?.trim();
3082
- const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
3083
- const variableRoom = Math.max(8, w - fixed);
3084
- let cwdRoom;
3085
- let titleRoom;
3086
- if (title) {
3087
- const cwdCap = Math.max(8, Math.floor(variableRoom / 2));
3088
- cwdRoom = Math.min(this.header.cwd.length, cwdCap);
3089
- titleRoom = Math.max(8, variableRoom - cwdRoom);
3090
- } else {
3091
- titleRoom = 0;
3092
- cwdRoom = variableRoom;
3093
- }
3094
- this.term.bold("hydra")(" \xB7 ").cyan(this.header.agent)(" \xB7 ").dim(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
3095
- if (title) {
3096
- this.term(" \xB7 ").bold(truncate(title, titleRoom));
3097
- }
3098
- if (usage) {
3099
- const col = Math.max(1, w - usage.length + 1);
3100
- this.term.moveTo(col, 1);
3101
- this.term.dim(usage);
3102
- }
3940
+ const sig = `hdr|${w}|${this.header.agent}|${this.header.cwd}|${sid}|${title ?? ""}|${usage ?? ""}`;
3941
+ this.paintRow(1, sig, () => {
3942
+ const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
3943
+ const variableRoom = Math.max(8, w - fixed);
3944
+ let cwdRoom;
3945
+ let titleRoom;
3946
+ if (title) {
3947
+ const cwdMin = Math.min(this.header.cwd.length, 12);
3948
+ const titleCap = Math.max(8, variableRoom - cwdMin);
3949
+ titleRoom = Math.min(title.length, titleCap);
3950
+ cwdRoom = Math.max(8, variableRoom - titleRoom);
3951
+ } else {
3952
+ titleRoom = 0;
3953
+ cwdRoom = variableRoom;
3954
+ }
3955
+ this.term.bold("hydra")(" \xB7 ").cyan.noFormat(this.header.agent)(" \xB7 ").dim.noFormat(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
3956
+ if (title) {
3957
+ this.term(" \xB7 ").bold.noFormat(truncate(title, titleRoom));
3958
+ }
3959
+ if (usage) {
3960
+ const col = Math.max(1, w - usage.length + 1);
3961
+ this.term.moveTo(col, 1);
3962
+ this.term.dim(usage);
3963
+ }
3964
+ });
3103
3965
  }
3104
3966
  drawSeparator(row) {
3105
- if (row < 1 || row > this.term.height) {
3106
- return;
3107
- }
3108
- this.term.moveTo(1, row).eraseLineAfter();
3109
- this.term.dim("\u2500".repeat(this.term.width));
3967
+ const w = this.term.width;
3968
+ this.paintRow(row, `sep|${w}`, () => {
3969
+ this.term.dim("\u2500".repeat(w));
3970
+ });
3110
3971
  }
3111
3972
  drawScrollback() {
3112
3973
  const w = this.term.width;
@@ -3115,21 +3976,30 @@ var init_screen = __esm({
3115
3976
  if (visibleRows <= 0) {
3116
3977
  return;
3117
3978
  }
3118
- const wrapped = this.wrapLines(this.lines, w);
3119
- const max = Math.max(0, wrapped.length - visibleRows);
3120
- if (this.scrollOffset > max) {
3121
- this.scrollOffset = max;
3979
+ const { rows: wrapped, exhausted } = this.wrapTail(
3980
+ w,
3981
+ visibleRows + this.scrollOffset
3982
+ );
3983
+ if (exhausted) {
3984
+ const max = Math.max(0, wrapped.length - visibleRows);
3985
+ if (this.scrollOffset > max) {
3986
+ this.scrollOffset = max;
3987
+ }
3122
3988
  }
3123
3989
  const end = wrapped.length - this.scrollOffset;
3124
3990
  const start = Math.max(0, end - visibleRows);
3125
3991
  const slice = wrapped.slice(start, end);
3992
+ const padTop = Math.max(0, visibleRows - slice.length);
3126
3993
  for (let i = 0; i < visibleRows; i++) {
3127
3994
  const row = top + i;
3128
- this.term.moveTo(1, row).eraseLineAfter();
3129
- const line = slice[i];
3130
- if (line) {
3131
- this.writeFormattedLine(line, w);
3132
- }
3995
+ const sliceIdx = i - padTop;
3996
+ const line = sliceIdx >= 0 ? slice[sliceIdx] : void 0;
3997
+ const sig = formattedLineSig("sb", w, line);
3998
+ this.paintRow(row, sig, () => {
3999
+ if (line) {
4000
+ this.writeFormattedLine(line, w);
4001
+ }
4002
+ });
3133
4003
  }
3134
4004
  }
3135
4005
  queuedRows() {
@@ -3160,26 +4030,27 @@ var init_screen = __esm({
3160
4030
  }
3161
4031
  for (let i = 0; i < rows; i++) {
3162
4032
  const row = completionTop + i;
3163
- this.term.moveTo(1, row).eraseLineAfter();
3164
4033
  const item = this.completions[i];
3165
- if (!item) {
3166
- continue;
3167
- }
3168
4034
  const isLast = i === rows - 1 && this.completions.length > MAX_COMPLETION_ROWS;
3169
- if (isLast) {
3170
- this.term.dim(
3171
- ` + ${this.completions.length - MAX_COMPLETION_ROWS + 1} more match(es)`
3172
- );
3173
- continue;
3174
- }
3175
- const namePadded = item.name.padEnd(nameWidth);
3176
- const desc = item.description ?? "";
3177
- const remaining = w - namePadded.length - 4;
3178
- const truncated = remaining > 0 ? truncate(desc, remaining) : "";
3179
- this.term(" ").brightCyan(namePadded);
3180
- if (truncated.length > 0) {
3181
- this.term(" ").dim(truncated);
3182
- }
4035
+ const overflow = this.completions.length - MAX_COMPLETION_ROWS + 1;
4036
+ const sig = item ? isLast ? `comp|${w}|overflow|${overflow}` : `comp|${w}|${nameWidth}|${item.name}|${item.description ?? ""}` : `comp|${w}|empty`;
4037
+ this.paintRow(row, sig, () => {
4038
+ if (!item) {
4039
+ return;
4040
+ }
4041
+ if (isLast) {
4042
+ this.term.dim(` + ${overflow} more match(es)`);
4043
+ return;
4044
+ }
4045
+ const namePadded = item.name.padEnd(nameWidth);
4046
+ const desc = item.description ?? "";
4047
+ const remaining = w - namePadded.length - 4;
4048
+ const truncated = remaining > 0 ? truncate(desc, remaining) : "";
4049
+ this.term(" ").brightCyan(namePadded);
4050
+ if (truncated.length > 0) {
4051
+ this.term(" ").dim(truncated);
4052
+ }
4053
+ });
3183
4054
  }
3184
4055
  }
3185
4056
  drawQueuedZone() {
@@ -3194,17 +4065,19 @@ var init_screen = __esm({
3194
4065
  const queuedTop = queuedBottom - rows + 1;
3195
4066
  for (let i = 0; i < rows; i++) {
3196
4067
  const row = queuedTop + i;
3197
- this.term.moveTo(1, row).eraseLineAfter();
3198
4068
  const text = this.queuedTexts[i];
3199
- if (text === void 0) {
3200
- continue;
3201
- }
3202
4069
  const isLast = i === rows - 1 && this.queuedTexts.length > MAX_QUEUED_ROWS;
3203
4070
  const overflow = this.queuedTexts.length - MAX_QUEUED_ROWS;
3204
- const summary = isLast ? `+ ${overflow + 1} more queued` : truncate(firstLine2(text), w - 4);
3205
- const display = ` \u23F3 ${summary}`;
3206
- const padded = display + " ".repeat(Math.max(0, w - display.length));
3207
- this.term.bgBlue.brightWhite.noFormat(padded);
4071
+ const summary = text === void 0 ? "" : isLast ? `+ ${overflow + 1} more queued` : truncate(firstLine2(text), w - 4);
4072
+ const sig = text === void 0 ? `queued|${w}|empty` : `queued|${w}|${isLast ? "ovf" : "row"}|${summary}`;
4073
+ this.paintRow(row, sig, () => {
4074
+ if (text === void 0) {
4075
+ return;
4076
+ }
4077
+ const display = ` \u23F3 ${summary}`;
4078
+ const padded = display + " ".repeat(Math.max(0, w - display.length));
4079
+ this.term.bgBlue.brightWhite.noFormat(padded);
4080
+ });
3208
4081
  }
3209
4082
  }
3210
4083
  drawPrompt() {
@@ -3225,19 +4098,30 @@ var init_screen = __esm({
3225
4098
  for (let i = 0; i < layout.rendered; i++) {
3226
4099
  const vr = visualRows[layout.windowStart + i];
3227
4100
  const row = top + i;
3228
- this.term.moveTo(1, row).eraseLineAfter();
3229
- if (!vr) {
3230
- continue;
3231
- }
3232
- if (vr.bufferIdx === 0 && vr.startCol === 0) {
3233
- this.term.brightWhite("> ");
3234
- } else if (vr.startCol === 0) {
3235
- this.term.dim("\xB7 ");
3236
- } else {
3237
- this.term(" ");
4101
+ let gutter = "wrap";
4102
+ let slice = "";
4103
+ if (vr) {
4104
+ if (vr.bufferIdx === 0 && vr.startCol === 0) {
4105
+ gutter = "first";
4106
+ } else if (vr.startCol === 0) {
4107
+ gutter = "newline";
4108
+ }
4109
+ slice = (state.buffer[vr.bufferIdx] ?? "").slice(vr.startCol, vr.endCol);
3238
4110
  }
3239
- const line = state.buffer[vr.bufferIdx] ?? "";
3240
- this.term.noFormat(line.slice(vr.startCol, vr.endCol));
4111
+ const sig = vr ? `prompt|${this.term.width}|${gutter}|${slice}` : `prompt|${this.term.width}|empty`;
4112
+ this.paintRow(row, sig, () => {
4113
+ if (!vr) {
4114
+ return;
4115
+ }
4116
+ if (gutter === "first") {
4117
+ this.term.brightWhite("> ");
4118
+ } else if (gutter === "newline") {
4119
+ this.term.dim("\xB7 ");
4120
+ } else {
4121
+ this.term(" ");
4122
+ }
4123
+ this.term.noFormat(slice);
4124
+ });
3241
4125
  }
3242
4126
  }
3243
4127
  drawConfirmPrompt() {
@@ -3247,10 +4131,12 @@ var init_screen = __esm({
3247
4131
  }
3248
4132
  const w = this.term.width;
3249
4133
  const top = this.term.height - CONFIRM_PROMPT_ROWS - BANNER_ROWS + 1;
3250
- this.term.moveTo(1, top).eraseLineAfter();
3251
- this.term.brightYellow(` ? ${truncate(spec.question, w - 4)}`);
3252
- this.term.moveTo(1, top + 1).eraseLineAfter();
3253
- this.term.dim(` ${truncate(spec.hint, w - 2)}`);
4134
+ this.paintRow(top, `confirm|q|${w}|${spec.question}`, () => {
4135
+ this.term.brightYellow(` ? ${truncate(spec.question, w - 4)}`);
4136
+ });
4137
+ this.paintRow(top + 1, `confirm|h|${w}|${spec.hint}`, () => {
4138
+ this.term.dim(` ${truncate(spec.hint, w - 2)}`);
4139
+ });
3254
4140
  }
3255
4141
  drawPermissionPrompt() {
3256
4142
  const spec = this.permissionPrompt;
@@ -3261,21 +4147,20 @@ var init_screen = __esm({
3261
4147
  const rows = this.permissionRows();
3262
4148
  const top = this.term.height - rows - BANNER_ROWS + 1;
3263
4149
  let row = top;
3264
- const writeRow = (paint) => {
4150
+ const writeRow = (sig, paint) => {
3265
4151
  if (row >= top + rows) {
3266
4152
  return;
3267
4153
  }
3268
- this.term.moveTo(1, row).eraseLineAfter();
3269
- paint();
4154
+ this.paintRow(row, sig, paint);
3270
4155
  row += 1;
3271
4156
  };
3272
- writeRow(() => {
4157
+ writeRow(`perm|t|${w}|${spec.title}`, () => {
3273
4158
  this.term.brightYellow(` \u{1F512} ${truncate(spec.title, w - 5)}`);
3274
4159
  });
3275
- writeRow(() => {
4160
+ writeRow(`perm|sub|${w}`, () => {
3276
4161
  this.term.dim(" This action requires approval");
3277
4162
  });
3278
- writeRow(() => {
4163
+ writeRow(`perm|q|${w}`, () => {
3279
4164
  this.term(" Do you want to proceed?");
3280
4165
  });
3281
4166
  for (let i = 0; i < spec.options.length; i++) {
@@ -3289,7 +4174,7 @@ var init_screen = __esm({
3289
4174
  const isSel = i === spec.selectedIndex;
3290
4175
  const marker = isSel ? "\u276F" : " ";
3291
4176
  const body = ` ${marker} ${i + 1}. ${truncate(opt.label, w - 8)}`;
3292
- writeRow(() => {
4177
+ writeRow(`perm|o|${w}|${i}|${isSel ? "1" : "0"}|${opt.label}`, () => {
3293
4178
  if (isSel) {
3294
4179
  this.term.brightCyan(body);
3295
4180
  } else {
@@ -3297,38 +4182,42 @@ var init_screen = __esm({
3297
4182
  }
3298
4183
  });
3299
4184
  }
3300
- writeRow(() => {
4185
+ writeRow(`perm|hint|${w}`, () => {
3301
4186
  this.term.dim(" \u2191/\u2193 choose \xB7 Enter submit \xB7 Esc cancel \xB7 1\u20139 quick-pick");
3302
4187
  });
3303
4188
  }
3304
4189
  drawBanner() {
3305
4190
  const row = this.term.height;
3306
- this.term.moveTo(1, row).eraseLineAfter();
3307
- const dot = this.banner.status === "running" ? "\u25CF" : "\u25CB";
3308
- const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
3309
- if (this.banner.status === "running") {
3310
- this.term.brightYellow(`${dot} ${this.banner.status}`);
3311
- if (this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3) {
3312
- this.term(" ").dim(formatElapsed(this.banner.elapsedMs));
4191
+ const w = this.term.width;
4192
+ const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
4193
+ const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}`;
4194
+ this.paintRow(row, sig, () => {
4195
+ const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
4196
+ const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
4197
+ if (this.banner.status === "busy") {
4198
+ this.term.brightYellow(`${dot} ${this.banner.status}`);
4199
+ if (elapsedStr) {
4200
+ this.term(" ").dim(elapsedStr);
4201
+ }
4202
+ } else if (this.banner.status === "disconnected") {
4203
+ this.term.brightRed(`${dot} ${this.banner.status}`);
4204
+ } else {
4205
+ this.term.brightGreen(`${dot} ${this.banner.status}`);
3313
4206
  }
3314
- } else if (this.banner.status === "disconnected") {
3315
- this.term.brightRed(`${dot} ${this.banner.status}`);
3316
- } else {
3317
- this.term.brightGreen(`${dot} ${this.banner.status}`);
3318
- }
3319
- if (this.banner.queued > 0) {
3320
- this.term(" \xB7 ").brightYellow(`${this.banner.queued} queued`);
3321
- }
3322
- if (this.scrollOffset > 0) {
3323
- this.term(" \xB7 ").brightCyan(`\u2191 ${this.scrollOffset}`);
3324
- }
3325
- this.term(" \xB7 ");
3326
- if (this.banner.planMode) {
3327
- this.term.brightMagenta(planLabel);
3328
- } else {
3329
- this.term.dim(planLabel);
3330
- }
3331
- this.term(" \xB7 ").dim(this.banner.hint);
4207
+ if (this.banner.queued > 0) {
4208
+ this.term(" \xB7 ").brightYellow(`${this.banner.queued} queued`);
4209
+ }
4210
+ if (this.scrollOffset > 0) {
4211
+ this.term(" \xB7 ").brightCyan(`\u2191 ${this.scrollOffset}`);
4212
+ }
4213
+ this.term(" \xB7 ");
4214
+ if (this.banner.planMode) {
4215
+ this.term.brightYellow(planLabel);
4216
+ } else {
4217
+ this.term.dim(planLabel);
4218
+ }
4219
+ this.term(" \xB7 ").dim(this.banner.hint);
4220
+ });
3332
4221
  }
3333
4222
  placeCursor() {
3334
4223
  if (this.permissionPrompt) {
@@ -3378,37 +4267,83 @@ var init_screen = __esm({
3378
4267
  4 + this.permissionPrompt.options.length
3379
4268
  );
3380
4269
  }
3381
- wrapLines(lines, width) {
4270
+ // Walk this.lines from the tail, accumulating wrapped rows via the
4271
+ // wrap cache, until we have at least `needed` rows or run out. Returns
4272
+ // the collected rows in original (top-down) order plus an `exhausted`
4273
+ // flag that's true iff we reached the head of this.lines. The hot path
4274
+ // (drawScrollback) only ever asks for `visibleRows + scrollOffset`
4275
+ // rows, so a 10k-line scrollback costs ~50 cache hits per repaint
4276
+ // instead of 10k. With `needed = Infinity` this walks everything and
4277
+ // doubles as a total-row counter for maxScrollOffset.
4278
+ wrapTail(width, needed) {
3382
4279
  if (width <= 4) {
3383
- return lines;
4280
+ const take = Math.min(needed, this.lines.length);
4281
+ return {
4282
+ rows: this.lines.slice(this.lines.length - take),
4283
+ exhausted: needed >= this.lines.length
4284
+ };
3384
4285
  }
3385
- const out = [];
3386
- for (const line of lines) {
3387
- const prefix = line.prefix ?? "";
3388
- const room = Math.max(1, width - prefix.length);
3389
- const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
3390
- for (let i = 0; i < chunks.length; i++) {
3391
- const chunk = chunks[i] ?? "";
3392
- const wrappedLine = {
3393
- prefix: i === 0 ? line.prefix : " ".repeat(prefix.length),
3394
- body: chunk
3395
- };
3396
- if (line.prefixStyle !== void 0) {
3397
- wrappedLine.prefixStyle = line.prefixStyle;
3398
- }
3399
- if (line.bodyStyle !== void 0) {
3400
- wrappedLine.bodyStyle = line.bodyStyle;
3401
- }
3402
- if (line.fillRow) {
3403
- wrappedLine.fillRow = true;
3404
- }
3405
- if (line.ansi) {
3406
- wrappedLine.ansi = true;
3407
- }
3408
- out.push(wrappedLine);
4286
+ if (this.wrapCacheWidth !== width) {
4287
+ this.wrapCache.clear();
4288
+ this.wrapCacheWidth = width;
4289
+ }
4290
+ if (needed <= 0 || this.lines.length === 0) {
4291
+ return { rows: [], exhausted: true };
4292
+ }
4293
+ const batches = [];
4294
+ let total = 0;
4295
+ let stoppedAt = 0;
4296
+ for (let i = this.lines.length - 1; i >= 0; i--) {
4297
+ const wrapped = this.wrapOne(this.lines[i], width);
4298
+ batches.push(wrapped);
4299
+ total += wrapped.length;
4300
+ stoppedAt = i;
4301
+ if (total >= needed) {
4302
+ break;
4303
+ }
4304
+ }
4305
+ const rows = [];
4306
+ for (let i = batches.length - 1; i >= 0; i--) {
4307
+ rows.push(...batches[i]);
4308
+ }
4309
+ return { rows, exhausted: stoppedAt === 0 };
4310
+ }
4311
+ wrapOne(line, width) {
4312
+ const id = this.lineIds.get(line);
4313
+ if (id !== void 0) {
4314
+ const cached = this.wrapCache.get(id);
4315
+ if (cached) {
4316
+ return cached;
4317
+ }
4318
+ }
4319
+ const prefix = line.prefix ?? "";
4320
+ const room = Math.max(1, width - prefix.length);
4321
+ const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
4322
+ const wrapped = [];
4323
+ for (let i = 0; i < chunks.length; i++) {
4324
+ const chunk = chunks[i] ?? "";
4325
+ const wrappedLine = {
4326
+ prefix: i === 0 ? line.prefix : " ".repeat(prefix.length),
4327
+ body: chunk
4328
+ };
4329
+ if (line.prefixStyle !== void 0) {
4330
+ wrappedLine.prefixStyle = line.prefixStyle;
3409
4331
  }
4332
+ if (line.bodyStyle !== void 0) {
4333
+ wrappedLine.bodyStyle = line.bodyStyle;
4334
+ }
4335
+ if (line.fillRow) {
4336
+ wrappedLine.fillRow = true;
4337
+ }
4338
+ if (line.ansi) {
4339
+ wrappedLine.ansi = true;
4340
+ }
4341
+ wrapped.push(wrappedLine);
4342
+ }
4343
+ if (id !== void 0) {
4344
+ this.wrapCache.set(id, wrapped);
3410
4345
  }
3411
- return out;
4346
+ return wrapped;
3412
4347
  }
3413
4348
  writeFormattedLine(line, width) {
3414
4349
  if (line.prefix) {
@@ -3429,6 +4364,8 @@ var init_screen = __esm({
3429
4364
  }
3430
4365
  }
3431
4366
  };
4367
+ NON_ASCII = /[^\x20-\x7e]/;
4368
+ SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3432
4369
  shortId = stripHydraSessionPrefix;
3433
4370
  }
3434
4371
  });
@@ -3447,6 +4384,10 @@ var init_input = __esm({
3447
4384
  savedDraft = null;
3448
4385
  history = [];
3449
4386
  turnRunning = false;
4387
+ // Single-slot kill ring. The most recent killed text (^U, ^K, ^W) lands
4388
+ // here so ^Y can yank it back. Standard readline keeps a stack; we
4389
+ // only keep one slot because that's what 99% of yank uses look like.
4390
+ killBuffer = "";
3450
4391
  constructor(opts = {}) {
3451
4392
  this.history = [...opts.history ?? []];
3452
4393
  this.planMode = opts.planMode ?? false;
@@ -3555,6 +4496,9 @@ var init_input = __esm({
3555
4496
  case "ctrl-w":
3556
4497
  this.killWord();
3557
4498
  return [];
4499
+ case "ctrl-y":
4500
+ this.yank();
4501
+ return [];
3558
4502
  case "escape":
3559
4503
  return [];
3560
4504
  }
@@ -3648,11 +4592,19 @@ var init_input = __esm({
3648
4592
  }
3649
4593
  killLine() {
3650
4594
  const line = this.currentLine();
4595
+ const killed = line.slice(0, this.col);
4596
+ if (killed.length > 0) {
4597
+ this.killBuffer = killed;
4598
+ }
3651
4599
  this.setCurrentLine(line.slice(this.col));
3652
4600
  this.col = 0;
3653
4601
  }
3654
4602
  killToEnd() {
3655
4603
  const line = this.currentLine();
4604
+ const killed = line.slice(this.col);
4605
+ if (killed.length > 0) {
4606
+ this.killBuffer = killed;
4607
+ }
3656
4608
  this.setCurrentLine(line.slice(0, this.col));
3657
4609
  }
3658
4610
  killWord() {
@@ -3668,9 +4620,19 @@ var init_input = __esm({
3668
4620
  while (i > 0 && !/\s/.test(line[i - 1] ?? "")) {
3669
4621
  i -= 1;
3670
4622
  }
4623
+ const killed = line.slice(i, this.col);
4624
+ if (killed.length > 0) {
4625
+ this.killBuffer = killed;
4626
+ }
3671
4627
  this.setCurrentLine(line.slice(0, i) + line.slice(this.col));
3672
4628
  this.col = i;
3673
4629
  }
4630
+ yank() {
4631
+ if (this.killBuffer.length === 0) {
4632
+ return;
4633
+ }
4634
+ this.insertText(this.killBuffer);
4635
+ }
3674
4636
  moveLeft() {
3675
4637
  if (this.col > 0) {
3676
4638
  this.col -= 1;
@@ -3821,10 +4783,28 @@ function mapUpdate(update) {
3821
4783
  }
3822
4784
  function mapSessionInfo(u) {
3823
4785
  const title = readString(u, "title");
3824
- if (title === void 0) {
4786
+ const meta = u._meta;
4787
+ let agentId;
4788
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4789
+ const ns = meta["hydra-acp"];
4790
+ if (ns && typeof ns === "object" && !Array.isArray(ns)) {
4791
+ const candidate = ns.agentId;
4792
+ if (typeof candidate === "string") {
4793
+ agentId = candidate;
4794
+ }
4795
+ }
4796
+ }
4797
+ if (title === void 0 && agentId === void 0) {
3825
4798
  return null;
3826
4799
  }
3827
- return { kind: "session-info", title };
4800
+ const event = { kind: "session-info" };
4801
+ if (title !== void 0) {
4802
+ event.title = title;
4803
+ }
4804
+ if (agentId !== void 0) {
4805
+ event.agentId = agentId;
4806
+ }
4807
+ return event;
3828
4808
  }
3829
4809
  function mapAvailableCommands(u) {
3830
4810
  const list = u.availableCommands ?? u.commands;
@@ -4228,8 +5208,9 @@ function formatToolLine(state) {
4228
5208
  title = `${initial} \xB7 ${latest}`;
4229
5209
  }
4230
5210
  return {
4231
- prefix: " ",
4232
- body: `${toolStatusIcon(state.status)} ${title}`,
5211
+ prefix: ` ${toolStatusIcon(state.status)} `,
5212
+ prefixStyle: toolIconStyle(state.status),
5213
+ body: title,
4233
5214
  bodyStyle: toolStatusStyle(state.status)
4234
5215
  };
4235
5216
  }
@@ -4254,6 +5235,22 @@ function toolStatusIcon(status) {
4254
5235
  return "\u25D0";
4255
5236
  }
4256
5237
  }
5238
+ function toolIconStyle(status) {
5239
+ switch (status) {
5240
+ case "completed":
5241
+ case "succeeded":
5242
+ case "ok":
5243
+ return "tool-status-ok";
5244
+ case "failed":
5245
+ case "error":
5246
+ case "rejected":
5247
+ return "tool-status-fail";
5248
+ case "cancelled":
5249
+ return "tool-status-cancelled";
5250
+ default:
5251
+ return "tool-status-running";
5252
+ }
5253
+ }
4257
5254
  function formatPlan(event) {
4258
5255
  if (event.entries.length === 0) {
4259
5256
  return [
@@ -4333,23 +5330,32 @@ var init_format = __esm({
4333
5330
  });
4334
5331
 
4335
5332
  // src/tui/app.ts
5333
+ import { appendFileSync, statSync, renameSync } from "fs";
4336
5334
  import { nanoid as nanoid3 } from "nanoid";
4337
5335
  import termkit from "terminal-kit";
4338
5336
  async function runTuiApp(opts) {
4339
5337
  const config = await ensureConfig();
4340
5338
  await ensureDaemonReachable(config);
4341
5339
  const term = termkit.terminal;
5340
+ const exitHint = {};
4342
5341
  let nextOpts = opts;
4343
5342
  while (nextOpts !== null) {
4344
- nextOpts = await runSession(term, config, nextOpts);
5343
+ nextOpts = await runSession(term, config, nextOpts, exitHint);
5344
+ }
5345
+ if (exitHint.sessionId) {
5346
+ const short = stripHydraSessionPrefix(exitHint.sessionId);
5347
+ process.stdout.write(`To resume: hydra-acp tui --resume ${short}
5348
+ `);
4345
5349
  }
4346
5350
  }
4347
- async function runSession(term, config, opts) {
5351
+ async function runSession(term, config, opts, exitHint) {
4348
5352
  const ctx = await resolveSession(term, config, opts);
4349
5353
  if (!ctx) {
4350
5354
  term.grabInput(false);
4351
5355
  process.exit(0);
4352
5356
  }
5357
+ const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
5358
+ term.cyan(launchLabel)("\n");
4353
5359
  const protocol = config.daemon.tls ? "wss" : "ws";
4354
5360
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
4355
5361
  const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
@@ -4396,8 +5402,9 @@ async function runSession(term, config, opts) {
4396
5402
  const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
4397
5403
  if (before === 0 && pendingTurns > 0) {
4398
5404
  sessionBusySince = Date.now();
5405
+ dispatcherRef?.setTurnRunning(true);
4399
5406
  if (screenReady) {
4400
- screenRef.setBanner({ status: "running", elapsedMs: 0 });
5407
+ screenRef.setBanner({ status: "busy", elapsedMs: 0 });
4401
5408
  }
4402
5409
  if (sessionElapsedTimer === null && screenReady) {
4403
5410
  sessionElapsedTimer = setInterval(() => {
@@ -4406,10 +5413,11 @@ async function runSession(term, config, opts) {
4406
5413
  }
4407
5414
  screenRef.setBanner({ elapsedMs: Date.now() - sessionBusySince });
4408
5415
  renderToolsBlock();
4409
- }, 5e3);
5416
+ }, 1e3);
4410
5417
  }
4411
5418
  } else if (before > 0 && pendingTurns === 0) {
4412
5419
  sessionBusySince = null;
5420
+ dispatcherRef?.setTurnRunning(false);
4413
5421
  if (sessionElapsedTimer !== null) {
4414
5422
  clearInterval(sessionElapsedTimer);
4415
5423
  sessionElapsedTimer = null;
@@ -4423,9 +5431,11 @@ async function runSession(term, config, opts) {
4423
5431
  }
4424
5432
  };
4425
5433
  let screenRef = null;
5434
+ let dispatcherRef = null;
4426
5435
  conn.onNotification("session/update", (params) => {
4427
5436
  const { update } = params ?? {};
4428
5437
  const event = mapUpdate(update);
5438
+ debugLogUpdate(update, event);
4429
5439
  if (event?.kind === "user-text") {
4430
5440
  adjustPendingTurns(1);
4431
5441
  } else if (event?.kind === "turn-complete") {
@@ -4446,10 +5456,10 @@ async function runSession(term, config, opts) {
4446
5456
  if (pendingPermission.toolCallId && toolCallId && pendingPermission.toolCallId !== toolCallId) {
4447
5457
  return;
4448
5458
  }
4449
- const resolve2 = pendingPermission.resolve;
5459
+ const resolve4 = pendingPermission.resolve;
4450
5460
  pendingPermission = null;
4451
5461
  screen.setPermissionPrompt(null);
4452
- resolve2(result ?? { outcome: { outcome: "cancelled" } });
5462
+ resolve4(result ?? { outcome: { outcome: "cancelled" } });
4453
5463
  };
4454
5464
  const maybeDismissPermissionByToolUpdate = (update) => {
4455
5465
  if (!pendingPermission?.toolCallId) {
@@ -4482,14 +5492,14 @@ async function runSession(term, config, opts) {
4482
5492
  if (!pendingPermission) {
4483
5493
  return;
4484
5494
  }
4485
- const { options, resolve: resolve2 } = pendingPermission;
5495
+ const { options, resolve: resolve4 } = pendingPermission;
4486
5496
  pendingPermission = null;
4487
5497
  screen.setPermissionPrompt(null);
4488
5498
  if (optionId === null) {
4489
- resolve2({ outcome: { outcome: "cancelled" } });
5499
+ resolve4({ outcome: { outcome: "cancelled" } });
4490
5500
  return;
4491
5501
  }
4492
- resolve2({ outcome: { outcome: "selected", optionId } });
5502
+ resolve4({ outcome: { outcome: "selected", optionId } });
4493
5503
  void options;
4494
5504
  };
4495
5505
  conn.onRequest("session/request_permission", async (params) => {
@@ -4507,12 +5517,12 @@ async function runSession(term, config, opts) {
4507
5517
  ]);
4508
5518
  return { outcome: { outcome: "cancelled" } };
4509
5519
  }
4510
- return new Promise((resolve2) => {
5520
+ return new Promise((resolve4) => {
4511
5521
  pendingPermission = {
4512
5522
  title,
4513
5523
  options,
4514
5524
  selectedIndex: 0,
4515
- resolve: resolve2,
5525
+ resolve: resolve4,
4516
5526
  toolCallId
4517
5527
  };
4518
5528
  refreshPermissionPrompt();
@@ -4542,6 +5552,7 @@ async function runSession(term, config, opts) {
4542
5552
  let initialModel;
4543
5553
  let initialMode;
4544
5554
  let initialCommands;
5555
+ let initialTurnStartedAt;
4545
5556
  if (ctx.sessionId === "__new__") {
4546
5557
  const created = await conn.request("session/new", {
4547
5558
  cwd: ctx.cwd,
@@ -4549,6 +5560,7 @@ async function runSession(term, config, opts) {
4549
5560
  ...opts.name ? { _meta: { [HYDRA_META_KEY]: { name: opts.name } } } : {}
4550
5561
  });
4551
5562
  resolvedSessionId = created.sessionId;
5563
+ exitHint.sessionId = resolvedSessionId;
4552
5564
  const hydraMeta = extractHydraMeta(created._meta ?? void 0);
4553
5565
  upstreamSessionId = hydraMeta.upstreamSessionId;
4554
5566
  if (hydraMeta.agentId) {
@@ -4562,6 +5574,7 @@ async function runSession(term, config, opts) {
4562
5574
  }
4563
5575
  initialModel = hydraMeta.currentModel;
4564
5576
  initialMode = hydraMeta.currentMode;
5577
+ initialTurnStartedAt = hydraMeta.turnStartedAt;
4565
5578
  if (hydraMeta.availableCommands) {
4566
5579
  initialCommands = hydraMeta.availableCommands.map(
4567
5580
  (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
@@ -4574,6 +5587,7 @@ async function runSession(term, config, opts) {
4574
5587
  clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
4575
5588
  });
4576
5589
  resolvedSessionId = attached.sessionId;
5590
+ exitHint.sessionId = resolvedSessionId;
4577
5591
  const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
4578
5592
  upstreamSessionId = hydraMeta.upstreamSessionId;
4579
5593
  if (hydraMeta.agentId) {
@@ -4587,20 +5601,26 @@ async function runSession(term, config, opts) {
4587
5601
  }
4588
5602
  initialModel = hydraMeta.currentModel;
4589
5603
  initialMode = hydraMeta.currentMode;
5604
+ initialTurnStartedAt = hydraMeta.turnStartedAt;
4590
5605
  if (hydraMeta.availableCommands) {
4591
5606
  initialCommands = hydraMeta.availableCommands.map(
4592
5607
  (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
4593
5608
  );
4594
5609
  }
4595
5610
  }
4596
- const historyFile = paths.tuiHistoryFile();
5611
+ const historyFile = paths.tuiHistoryFile(resolvedSessionId);
4597
5612
  let history = await loadHistory(historyFile).catch(() => []);
4598
5613
  const dispatcher = new InputDispatcher({ history });
5614
+ dispatcherRef = dispatcher;
5615
+ if (pendingTurns > 0) {
5616
+ dispatcher.setTurnRunning(true);
5617
+ }
4599
5618
  let turnInFlight = null;
4600
5619
  const screen = new Screen({
4601
5620
  term,
4602
5621
  dispatcher,
4603
5622
  repaintThrottleMs: config.tui.repaintThrottleMs,
5623
+ maxScrollbackLines: config.tui.maxScrollbackLines,
4604
5624
  onKey: (events) => {
4605
5625
  for (const ev of events) {
4606
5626
  if (pendingPermission && tryHandlePermissionKey(ev)) {
@@ -4764,14 +5784,21 @@ async function runSession(term, config, opts) {
4764
5784
  screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
4765
5785
  }
4766
5786
  let finishSession = null;
4767
- const sessionDone = new Promise((resolve2) => {
4768
- finishSession = resolve2;
5787
+ const sessionDone = new Promise((resolve4) => {
5788
+ finishSession = resolve4;
4769
5789
  });
5790
+ const cancelRemoteTurn = () => {
5791
+ conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
5792
+ };
4770
5793
  const sigintHandler = () => {
4771
5794
  if (turnInFlight) {
4772
5795
  turnInFlight.cancel();
4773
5796
  return;
4774
5797
  }
5798
+ if (pendingTurns > 0) {
5799
+ cancelRemoteTurn();
5800
+ return;
5801
+ }
4775
5802
  void requestExit();
4776
5803
  };
4777
5804
  let exitConfirmation = null;
@@ -4846,6 +5873,7 @@ async function runSession(term, config, opts) {
4846
5873
  };
4847
5874
  const teardown = () => {
4848
5875
  process.off("SIGINT", sigintHandler);
5876
+ screen.clearWindowTitle();
4849
5877
  screen.stop();
4850
5878
  saveHistory(historyFile, history).catch(() => void 0);
4851
5879
  void stream.close().catch(() => void 0);
@@ -4861,22 +5889,32 @@ async function runSession(term, config, opts) {
4861
5889
  }
4862
5890
  };
4863
5891
  const switchSession = async () => {
4864
- const resume = finishSession;
4865
- if (!resume) {
5892
+ if (!finishSession) {
4866
5893
  return;
4867
5894
  }
4868
- finishSession = null;
4869
- teardown();
5895
+ const pendingDraft = dispatcher.state().buffer.join("\n");
5896
+ if (pendingDraft.replace(/\s+$/, "").length > 0) {
5897
+ history = appendEntry(history, pendingDraft);
5898
+ dispatcher.setHistory(history);
5899
+ }
5900
+ screen.pauseRepaint();
5901
+ screen.stop();
5902
+ saveHistory(historyFile, history).catch(() => void 0);
4870
5903
  const sessions = await listSessions(config);
4871
5904
  const choice = await pickSession(term, {
4872
5905
  cwd: resolvedCwd,
4873
5906
  sessions,
4874
- coldLimit: config.sessionListColdLimit
5907
+ config
4875
5908
  });
4876
5909
  if (choice.kind === "abort") {
4877
- resume({ ...opts, sessionId: resolvedSessionId, cwd: resolvedCwd });
5910
+ screen.start();
5911
+ screen.resumeRepaint();
4878
5912
  return;
4879
5913
  }
5914
+ const resume = finishSession;
5915
+ finishSession = null;
5916
+ process.off("SIGINT", sigintHandler);
5917
+ void stream.close().catch(() => void 0);
4880
5918
  if (choice.kind === "new") {
4881
5919
  const { sessionId: _drop, ...rest } = opts;
4882
5920
  void _drop;
@@ -4901,6 +5939,8 @@ async function runSession(term, config, opts) {
4901
5939
  case "cancel":
4902
5940
  if (turnInFlight) {
4903
5941
  turnInFlight.cancel();
5942
+ } else if (pendingTurns > 0) {
5943
+ cancelRemoteTurn();
4904
5944
  }
4905
5945
  if (promptQueue.length > (workerActive ? 1 : 0)) {
4906
5946
  promptQueue.length = workerActive ? 1 : 0;
@@ -4917,7 +5957,7 @@ async function runSession(term, config, opts) {
4917
5957
  screen.setBanner({});
4918
5958
  return;
4919
5959
  case "redraw":
4920
- screen.redraw();
5960
+ screen.fullRedraw();
4921
5961
  return;
4922
5962
  case "switch-session":
4923
5963
  void switchSession();
@@ -5088,7 +6128,6 @@ async function runSession(term, config, opts) {
5088
6128
  const promptArr = planMode ? [{ type: "text", text: PLAN_PREFIX_TEXT }, ...userBlocks] : userBlocks;
5089
6129
  adjustPendingTurns(1);
5090
6130
  appendRender({ kind: "user-text", text });
5091
- dispatcher.setTurnRunning(true);
5092
6131
  let cancelled = false;
5093
6132
  turnInFlight = {
5094
6133
  cancel: () => {
@@ -5118,7 +6157,6 @@ async function runSession(term, config, opts) {
5118
6157
  });
5119
6158
  } finally {
5120
6159
  turnInFlight = null;
5121
- dispatcher.setTurnRunning(false);
5122
6160
  adjustPendingTurns(-1);
5123
6161
  appendRender(
5124
6162
  stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" }
@@ -5174,7 +6212,7 @@ async function runSession(term, config, opts) {
5174
6212
  const elapsed = end - toolsBlockStartedAt;
5175
6213
  let summary;
5176
6214
  if (total === 0) {
5177
- summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `no tools \xB7 took ${formatElapsed(elapsed)}`;
6215
+ summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
5178
6216
  } else {
5179
6217
  const noun = total === 1 ? "tool" : "tools";
5180
6218
  const timing = inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
@@ -5245,6 +6283,10 @@ async function runSession(term, config, opts) {
5245
6283
  if (event.title !== void 0) {
5246
6284
  screen.setHeader({ title: event.title });
5247
6285
  }
6286
+ if (event.agentId !== void 0 && event.agentId !== resolvedAgentId) {
6287
+ resolvedAgentId = event.agentId;
6288
+ screen.setHeader({ agent: event.agentId });
6289
+ }
5248
6290
  return;
5249
6291
  }
5250
6292
  if (event.kind === "usage-update") {
@@ -5277,6 +6319,12 @@ async function runSession(term, config, opts) {
5277
6319
  if (formatted2.length > 0) {
5278
6320
  screen.appendLines(formatted2);
5279
6321
  }
6322
+ screen.clearKey("tools");
6323
+ screen.clearKey("plan");
6324
+ toolStates.clear();
6325
+ toolCallOrder.length = 0;
6326
+ toolsExpanded = false;
6327
+ toolsBlockEndedAt = null;
5280
6328
  startToolsBlock();
5281
6329
  screen.redraw();
5282
6330
  return;
@@ -5317,12 +6365,10 @@ async function runSession(term, config, opts) {
5317
6365
  if (event.kind === "turn-complete") {
5318
6366
  closeAgentText();
5319
6367
  screen.clearKey("plan");
5320
- if (toolCallOrder.length > 0) {
6368
+ if (toolsBlockStartedAt !== null) {
5321
6369
  toolsBlockEndedAt = Date.now();
5322
6370
  renderToolsBlock();
5323
6371
  screen.clearKey("tools");
5324
- } else if (toolsBlockStartedAt !== null) {
5325
- screen.removeBlock("tools");
5326
6372
  }
5327
6373
  toolStates.clear();
5328
6374
  toolCallOrder.length = 0;
@@ -5342,22 +6388,35 @@ async function runSession(term, config, opts) {
5342
6388
  } finally {
5343
6389
  screen.resumeRepaint();
5344
6390
  }
6391
+ if (initialTurnStartedAt !== void 0 && pendingTurns > 0) {
6392
+ sessionBusySince = initialTurnStartedAt;
6393
+ screen.setBanner({
6394
+ status: "busy",
6395
+ elapsedMs: Date.now() - initialTurnStartedAt
6396
+ });
6397
+ if (sessionElapsedTimer === null) {
6398
+ sessionElapsedTimer = setInterval(() => {
6399
+ if (sessionBusySince === null || screenRef === null) {
6400
+ return;
6401
+ }
6402
+ screenRef.setBanner({ elapsedMs: Date.now() - sessionBusySince });
6403
+ renderToolsBlock();
6404
+ }, 1e3);
6405
+ }
6406
+ startToolsBlock();
6407
+ }
5345
6408
  const resetInFlightUiState = () => {
5346
6409
  if (pendingPermission) {
5347
- const resolve2 = pendingPermission.resolve;
6410
+ const resolve4 = pendingPermission.resolve;
5348
6411
  pendingPermission = null;
5349
6412
  screen.setPermissionPrompt(null);
5350
- resolve2({ outcome: { outcome: "cancelled" } });
6413
+ resolve4({ outcome: { outcome: "cancelled" } });
5351
6414
  }
5352
6415
  closeAgentText();
5353
6416
  if (toolsBlockStartedAt !== null) {
5354
- if (toolCallOrder.length > 0) {
5355
- toolsBlockEndedAt = Date.now();
5356
- renderToolsBlock();
5357
- screen.clearKey("tools");
5358
- } else {
5359
- screen.removeBlock("tools");
5360
- }
6417
+ toolsBlockEndedAt = Date.now();
6418
+ renderToolsBlock();
6419
+ screen.clearKey("tools");
5361
6420
  toolStates.clear();
5362
6421
  toolCallOrder.length = 0;
5363
6422
  toolsBlockStartedAt = null;
@@ -5427,7 +6486,7 @@ async function runSession(term, config, opts) {
5427
6486
  ]);
5428
6487
  }
5429
6488
  screen.setBanner({
5430
- status: pendingTurns > 0 ? "running" : "ready",
6489
+ status: pendingTurns > 0 ? "busy" : "ready",
5431
6490
  elapsedMs: pendingTurns > 0 ? 0 : void 0
5432
6491
  });
5433
6492
  };
@@ -5475,7 +6534,7 @@ async function resolveSession(term, config, opts) {
5475
6534
  const choice = await pickSession(term, {
5476
6535
  cwd,
5477
6536
  sessions,
5478
- coldLimit: config.sessionListColdLimit
6537
+ config
5479
6538
  });
5480
6539
  if (choice.kind === "abort") {
5481
6540
  return null;
@@ -5496,7 +6555,41 @@ function newCtx(opts, cwd, config) {
5496
6555
  cwd
5497
6556
  };
5498
6557
  }
5499
- var PLAN_PREFIX_TEXT;
6558
+ function debugLogUpdate(update, event) {
6559
+ writeDebugLine({
6560
+ src: "session/update",
6561
+ update,
6562
+ event: event === null ? null : { kind: event.kind }
6563
+ });
6564
+ }
6565
+ function writeDebugLine(payload) {
6566
+ const override = process.env.HYDRA_TUI_DEBUG_LOG;
6567
+ const target = override === void 0 ? paths.tuiLogFile() : override;
6568
+ if (target.length === 0) {
6569
+ return;
6570
+ }
6571
+ try {
6572
+ rotateIfBig(target);
6573
+ const line = JSON.stringify({
6574
+ t: (/* @__PURE__ */ new Date()).toISOString(),
6575
+ ...payload
6576
+ });
6577
+ appendFileSync(target, `${line}
6578
+ `);
6579
+ } catch {
6580
+ }
6581
+ }
6582
+ function rotateIfBig(target) {
6583
+ try {
6584
+ const stat3 = statSync(target);
6585
+ if (stat3.size < TUI_LOG_MAX_BYTES) {
6586
+ return;
6587
+ }
6588
+ renameSync(target, `${target}.0`);
6589
+ } catch {
6590
+ }
6591
+ }
6592
+ var PLAN_PREFIX_TEXT, TUI_LOG_MAX_BYTES;
5500
6593
  var init_app = __esm({
5501
6594
  "src/tui/app.ts"() {
5502
6595
  "use strict";
@@ -5505,6 +6598,7 @@ var init_app = __esm({
5505
6598
  init_resilient_ws();
5506
6599
  init_config();
5507
6600
  init_daemon_bootstrap();
6601
+ init_session();
5508
6602
  init_paths();
5509
6603
  init_history();
5510
6604
  init_discovery();
@@ -5514,6 +6608,7 @@ var init_app = __esm({
5514
6608
  init_render_update();
5515
6609
  init_format();
5516
6610
  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.";
6611
+ TUI_LOG_MAX_BYTES = 5 * 1024 * 1024;
5517
6612
  }
5518
6613
  });
5519
6614
 
@@ -5529,6 +6624,11 @@ var init_tui = __esm({
5529
6624
  }
5530
6625
  });
5531
6626
 
6627
+ // src/cli.ts
6628
+ import { readFileSync } from "fs";
6629
+ import { fileURLToPath } from "url";
6630
+ import { dirname as dirname3, resolve as resolve3 } from "path";
6631
+
5532
6632
  // src/cli/parse-args.ts
5533
6633
  function parseArgs(argv) {
5534
6634
  const positional = [];
@@ -5598,8 +6698,7 @@ async function runInit(flags) {
5598
6698
  existing = void 0;
5599
6699
  }
5600
6700
  if (!existing) {
5601
- const config = defaultConfig();
5602
- await writeConfig(config);
6701
+ const config = await writeMinimalInitConfig();
5603
6702
  process.stdout.write(
5604
6703
  `Initialized ${paths.config()}
5605
6704
  Auth token: ${config.daemon.authToken}
@@ -5608,11 +6707,14 @@ Auth token: ${config.daemon.authToken}
5608
6707
  return;
5609
6708
  }
5610
6709
  if (flagBool(flags, "rotate-token")) {
5611
- existing.daemon.authToken = generateAuthToken();
5612
- await writeConfig(existing);
6710
+ const newToken = generateAuthToken();
6711
+ await updateConfigField((raw) => {
6712
+ const daemon = raw.daemon ??= {};
6713
+ daemon.authToken = newToken;
6714
+ });
5613
6715
  process.stdout.write(
5614
6716
  `Rotated token in ${paths.config()}
5615
- New token: ${existing.daemon.authToken}
6717
+ New token: ${newToken}
5616
6718
  `
5617
6719
  );
5618
6720
  return;
@@ -5630,7 +6732,7 @@ import { setTimeout as sleep2 } from "timers/promises";
5630
6732
 
5631
6733
  // src/daemon/server.ts
5632
6734
  init_config();
5633
- import * as fs7 from "fs";
6735
+ import * as fs9 from "fs";
5634
6736
  import * as fsp2 from "fs/promises";
5635
6737
  import Fastify from "fastify";
5636
6738
  import websocketPlugin from "@fastify/websocket";
@@ -5802,6 +6904,10 @@ function planSpawn(agent, extraArgs = []) {
5802
6904
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
5803
6905
  }
5804
6906
 
6907
+ // src/core/session-manager.ts
6908
+ import * as fs7 from "fs/promises";
6909
+ import { customAlphabet as customAlphabet3 } from "nanoid";
6910
+
5805
6911
  // src/core/agent-instance.ts
5806
6912
  import { spawn } from "child_process";
5807
6913
 
@@ -5859,13 +6965,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
5859
6965
  throw new Error("stream is closed");
5860
6966
  }
5861
6967
  const line = JSON.stringify(message) + "\n";
5862
- await new Promise((resolve2, reject) => {
6968
+ await new Promise((resolve4, reject) => {
5863
6969
  stdin.write(line, (err) => {
5864
6970
  if (err) {
5865
6971
  reject(err);
5866
6972
  return;
5867
6973
  }
5868
- resolve2();
6974
+ resolve4();
5869
6975
  });
5870
6976
  });
5871
6977
  },
@@ -5946,7 +7052,14 @@ init_session();
5946
7052
  init_paths();
5947
7053
  import * as fs4 from "fs/promises";
5948
7054
  import * as path2 from "path";
7055
+ import { customAlphabet as customAlphabet2 } from "nanoid";
5949
7056
  import { z as z4 } from "zod";
7057
+ var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7058
+ var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
7059
+ var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
7060
+ function generateLineageId() {
7061
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
7062
+ }
5950
7063
  var PersistedAgentCommand = z4.object({
5951
7064
  name: z4.string(),
5952
7065
  description: z4.string().optional()
@@ -5954,7 +7067,20 @@ var PersistedAgentCommand = z4.object({
5954
7067
  var SessionRecord = z4.object({
5955
7068
  version: z4.literal(1),
5956
7069
  sessionId: z4.string(),
7070
+ // Optional for back-compat with records written before this field
7071
+ // existed; mergeForPersistence generates one on next write so any
7072
+ // touched session converges to having a lineageId. A record that
7073
+ // never gets written again (truly cold and untouched) just won't
7074
+ // participate in lineage-based dedup, which is correct — it was
7075
+ // never exported, so no incoming bundle can claim its lineage.
7076
+ lineageId: z4.string().optional(),
5957
7077
  upstreamSessionId: z4.string(),
7078
+ // When non-empty, marks a session that was created by import and is
7079
+ // waiting for its first attach to bootstrap a fresh upstream agent
7080
+ // and replay the imported history as a takeover transcript. The
7081
+ // origin's local id at export time, kept for debuggability and as a
7082
+ // breadcrumb in `sessions list` (informational, not used for routing).
7083
+ importedFromSessionId: z4.string().optional(),
5958
7084
  agentId: z4.string(),
5959
7085
  cwd: z4.string(),
5960
7086
  title: z4.string().optional(),
@@ -6027,6 +7153,25 @@ var SessionStore = class {
6027
7153
  }
6028
7154
  }
6029
7155
  }
7156
+ // Find a persisted session by lineageId. Used by SessionManager.import
7157
+ // to detect bundles that have already been imported (lineageId match)
7158
+ // so we can either error out or, with replace:true, overwrite.
7159
+ // Returns undefined if no record has that lineageId. Records that
7160
+ // pre-date the lineageId field simply don't match — which is
7161
+ // correct: they were never exported, so no incoming bundle can
7162
+ // legitimately claim their lineage.
7163
+ async findByLineageId(lineageId) {
7164
+ if (lineageId.length === 0) {
7165
+ return void 0;
7166
+ }
7167
+ const all = await this.list().catch(() => []);
7168
+ for (const record of all) {
7169
+ if (record.lineageId === lineageId) {
7170
+ return record;
7171
+ }
7172
+ }
7173
+ return void 0;
7174
+ }
6030
7175
  async list() {
6031
7176
  let entries;
6032
7177
  try {
@@ -6052,7 +7197,9 @@ function recordFromMemorySession(args) {
6052
7197
  const now = (/* @__PURE__ */ new Date()).toISOString();
6053
7198
  return {
6054
7199
  sessionId: args.sessionId,
7200
+ lineageId: args.lineageId,
6055
7201
  upstreamSessionId: args.upstreamSessionId,
7202
+ importedFromSessionId: args.importedFromSessionId,
6056
7203
  agentId: args.agentId,
6057
7204
  cwd: args.cwd,
6058
7205
  title: args.title,
@@ -6101,6 +7248,36 @@ var HistoryStore = class {
6101
7248
  });
6102
7249
  });
6103
7250
  }
7251
+ // Trim the on-disk history file to the most recent maxEntries lines.
7252
+ // Runs through the same per-session write queue as append/rewrite so
7253
+ // it's safe to invoke alongside ongoing writes; a no-op if the file is
7254
+ // already at or below the cap.
7255
+ async compact(sessionId, maxEntries) {
7256
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
7257
+ return;
7258
+ }
7259
+ return this.enqueue(sessionId, async () => {
7260
+ let raw;
7261
+ try {
7262
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
7263
+ } catch (err) {
7264
+ const e = err;
7265
+ if (e.code === "ENOENT") {
7266
+ return;
7267
+ }
7268
+ throw err;
7269
+ }
7270
+ const lines = raw.split("\n").filter((l) => l.length > 0);
7271
+ if (lines.length <= maxEntries) {
7272
+ return;
7273
+ }
7274
+ const trimmed = lines.slice(-maxEntries);
7275
+ await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
7276
+ encoding: "utf8",
7277
+ mode: 384
7278
+ });
7279
+ });
7280
+ }
6104
7281
  async load(sessionId) {
6105
7282
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
6106
7283
  return [];
@@ -6189,7 +7366,11 @@ var HistoryStore = class {
6189
7366
  };
6190
7367
 
6191
7368
  // src/core/session-manager.ts
7369
+ init_paths();
7370
+ init_history();
6192
7371
  init_types();
7372
+ var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7373
+ var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
6193
7374
  var SessionManager = class {
6194
7375
  constructor(registry, spawner, store, options = {}) {
6195
7376
  this.registry = registry;
@@ -6268,6 +7449,9 @@ var SessionManager = class {
6268
7449
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
6269
7450
  throw err;
6270
7451
  }
7452
+ if (params.upstreamSessionId === "") {
7453
+ return this.doResurrectFromImport(params);
7454
+ }
6271
7455
  const plan = planSpawn(agentDef, params.agentArgs ?? []);
6272
7456
  const agent = this.spawner({
6273
7457
  agentId: params.agentId,
@@ -6304,12 +7488,53 @@ var SessionManager = class {
6304
7488
  idleTimeoutMs: this.idleTimeoutMs,
6305
7489
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
6306
7490
  historyStore: this.histories,
6307
- seedHistory: params.seedHistory,
6308
7491
  currentModel: params.currentModel,
6309
7492
  currentMode: params.currentMode,
6310
- agentCommands: params.agentCommands
7493
+ agentCommands: params.agentCommands,
7494
+ // Only gate the first-prompt title heuristic when we actually have
7495
+ // a title to preserve. A title-less session (lost to a write race
7496
+ // or never seeded) should re-derive from the next prompt rather
7497
+ // than stay stuck.
7498
+ firstPromptSeeded: !!params.title,
7499
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
7500
+ });
7501
+ await this.attachManagerHooks(session);
7502
+ return session;
7503
+ }
7504
+ // First-attach path for a session that was created via import(). The
7505
+ // on-disk meta.json carries upstreamSessionId="" as the import
7506
+ // marker; bootstrap a fresh agent (gets a real upstream id) and kick
7507
+ // off seedFromImport so the agent absorbs the historical transcript.
7508
+ // attachManagerHooks rewrites meta.json with the new upstreamSessionId,
7509
+ // so subsequent resurrects of this session use the normal session/load
7510
+ // path.
7511
+ async doResurrectFromImport(params) {
7512
+ const fresh = await this.bootstrapAgent({
7513
+ agentId: params.agentId,
7514
+ cwd: params.cwd,
7515
+ agentArgs: params.agentArgs,
7516
+ mcpServers: []
7517
+ });
7518
+ const session = new Session({
7519
+ sessionId: params.hydraSessionId,
7520
+ cwd: params.cwd,
7521
+ agentId: params.agentId,
7522
+ agent: fresh.agent,
7523
+ upstreamSessionId: fresh.upstreamSessionId,
7524
+ agentMeta: fresh.agentMeta,
7525
+ title: params.title,
7526
+ agentArgs: params.agentArgs,
7527
+ idleTimeoutMs: this.idleTimeoutMs,
7528
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7529
+ historyStore: this.histories,
7530
+ currentModel: params.currentModel,
7531
+ currentMode: params.currentMode,
7532
+ agentCommands: params.agentCommands,
7533
+ firstPromptSeeded: !!params.title,
7534
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
6311
7535
  });
6312
7536
  await this.attachManagerHooks(session);
7537
+ void session.seedFromImport().catch(() => void 0);
6313
7538
  return session;
6314
7539
  }
6315
7540
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
@@ -6391,28 +7616,20 @@ var SessionManager = class {
6391
7616
  }).catch(() => void 0);
6392
7617
  });
6393
7618
  this.sessions.set(session.sessionId, session);
6394
- await this.store.write(
6395
- recordFromMemorySession({
6396
- sessionId: session.sessionId,
6397
- upstreamSessionId: session.upstreamSessionId,
6398
- agentId: session.agentId,
6399
- cwd: session.cwd,
6400
- title: session.title,
6401
- agentArgs: session.agentArgs,
6402
- currentModel: session.currentModel,
6403
- currentMode: session.currentMode
6404
- })
6405
- ).catch(() => void 0);
7619
+ await this.enqueueMetaWrite(session.sessionId, async () => {
7620
+ const existing = await this.store.read(session.sessionId);
7621
+ const merged = mergeForPersistence(session, existing);
7622
+ await this.store.write(merged);
7623
+ }).catch(() => void 0);
6406
7624
  }
6407
7625
  // Resolve a session's recorded history without forcing a resurrect.
6408
- // Returns the in-memory snapshot if the session is hot, falls back
6409
- // to the on-disk history file otherwise. Returns undefined if the
6410
- // session id is unknown to both the live map and disk store, so the
6411
- // caller can distinguish "no history yet" (empty array) from "404".
7626
+ // Always loads from disk that's the source of truth whether the
7627
+ // session is hot or cold. Returns undefined if the session id is
7628
+ // unknown to both the live map and disk store, so the caller can
7629
+ // distinguish "no history yet" (empty array) from "404".
6412
7630
  async getHistory(sessionId) {
6413
- const live = this.sessions.get(sessionId);
6414
- if (live) {
6415
- return live.getHistorySnapshot();
7631
+ if (this.sessions.has(sessionId)) {
7632
+ return this.histories.load(sessionId).catch(() => []);
6416
7633
  }
6417
7634
  const record = await this.store.read(sessionId);
6418
7635
  if (!record) {
@@ -6425,20 +7642,41 @@ var SessionManager = class {
6425
7642
  if (!record) {
6426
7643
  return void 0;
6427
7644
  }
6428
- const seedHistory = await this.histories.load(sessionId).catch(() => []);
7645
+ let title = record.title;
7646
+ if (!title) {
7647
+ title = await this.deriveTitleFromHistory(sessionId);
7648
+ }
6429
7649
  return {
6430
7650
  hydraSessionId: record.sessionId,
6431
7651
  upstreamSessionId: record.upstreamSessionId,
6432
7652
  agentId: record.agentId,
6433
7653
  cwd: record.cwd,
6434
- title: record.title,
7654
+ title,
6435
7655
  agentArgs: record.agentArgs,
6436
- seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
6437
7656
  currentModel: record.currentModel,
6438
7657
  currentMode: record.currentMode,
6439
- agentCommands: record.agentCommands
7658
+ agentCommands: record.agentCommands,
7659
+ createdAt: record.createdAt
6440
7660
  };
6441
7661
  }
7662
+ // Best-effort: peek at the persisted history's first prompt and use
7663
+ // its first line (capped to 200 chars) as a session title. Returns
7664
+ // undefined if no usable prompt is found or any I/O fails.
7665
+ async deriveTitleFromHistory(sessionId) {
7666
+ const history = await this.histories.load(sessionId).catch(() => []);
7667
+ for (const entry of history) {
7668
+ const params = entry.params;
7669
+ if (params?.update?.sessionUpdate !== "prompt_received") {
7670
+ continue;
7671
+ }
7672
+ const text = extractPromptText(params.update.prompt);
7673
+ const line = firstLine(text, 200);
7674
+ if (line) {
7675
+ return line;
7676
+ }
7677
+ }
7678
+ return void 0;
7679
+ }
6442
7680
  get(sessionId) {
6443
7681
  return this.sessions.get(sessionId);
6444
7682
  }
@@ -6478,13 +7716,14 @@ var SessionManager = class {
6478
7716
  continue;
6479
7717
  }
6480
7718
  liveIds.add(session.sessionId);
7719
+ const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
6481
7720
  entries.push({
6482
7721
  sessionId: session.sessionId,
6483
7722
  upstreamSessionId: session.upstreamSessionId,
6484
7723
  cwd: session.cwd,
6485
7724
  title: session.title,
6486
7725
  agentId: session.agentId,
6487
- updatedAt: new Date(session.updatedAt).toISOString(),
7726
+ updatedAt: used,
6488
7727
  attachedClients: session.attachedCount,
6489
7728
  status: "live"
6490
7729
  });
@@ -6497,13 +7736,14 @@ var SessionManager = class {
6497
7736
  if (filter.cwd && r.cwd !== filter.cwd) {
6498
7737
  continue;
6499
7738
  }
7739
+ const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
6500
7740
  entries.push({
6501
7741
  sessionId: r.sessionId,
6502
7742
  upstreamSessionId: r.upstreamSessionId,
6503
7743
  cwd: r.cwd,
6504
7744
  title: r.title,
6505
7745
  agentId: r.agentId,
6506
- updatedAt: r.updatedAt,
7746
+ updatedAt: used,
6507
7747
  attachedClients: 0,
6508
7748
  status: "cold"
6509
7749
  });
@@ -6511,6 +7751,111 @@ var SessionManager = class {
6511
7751
  entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
6512
7752
  return entries;
6513
7753
  }
7754
+ // Build an export bundle for a session, reading meta + history from
7755
+ // disk. Backfills lineageId if the on-disk record pre-dates that
7756
+ // field. Returns undefined if the session doesn't exist. Callers
7757
+ // populate the bundle's exportedFrom metadata themselves.
7758
+ async exportBundle(sessionId) {
7759
+ const record = await this.store.read(sessionId);
7760
+ if (!record) {
7761
+ return void 0;
7762
+ }
7763
+ let withLineage;
7764
+ if (record.lineageId) {
7765
+ withLineage = record;
7766
+ } else {
7767
+ const lineageId = generateLineageId();
7768
+ const backfilled = { ...record, lineageId };
7769
+ await this.enqueueMetaWrite(sessionId, async () => {
7770
+ const latest = await this.store.read(sessionId);
7771
+ if (!latest) {
7772
+ return;
7773
+ }
7774
+ if (latest.lineageId) {
7775
+ return;
7776
+ }
7777
+ await this.store.write({ ...latest, lineageId });
7778
+ }).catch(() => void 0);
7779
+ withLineage = backfilled;
7780
+ }
7781
+ const history = await this.histories.load(sessionId).catch(() => []);
7782
+ const promptHistory = await loadPromptHistorySafely(sessionId);
7783
+ return { record: withLineage, history, promptHistory };
7784
+ }
7785
+ // Create a local session from an imported bundle. Without `replace`,
7786
+ // a bundle with a lineageId we already have on disk throws
7787
+ // BundleAlreadyImported citing the existing local id. With
7788
+ // `replace: true`, the existing record is overwritten in-place (its
7789
+ // local sessionId is preserved so bookmarks/Slack thread links still
7790
+ // resolve), and any live in-memory session is closed so the next
7791
+ // attach triggers the import-reseed path.
7792
+ async importBundle(bundle, opts = {}) {
7793
+ const existing = await this.store.findByLineageId(bundle.session.lineageId);
7794
+ if (existing) {
7795
+ if (!opts.replace) {
7796
+ const err = new Error(
7797
+ `bundle already imported as ${existing.sessionId}`
7798
+ );
7799
+ err.code = JsonRpcErrorCodes.BundleAlreadyImported;
7800
+ err.existingSessionId = existing.sessionId;
7801
+ throw err;
7802
+ }
7803
+ const live = this.sessions.get(existing.sessionId);
7804
+ if (live) {
7805
+ await live.close({ deleteRecord: false }).catch(() => void 0);
7806
+ }
7807
+ await this.writeImportedRecord({
7808
+ sessionId: existing.sessionId,
7809
+ bundle,
7810
+ preservedCreatedAt: existing.createdAt
7811
+ });
7812
+ return {
7813
+ sessionId: existing.sessionId,
7814
+ importedFromSessionId: bundle.session.sessionId,
7815
+ replaced: true
7816
+ };
7817
+ }
7818
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
7819
+ await this.writeImportedRecord({ sessionId: newId, bundle });
7820
+ return {
7821
+ sessionId: newId,
7822
+ importedFromSessionId: bundle.session.sessionId,
7823
+ replaced: false
7824
+ };
7825
+ }
7826
+ // Write the imported bundle's history.jsonl, prompt-history (if
7827
+ // present), and meta.json. upstreamSessionId is left empty as the
7828
+ // marker that the first attach should bootstrap a fresh agent and
7829
+ // run seedFromImport rather than calling session/load.
7830
+ async writeImportedRecord(args) {
7831
+ await this.histories.rewrite(
7832
+ args.sessionId,
7833
+ args.bundle.history
7834
+ );
7835
+ if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
7836
+ await saveHistory(
7837
+ paths.tuiHistoryFile(args.sessionId),
7838
+ args.bundle.promptHistory
7839
+ ).catch(() => void 0);
7840
+ }
7841
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7842
+ await this.enqueueMetaWrite(args.sessionId, async () => {
7843
+ await this.store.write({
7844
+ sessionId: args.sessionId,
7845
+ lineageId: args.bundle.session.lineageId,
7846
+ upstreamSessionId: "",
7847
+ importedFromSessionId: args.bundle.session.sessionId,
7848
+ agentId: args.bundle.session.agentId,
7849
+ cwd: args.bundle.session.cwd,
7850
+ title: args.bundle.session.title,
7851
+ currentModel: args.bundle.session.currentModel,
7852
+ currentMode: args.bundle.session.currentMode,
7853
+ agentCommands: args.bundle.session.agentCommands,
7854
+ createdAt: args.preservedCreatedAt ?? now,
7855
+ updatedAt: now
7856
+ });
7857
+ });
7858
+ }
6514
7859
  async deleteRecord(sessionId) {
6515
7860
  const record = await this.store.read(sessionId);
6516
7861
  if (!record) {
@@ -6519,6 +7864,10 @@ var SessionManager = class {
6519
7864
  await this.store.delete(sessionId).catch(() => void 0);
6520
7865
  return true;
6521
7866
  }
7867
+ async hasRecord(sessionId) {
7868
+ const record = await this.store.read(sessionId).catch(() => void 0);
7869
+ return record !== void 0;
7870
+ }
6522
7871
  // Persist a title update from Session.setTitle. The on-disk record
6523
7872
  // was written at create time; updating it here keeps the session
6524
7873
  // record's title in sync with what was broadcast to clients so a
@@ -6592,14 +7941,76 @@ var SessionManager = class {
6592
7941
  await Promise.allSettled(sessions.map((s) => s.close()));
6593
7942
  this.sessions.clear();
6594
7943
  }
7944
+ // Wait for every pending meta.json write to settle. Daemon shutdown
7945
+ // hooks call this so a SIGTERM doesn't kill the process mid-write
7946
+ // and lose a freshly-set title (or model/mode/commands).
7947
+ async flushMetaWrites() {
7948
+ const pending = [...this.metaWriteQueues.values()];
7949
+ if (pending.length === 0) {
7950
+ return;
7951
+ }
7952
+ await Promise.allSettled(pending);
7953
+ }
6595
7954
  };
7955
+ function mergeForPersistence(session, existing) {
7956
+ const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
7957
+ if (c.description !== void 0) {
7958
+ return { name: c.name, description: c.description };
7959
+ }
7960
+ return { name: c.name };
7961
+ }) : void 0;
7962
+ const agentCommands = persistedCommands ?? existing?.agentCommands;
7963
+ return recordFromMemorySession({
7964
+ sessionId: session.sessionId,
7965
+ lineageId: existing?.lineageId ?? generateLineageId(),
7966
+ upstreamSessionId: session.upstreamSessionId,
7967
+ importedFromSessionId: existing?.importedFromSessionId,
7968
+ agentId: session.agentId,
7969
+ cwd: session.cwd,
7970
+ title: session.title,
7971
+ agentArgs: session.agentArgs,
7972
+ currentModel: session.currentModel ?? existing?.currentModel,
7973
+ currentMode: session.currentMode ?? existing?.currentMode,
7974
+ agentCommands,
7975
+ createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
7976
+ });
7977
+ }
7978
+ async function loadPromptHistorySafely(sessionId) {
7979
+ try {
7980
+ const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
7981
+ const out = [];
7982
+ for (const line of raw.split("\n")) {
7983
+ if (line.length === 0) {
7984
+ continue;
7985
+ }
7986
+ try {
7987
+ const decoded = JSON.parse(line);
7988
+ if (typeof decoded === "string") {
7989
+ out.push(decoded);
7990
+ }
7991
+ } catch {
7992
+ }
7993
+ }
7994
+ return out;
7995
+ } catch {
7996
+ return [];
7997
+ }
7998
+ }
7999
+ async function historyMtimeIso(sessionId) {
8000
+ try {
8001
+ const st = await fs7.stat(paths.historyFile(sessionId));
8002
+ return new Date(st.mtimeMs).toISOString();
8003
+ } catch {
8004
+ return void 0;
8005
+ }
8006
+ }
6596
8007
 
6597
8008
  // src/core/extensions.ts
6598
8009
  init_paths();
6599
8010
  import { spawn as spawn2 } from "child_process";
6600
- import * as fs6 from "fs";
8011
+ import * as fs8 from "fs";
6601
8012
  import * as fsp from "fs/promises";
6602
- import * as path3 from "path";
8013
+ import * as path4 from "path";
6603
8014
  var RESTART_BASE_MS = 1e3;
6604
8015
  var RESTART_CAP_MS = 6e4;
6605
8016
  var STOP_GRACE_MS = 3e3;
@@ -6646,9 +8057,9 @@ var ExtensionManager = class {
6646
8057
  } catch {
6647
8058
  }
6648
8059
  tasks.push(
6649
- new Promise((resolve2) => {
8060
+ new Promise((resolve4) => {
6650
8061
  if (child.exitCode !== null || child.signalCode !== null) {
6651
- resolve2();
8062
+ resolve4();
6652
8063
  return;
6653
8064
  }
6654
8065
  const timer = setTimeout(() => {
@@ -6656,11 +8067,11 @@ var ExtensionManager = class {
6656
8067
  child.kill("SIGKILL");
6657
8068
  } catch {
6658
8069
  }
6659
- resolve2();
8070
+ resolve4();
6660
8071
  }, STOP_GRACE_MS);
6661
8072
  child.on("exit", () => {
6662
8073
  clearTimeout(timer);
6663
- resolve2();
8074
+ resolve4();
6664
8075
  });
6665
8076
  })
6666
8077
  );
@@ -6768,8 +8179,8 @@ var ExtensionManager = class {
6768
8179
  if (child.exitCode !== null || child.signalCode !== null) {
6769
8180
  return;
6770
8181
  }
6771
- const exited = new Promise((resolve2) => {
6772
- entry.exitWaiters.push(resolve2);
8182
+ const exited = new Promise((resolve4) => {
8183
+ entry.exitWaiters.push(resolve4);
6773
8184
  });
6774
8185
  try {
6775
8186
  child.kill("SIGTERM");
@@ -6841,7 +8252,7 @@ var ExtensionManager = class {
6841
8252
  if (!entry.endsWith(".pid")) {
6842
8253
  continue;
6843
8254
  }
6844
- const pidPath = path3.join(paths.extensionsDir(), entry);
8255
+ const pidPath = path4.join(paths.extensionsDir(), entry);
6845
8256
  let pid;
6846
8257
  try {
6847
8258
  const raw = await fsp.readFile(pidPath, "utf8");
@@ -6880,7 +8291,7 @@ var ExtensionManager = class {
6880
8291
  }
6881
8292
  const ext = entry.config;
6882
8293
  const command = ext.command.length > 0 ? ext.command : [ext.name];
6883
- const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
8294
+ const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
6884
8295
  flags: "a"
6885
8296
  });
6886
8297
  logStream.write(
@@ -6930,7 +8341,7 @@ var ExtensionManager = class {
6930
8341
  }
6931
8342
  if (typeof child.pid === "number") {
6932
8343
  try {
6933
- fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
8344
+ fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
6934
8345
  `, {
6935
8346
  encoding: "utf8",
6936
8347
  mode: 384
@@ -6955,7 +8366,7 @@ var ExtensionManager = class {
6955
8366
  });
6956
8367
  child.on("exit", (code, signal) => {
6957
8368
  try {
6958
- fs6.unlinkSync(paths.extensionPidFile(ext.name));
8369
+ fs8.unlinkSync(paths.extensionPidFile(ext.name));
6959
8370
  } catch {
6960
8371
  }
6961
8372
  logStream.write(
@@ -6966,8 +8377,8 @@ var ExtensionManager = class {
6966
8377
  entry.pid = void 0;
6967
8378
  entry.lastExitCode = typeof code === "number" ? code : void 0;
6968
8379
  const waiters = entry.exitWaiters.splice(0);
6969
- for (const resolve2 of waiters) {
6970
- resolve2();
8380
+ for (const resolve4 of waiters) {
8381
+ resolve4();
6971
8382
  }
6972
8383
  if (this.stopping || entry.manuallyStopped) {
6973
8384
  try {
@@ -7068,6 +8479,76 @@ function constantTimeEqual(a, b) {
7068
8479
 
7069
8480
  // src/daemon/routes/sessions.ts
7070
8481
  init_config();
8482
+ import * as os2 from "os";
8483
+
8484
+ // src/core/bundle.ts
8485
+ import { z as z5 } from "zod";
8486
+ var HistoryEntrySchema = z5.object({
8487
+ method: z5.string(),
8488
+ params: z5.unknown(),
8489
+ recordedAt: z5.number()
8490
+ });
8491
+ var BundleSession = z5.object({
8492
+ // The exporter's local id. Regenerated fresh on import (sessionId is
8493
+ // the local namespace; lineageId is what survives across hops).
8494
+ sessionId: z5.string(),
8495
+ // Required on bundles — the export path backfills if the source
8496
+ // record was written before lineageId existed.
8497
+ lineageId: z5.string(),
8498
+ agentId: z5.string(),
8499
+ cwd: z5.string(),
8500
+ title: z5.string().optional(),
8501
+ currentModel: z5.string().optional(),
8502
+ currentMode: z5.string().optional(),
8503
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
8504
+ createdAt: z5.string(),
8505
+ updatedAt: z5.string()
8506
+ });
8507
+ var Bundle = z5.object({
8508
+ version: z5.literal(1),
8509
+ exportedAt: z5.string(),
8510
+ exportedFrom: z5.object({
8511
+ hydraVersion: z5.string(),
8512
+ machine: z5.string()
8513
+ }),
8514
+ session: BundleSession,
8515
+ history: z5.array(HistoryEntrySchema),
8516
+ promptHistory: z5.array(z5.string()).optional()
8517
+ });
8518
+ function encodeBundle(params) {
8519
+ const bundle = {
8520
+ version: 1,
8521
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8522
+ exportedFrom: {
8523
+ hydraVersion: params.hydraVersion,
8524
+ machine: params.machine
8525
+ },
8526
+ session: {
8527
+ sessionId: params.record.sessionId,
8528
+ lineageId: params.record.lineageId,
8529
+ agentId: params.record.agentId,
8530
+ cwd: params.record.cwd,
8531
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
8532
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
8533
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
8534
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
8535
+ createdAt: params.record.createdAt,
8536
+ updatedAt: params.record.updatedAt
8537
+ },
8538
+ history: params.history
8539
+ };
8540
+ if (params.promptHistory !== void 0) {
8541
+ bundle.promptHistory = params.promptHistory;
8542
+ }
8543
+ return bundle;
8544
+ }
8545
+ function decodeBundle(raw) {
8546
+ return Bundle.parse(raw);
8547
+ }
8548
+
8549
+ // src/daemon/routes/sessions.ts
8550
+ init_types();
8551
+ var HYDRA_VERSION = "0.1.0";
7071
8552
  function registerSessionRoutes(app, manager, defaults) {
7072
8553
  app.get("/v1/sessions", async (request) => {
7073
8554
  const query = request.query;
@@ -7093,6 +8574,22 @@ function registerSessionRoutes(app, manager, defaults) {
7093
8574
  reply.code(500).send({ error: err.message });
7094
8575
  }
7095
8576
  });
8577
+ app.post("/v1/sessions/:id/kill", async (request, reply) => {
8578
+ const raw = request.params.id;
8579
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
8580
+ const session = manager.get(id);
8581
+ if (session) {
8582
+ await session.close({ deleteRecord: false });
8583
+ reply.code(204).send();
8584
+ return;
8585
+ }
8586
+ const exists = await manager.hasRecord(id);
8587
+ if (!exists) {
8588
+ reply.code(404).send({ error: "session not found" });
8589
+ return;
8590
+ }
8591
+ reply.code(204).send();
8592
+ });
7096
8593
  app.delete("/v1/sessions/:id", async (request, reply) => {
7097
8594
  const raw = request.params.id;
7098
8595
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -7109,6 +8606,61 @@ function registerSessionRoutes(app, manager, defaults) {
7109
8606
  }
7110
8607
  reply.code(204).send();
7111
8608
  });
8609
+ app.get("/v1/sessions/:id/export", async (request, reply) => {
8610
+ const raw = request.params.id;
8611
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
8612
+ const exported = await manager.exportBundle(id);
8613
+ if (!exported) {
8614
+ reply.code(404).send({ error: "session not found" });
8615
+ return;
8616
+ }
8617
+ const bundle = encodeBundle({
8618
+ record: exported.record,
8619
+ history: exported.history,
8620
+ promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
8621
+ hydraVersion: HYDRA_VERSION,
8622
+ machine: os2.hostname()
8623
+ });
8624
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8625
+ reply.header(
8626
+ "Content-Disposition",
8627
+ `attachment; filename="hydra-${id}-${stamp}.hydra"`
8628
+ );
8629
+ reply.code(200).send(bundle);
8630
+ });
8631
+ app.post("/v1/sessions/import", async (request, reply) => {
8632
+ const body = request.body ?? {};
8633
+ if (body.bundle === void 0) {
8634
+ reply.code(400).send({ error: "missing bundle" });
8635
+ return;
8636
+ }
8637
+ let bundle;
8638
+ try {
8639
+ bundle = decodeBundle(body.bundle);
8640
+ } catch (err) {
8641
+ reply.code(400).send({
8642
+ error: "invalid bundle",
8643
+ details: err.message
8644
+ });
8645
+ return;
8646
+ }
8647
+ try {
8648
+ const result = await manager.importBundle(bundle, {
8649
+ replace: body.replace === true
8650
+ });
8651
+ reply.code(201).send(result);
8652
+ } catch (err) {
8653
+ const e = err;
8654
+ if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
8655
+ reply.code(409).send({
8656
+ error: "bundle already imported",
8657
+ existingSessionId: e.existingSessionId
8658
+ });
8659
+ return;
8660
+ }
8661
+ reply.code(500).send({ error: e.message });
8662
+ }
8663
+ });
7112
8664
  app.get("/v1/sessions/:id/history", async (request, reply) => {
7113
8665
  const raw = request.params.id;
7114
8666
  const query = request.query;
@@ -7117,16 +8669,22 @@ function registerSessionRoutes(app, manager, defaults) {
7117
8669
  const live = manager.get(id);
7118
8670
  let snapshot;
7119
8671
  let unsubscribe;
8672
+ let snapshotDone = false;
8673
+ const pending = [];
7120
8674
  if (live) {
7121
- snapshot = live.getHistorySnapshot();
7122
8675
  if (follow) {
7123
8676
  unsubscribe = live.onBroadcast((entry) => {
7124
8677
  if (reply.raw.writableEnded) {
7125
8678
  return;
7126
8679
  }
7127
- reply.raw.write(JSON.stringify(entry) + "\n");
8680
+ if (snapshotDone) {
8681
+ reply.raw.write(JSON.stringify(entry) + "\n");
8682
+ } else {
8683
+ pending.push(entry);
8684
+ }
7128
8685
  });
7129
8686
  }
8687
+ snapshot = await live.getHistorySnapshot();
7130
8688
  } else {
7131
8689
  const cold = await manager.getHistory(id);
7132
8690
  if (cold === void 0) {
@@ -7138,9 +8696,23 @@ function registerSessionRoutes(app, manager, defaults) {
7138
8696
  reply.raw.setHeader("Content-Type", "application/x-ndjson");
7139
8697
  reply.raw.setHeader("Cache-Control", "no-cache");
7140
8698
  reply.raw.statusCode = 200;
8699
+ const snapshotKeys = /* @__PURE__ */ new Set();
7141
8700
  for (const entry of snapshot ?? []) {
7142
8701
  reply.raw.write(JSON.stringify(entry) + "\n");
8702
+ const e = entry;
8703
+ if (typeof e.recordedAt === "number") {
8704
+ snapshotKeys.add(String(e.recordedAt));
8705
+ }
8706
+ }
8707
+ for (const entry of pending) {
8708
+ const e = entry;
8709
+ const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
8710
+ if (key && snapshotKeys.has(key)) {
8711
+ continue;
8712
+ }
8713
+ reply.raw.write(JSON.stringify(entry) + "\n");
7143
8714
  }
8715
+ snapshotDone = true;
7144
8716
  if (!unsubscribe) {
7145
8717
  reply.raw.end();
7146
8718
  return reply;
@@ -7300,12 +8872,22 @@ function parseRegisterBody(body) {
7300
8872
  };
7301
8873
  }
7302
8874
 
8875
+ // src/daemon/routes/config.ts
8876
+ function registerConfigRoutes(app, defaults) {
8877
+ app.get("/v1/config", async () => {
8878
+ return {
8879
+ defaultAgent: defaults.defaultAgent,
8880
+ defaultCwd: defaults.defaultCwd
8881
+ };
8882
+ });
8883
+ }
8884
+
7303
8885
  // src/daemon/acp-ws.ts
7304
8886
  init_connection();
7305
8887
  init_ws_stream();
7306
8888
  init_types();
7307
8889
  import { nanoid as nanoid2 } from "nanoid";
7308
- var HYDRA_VERSION = "0.1.0";
8890
+ var HYDRA_VERSION2 = "0.1.0";
7309
8891
  var HYDRA_PROTOCOL_VERSION = 1;
7310
8892
  function registerAcpWsEndpoint(app, deps) {
7311
8893
  app.get("/acp", { websocket: true }, (socket, request) => {
@@ -7351,7 +8933,7 @@ function registerAcpWsEndpoint(app, deps) {
7351
8933
  agentArgs: hydraMeta.agentArgs
7352
8934
  });
7353
8935
  const client = bindClientToSession(connection, session, state);
7354
- session.attach(client, "full");
8936
+ await session.attach(client, "full");
7355
8937
  state.attached.set(session.sessionId, {
7356
8938
  sessionId: session.sessionId,
7357
8939
  clientId: client.clientId
@@ -7370,14 +8952,22 @@ function registerAcpWsEndpoint(app, deps) {
7370
8952
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
7371
8953
  let session = deps.manager.get(lookupId);
7372
8954
  if (!session) {
7373
- let resurrectParams = hydraHints ? {
7374
- hydraSessionId: params.sessionId,
7375
- upstreamSessionId: hydraHints.upstreamSessionId,
7376
- agentId: hydraHints.agentId,
7377
- cwd: hydraHints.cwd,
7378
- title: hydraHints.title,
7379
- agentArgs: hydraHints.agentArgs
7380
- } : await deps.manager.loadFromDisk(lookupId);
8955
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
8956
+ let resurrectParams = fromDisk;
8957
+ if (hydraHints) {
8958
+ resurrectParams = {
8959
+ hydraSessionId: params.sessionId,
8960
+ upstreamSessionId: hydraHints.upstreamSessionId,
8961
+ agentId: hydraHints.agentId,
8962
+ cwd: hydraHints.cwd,
8963
+ title: hydraHints.title ?? fromDisk?.title,
8964
+ agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
8965
+ currentModel: fromDisk?.currentModel,
8966
+ currentMode: fromDisk?.currentMode,
8967
+ agentCommands: fromDisk?.agentCommands,
8968
+ createdAt: fromDisk?.createdAt
8969
+ };
8970
+ }
7381
8971
  if (!resurrectParams) {
7382
8972
  const err = new Error(
7383
8973
  `session ${params.sessionId} not found and no resume hints provided`
@@ -7393,13 +8983,13 @@ function registerAcpWsEndpoint(app, deps) {
7393
8983
  state,
7394
8984
  params.clientInfo
7395
8985
  );
7396
- const replay = session.attach(client, params.historyPolicy);
8986
+ const replay = await session.attach(client, params.historyPolicy);
7397
8987
  state.attached.set(session.sessionId, {
7398
8988
  sessionId: session.sessionId,
7399
8989
  clientId: client.clientId
7400
8990
  });
7401
8991
  app.log.info(
7402
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
8992
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
7403
8993
  );
7404
8994
  for (const note of replay) {
7405
8995
  await connection.notify(note.method, note.params);
@@ -7495,7 +9085,7 @@ function registerAcpWsEndpoint(app, deps) {
7495
9085
  session = await deps.manager.resurrect(fromDisk);
7496
9086
  }
7497
9087
  const client = bindClientToSession(connection, session, state);
7498
- const replay = session.attach(client, "pending_only");
9088
+ const replay = await session.attach(client, "pending_only");
7499
9089
  state.attached.set(session.sessionId, {
7500
9090
  sessionId: session.sessionId,
7501
9091
  clientId: client.clientId
@@ -7553,12 +9143,15 @@ function buildResponseMeta(session) {
7553
9143
  if (commands.length > 0) {
7554
9144
  ours.availableCommands = commands;
7555
9145
  }
9146
+ if (session.turnStartedAt !== void 0) {
9147
+ ours.turnStartedAt = session.turnStartedAt;
9148
+ }
7556
9149
  return mergeMeta(session.agentMeta, ours);
7557
9150
  }
7558
9151
  function buildInitializeResult() {
7559
9152
  return {
7560
9153
  protocolVersion: HYDRA_PROTOCOL_VERSION,
7561
- agentInfo: { name: "hydra", version: HYDRA_VERSION },
9154
+ agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
7562
9155
  agentCapabilities: {
7563
9156
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
7564
9157
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -7597,7 +9190,7 @@ function bindClientToSession(connection, session, state, clientInfo) {
7597
9190
  }
7598
9191
 
7599
9192
  // src/daemon/server.ts
7600
- var HYDRA_VERSION2 = "0.1.0";
9193
+ var HYDRA_VERSION3 = "0.1.0";
7601
9194
  async function startDaemon(config) {
7602
9195
  ensureLoopbackOrTls(config);
7603
9196
  const httpsOptions = config.daemon.tls ? {
@@ -7631,13 +9224,17 @@ async function startDaemon(config) {
7631
9224
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
7632
9225
  });
7633
9226
  const extensions = new ExtensionManager(extensionList(config));
7634
- registerHealthRoutes(app, HYDRA_VERSION2);
9227
+ registerHealthRoutes(app, HYDRA_VERSION3);
7635
9228
  registerSessionRoutes(app, manager, {
7636
9229
  agentId: config.defaultAgent,
7637
9230
  cwd: config.defaultCwd
7638
9231
  });
7639
9232
  registerAgentRoutes(app, registry);
7640
9233
  registerExtensionRoutes(app, extensions);
9234
+ registerConfigRoutes(app, {
9235
+ defaultAgent: config.defaultAgent,
9236
+ defaultCwd: config.defaultCwd
9237
+ });
7641
9238
  registerAcpWsEndpoint(app, {
7642
9239
  config,
7643
9240
  manager,
@@ -7671,9 +9268,10 @@ async function startDaemon(config) {
7671
9268
  const shutdown = async () => {
7672
9269
  await extensions.stop();
7673
9270
  await manager.closeAll();
9271
+ await manager.flushMetaWrites();
7674
9272
  await app.close();
7675
9273
  try {
7676
- fs7.unlinkSync(paths.pidFile());
9274
+ fs9.unlinkSync(paths.pidFile());
7677
9275
  } catch {
7678
9276
  }
7679
9277
  try {
@@ -7712,13 +9310,13 @@ function ensureLoopbackOrTls(config) {
7712
9310
  init_daemon_bootstrap();
7713
9311
 
7714
9312
  // src/cli/commands/log-tail.ts
7715
- import * as fs8 from "fs";
9313
+ import * as fs10 from "fs";
7716
9314
  import * as fsp3 from "fs/promises";
7717
9315
  async function runLogTail(logPath, argv, notFoundMessage) {
7718
9316
  const opts = parseLogTailFlags(argv);
7719
- let stat2;
9317
+ let stat3;
7720
9318
  try {
7721
- stat2 = await fsp3.stat(logPath);
9319
+ stat3 = await fsp3.stat(logPath);
7722
9320
  } catch (err) {
7723
9321
  const e = err;
7724
9322
  if (e.code === "ENOENT") {
@@ -7729,14 +9327,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
7729
9327
  }
7730
9328
  throw err;
7731
9329
  }
7732
- let position = await printTail(logPath, stat2.size, opts.tail);
9330
+ let position = await printTail(logPath, stat3.size, opts.tail);
7733
9331
  if (!opts.follow) {
7734
9332
  return;
7735
9333
  }
7736
9334
  process.stdout.write(`-- following ${logPath} --
7737
9335
  `);
7738
9336
  let pending = false;
7739
- const watcher = fs8.watch(logPath, () => {
9337
+ const watcher = fs10.watch(logPath, () => {
7740
9338
  if (pending) {
7741
9339
  return;
7742
9340
  }
@@ -7764,10 +9362,10 @@ async function runLogTail(logPath, argv, notFoundMessage) {
7764
9362
  }
7765
9363
  });
7766
9364
  });
7767
- await new Promise((resolve2) => {
9365
+ await new Promise((resolve4) => {
7768
9366
  const finish = () => {
7769
9367
  watcher.close();
7770
- resolve2();
9368
+ resolve4();
7771
9369
  };
7772
9370
  process.once("SIGINT", finish);
7773
9371
  process.once("SIGTERM", finish);
@@ -8839,14 +10437,16 @@ async function main() {
8839
10437
  process.exit(2);
8840
10438
  return;
8841
10439
  }
8842
- const sessionId2 = resolveOption(flags2, "session-id");
10440
+ const launchResume = flags2.resume;
10441
+ const sessionId2 = typeof launchResume === "string" ? launchResume : resolveOption(flags2, "session-id");
8843
10442
  const name2 = resolveOption(flags2, "name");
8844
10443
  await runShim({ sessionId: sessionId2, agentId, agentArgs, name: name2 });
8845
10444
  return;
8846
10445
  }
8847
10446
  const { positional, flags } = parseArgs(argv);
8848
10447
  if (flags.version === true || positional[0] === "--version") {
8849
- process.stdout.write("hydra-acp 0.1.0\n");
10448
+ process.stdout.write(`hydra-acp ${readVersion()}
10449
+ `);
8850
10450
  return;
8851
10451
  }
8852
10452
  if (flags.help === true) {
@@ -8854,7 +10454,8 @@ async function main() {
8854
10454
  return;
8855
10455
  }
8856
10456
  const subcommand = positional[0];
8857
- const sessionId = resolveOption(flags, "session-id");
10457
+ const resumeFlag = flags.resume;
10458
+ const sessionId = typeof resumeFlag === "string" ? resumeFlag : resolveOption(flags, "session-id");
8858
10459
  const name = resolveOption(flags, "name");
8859
10460
  const agentIdFromFlag = resolveOption(flags, "agent-id");
8860
10461
  if (!subcommand) {
@@ -8915,6 +10516,21 @@ async function main() {
8915
10516
  await runSessionsKill(positional[2]);
8916
10517
  return;
8917
10518
  }
10519
+ if (sub === "rm") {
10520
+ await runSessionsRm(positional[2]);
10521
+ return;
10522
+ }
10523
+ if (sub === "export") {
10524
+ const out = resolveOption(flags, "out");
10525
+ await runSessionsExport(positional[2], out);
10526
+ return;
10527
+ }
10528
+ if (sub === "import") {
10529
+ await runSessionsImport(positional[2], {
10530
+ replace: flags.replace === true
10531
+ });
10532
+ return;
10533
+ }
8918
10534
  process.stderr.write(`Unknown sessions subcommand: ${sub}
8919
10535
  `);
8920
10536
  process.exit(2);
@@ -9008,6 +10624,17 @@ async function dispatchTui(flags, base) {
9008
10624
  }
9009
10625
  await runTui(tuiOpts);
9010
10626
  }
10627
+ function readVersion() {
10628
+ try {
10629
+ const here = dirname3(fileURLToPath(import.meta.url));
10630
+ const pkg = JSON.parse(
10631
+ readFileSync(resolve3(here, "../package.json"), "utf8")
10632
+ );
10633
+ return pkg.version ?? "unknown";
10634
+ } catch {
10635
+ return "unknown";
10636
+ }
10637
+ }
9011
10638
  function printHelp() {
9012
10639
  process.stdout.write(
9013
10640
  [
@@ -9021,12 +10648,17 @@ function printHelp() {
9021
10648
  " Shim mode, force daemon to spawn <agent-id>",
9022
10649
  " from the registry. Args after <agent-id>",
9023
10650
  " are forwarded to the agent's command.",
9024
- " hydra-acp --session-id <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
10651
+ " hydra-acp --resume <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
9025
10652
  " hydra-acp init [--rotate-token] Initialize ~/.hydra-acp/config.json",
9026
10653
  " hydra-acp daemon start|stop|restart|status",
9027
10654
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
9028
10655
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
9029
- " hydra-acp sessions kill <id> Kill a session (live or cold)",
10656
+ " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
10657
+ " hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
10658
+ " hydra-acp sessions export <id> [--out <file>|.]",
10659
+ " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
10660
+ " hydra-acp sessions import <file>|- [--replace]",
10661
+ " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live)",
9030
10662
  " hydra-acp extensions list List configured extensions and live state",
9031
10663
  " hydra-acp extensions add <name> [opts] Add an extension to config",
9032
10664
  " hydra-acp extensions remove <name> Remove an extension from config",
@@ -9034,15 +10666,16 @@ function printHelp() {
9034
10666
  " hydra-acp extensions logs <name> [-f] [-n N]Tail or follow an extension's log",
9035
10667
  " hydra-acp agents [list] List agents in the cached registry",
9036
10668
  " hydra-acp agents refresh Force a registry re-fetch",
9037
- " hydra-acp tui flags: [--session-id <id>] [--resume] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
9038
- " Smart default: picks an existing live session if any exist in cwd, else creates a new one",
10669
+ " hydra-acp tui flags: [--resume [<id>]] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
10670
+ " --resume <id> attaches to a specific session; bare --resume picks the most-recent",
10671
+ " in cwd. Smart default (no flags): picks if any live sessions exist, else new.",
9039
10672
  " hydra-acp --version Print version",
9040
10673
  " hydra-acp --help Show this help",
9041
10674
  "",
9042
10675
  "Config knob flags accept env-var equivalents (flag wins):",
9043
- " --agent-id HYDRA_ACP_AGENT_ID",
9044
- " --session-id HYDRA_ACP_SESSION_ID",
9045
- " --name HYDRA_ACP_NAME",
10676
+ " --agent-id HYDRA_ACP_AGENT_ID",
10677
+ " --resume / --session-id HYDRA_ACP_SESSION_ID",
10678
+ " --name HYDRA_ACP_NAME",
9046
10679
  ""
9047
10680
  ].join("\n")
9048
10681
  );