@hydra-acp/cli 0.1.3 → 0.1.5

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
@@ -37,7 +37,11 @@ var init_paths = __esm({
37
37
  currentLogFile: () => path.join(hydraHome(), "current.log"),
38
38
  registryCache: () => path.join(hydraHome(), "registry.json"),
39
39
  agentsDir: () => path.join(hydraHome(), "agents"),
40
- agentDir: (id) => path.join(hydraHome(), "agents", id),
40
+ // <platformKey>/<agentId>/<version>/ platform at the top so a Hydra
41
+ // home shared between machines (NFS, rsync'd dotfiles) keeps each
42
+ // machine's binaries cleanly separated. `ls agents/` immediately
43
+ // shows which platforms have ever installed anything.
44
+ agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
41
45
  sessionsDir: () => path.join(hydraHome(), "sessions"),
42
46
  // One directory per session id under sessions/. Co-locates the
43
47
  // session record, its transcript, and any future per-session state
@@ -89,8 +93,7 @@ async function ensureConfig() {
89
93
  if (e.code !== "ENOENT") {
90
94
  throw err;
91
95
  }
92
- const config = defaultConfig();
93
- await writeConfig(config);
96
+ const config = await writeMinimalInitConfig();
94
97
  process.stderr.write(
95
98
  `hydra-acp: initialized ${paths.config()} with a fresh auth token.
96
99
  `
@@ -99,9 +102,23 @@ async function ensureConfig() {
99
102
  }
100
103
  return loadConfig();
101
104
  }
102
- async function writeConfig(config) {
105
+ async function writeMinimalInitConfig(authToken) {
106
+ const token = authToken ?? generateAuthToken();
107
+ const minimal = { daemon: { authToken: token } };
103
108
  await fs.mkdir(paths.home(), { recursive: true });
104
- await fs.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
109
+ await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
110
+ encoding: "utf8",
111
+ mode: 384
112
+ });
113
+ return HydraConfig.parse(minimal);
114
+ }
115
+ async function updateConfigField(mutate) {
116
+ const path7 = paths.config();
117
+ const text = await fs.readFile(path7, "utf8");
118
+ const raw = JSON.parse(text);
119
+ mutate(raw);
120
+ HydraConfig.parse(raw);
121
+ await fs.writeFile(path7, JSON.stringify(raw, null, 2) + "\n", {
105
122
  encoding: "utf8",
106
123
  mode: 384
107
124
  });
@@ -115,13 +132,6 @@ function generateAuthToken() {
115
132
  }
116
133
  return `hydra_token_${hex}`;
117
134
  }
118
- function defaultConfig() {
119
- return HydraConfig.parse({
120
- daemon: {
121
- authToken: generateAuthToken()
122
- }
123
- });
124
- }
125
135
  function expandHome(p) {
126
136
  if (p === "~" || p === "$HOME") {
127
137
  return homedir2();
@@ -150,7 +160,7 @@ var init_config = __esm({
150
160
  authToken: z.string().min(16),
151
161
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
152
162
  tls: TlsConfig.optional(),
153
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
163
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
154
164
  });
155
165
  RegistryConfig = z.object({
156
166
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -183,6 +193,14 @@ var init_config = __esm({
183
193
  daemon: DaemonConfig,
184
194
  registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
185
195
  defaultAgent: z.string().default("claude-acp"),
196
+ // Optional per-agent default model id. When a brand-new agent process
197
+ // is spawned (session/new path), hydra issues session/set_model with
198
+ // the matching entry so the user lands on their preferred model from
199
+ // the first prompt. Not applied on resurrect — those sessions keep
200
+ // whatever the user last selected. Keys are agent ids; values are the
201
+ // raw model id strings the agent expects (claude-acp: "claude-opus-4-7",
202
+ // opencode: "openai/gpt-5-codex" or "ncp-anthropic/claude-opus-4-7", …).
203
+ defaultModels: z.record(z.string(), z.string()).default({}),
186
204
  // Where new sessions land when POST /v1/sessions omits cwd. Stored as
187
205
  // a literal string ("~", "~/dev", "$HOME/work") so the config file is
188
206
  // portable across machines; expanded via expandHome at use time.
@@ -264,7 +282,7 @@ function extractHydraMeta(meta) {
264
282
  function mergeMeta(passthrough, ours) {
265
283
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
266
284
  }
267
- var JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
285
+ var JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
268
286
  var init_types = __esm({
269
287
  "src/acp/types.ts"() {
270
288
  "use strict";
@@ -277,7 +295,8 @@ var init_types = __esm({
277
295
  SessionNotFound: -32001,
278
296
  PermissionDenied: -32002,
279
297
  AlreadyAttached: -32003,
280
- AgentNotInstalled: -32005
298
+ AgentNotInstalled: -32005,
299
+ BundleAlreadyImported: -32010
281
300
  };
282
301
  InitializeParams = z3.object({
283
302
  protocolVersion: z3.number().optional(),
@@ -318,12 +337,24 @@ var init_types = __esm({
318
337
  cursor: z3.string().optional(),
319
338
  limit: z3.number().int().positive().max(200).optional()
320
339
  });
340
+ SessionListUsage = z3.object({
341
+ used: z3.number().optional(),
342
+ size: z3.number().optional(),
343
+ costAmount: z3.number().optional(),
344
+ costCurrency: z3.string().optional()
345
+ });
321
346
  SessionListEntry = z3.object({
322
347
  sessionId: z3.string(),
323
348
  upstreamSessionId: z3.string().optional(),
324
349
  cwd: z3.string(),
325
350
  title: z3.string().optional(),
326
351
  agentId: z3.string().optional(),
352
+ // Last-known model id, so list views can render `<agent>(<model>)`
353
+ // without resurrecting cold sessions to look it up.
354
+ currentModel: z3.string().optional(),
355
+ // Last-known usage snapshot so list views can show per-session cost
356
+ // (and tokens, in callers that care) without resurrecting cold sessions.
357
+ currentUsage: SessionListUsage.optional(),
327
358
  updatedAt: z3.string(),
328
359
  attachedClients: z3.number().int().nonnegative(),
329
360
  status: z3.enum(["live", "cold"]).default("live"),
@@ -401,9 +432,9 @@ var init_connection = __esm({
401
432
  }
402
433
  const id = nanoid();
403
434
  const message = { jsonrpc: "2.0", id, method, params };
404
- const response = new Promise((resolve2, reject) => {
435
+ const response = new Promise((resolve5, reject) => {
405
436
  this.pending.set(id, {
406
- resolve: (result) => resolve2(result),
437
+ resolve: (result) => resolve5(result),
407
438
  reject
408
439
  });
409
440
  this.stream.send(message).catch((err) => {
@@ -527,8 +558,8 @@ var init_hydra_commands = __esm({
527
558
  description: "Regenerate the session title via the agent (or set manually with an arg)"
528
559
  },
529
560
  {
530
- verb: "switch",
531
- name: "/hydra switch",
561
+ verb: "agent",
562
+ name: "/hydra agent",
532
563
  argsHint: "<agent>",
533
564
  description: "Swap the agent backing this session, preserving context"
534
565
  }
@@ -627,7 +658,7 @@ function firstLine(text, max) {
627
658
  }
628
659
  return void 0;
629
660
  }
630
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
661
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, MAX_HISTORY_ENTRIES, COMPACT_EVERY, Session, STATE_UPDATE_KINDS;
631
662
  var init_session = __esm({
632
663
  "src/core/session.ts"() {
633
664
  "use strict";
@@ -636,10 +667,12 @@ var init_session = __esm({
636
667
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
637
668
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
638
669
  HYDRA_SESSION_PREFIX = "hydra_session_";
670
+ MAX_HISTORY_ENTRIES = 1e3;
671
+ COMPACT_EVERY = 200;
639
672
  Session = class {
640
673
  sessionId;
641
674
  cwd;
642
- // agent / agentId / upstreamSessionId are mutable so /hydra switch can
675
+ // agent / agentId / upstreamSessionId are mutable so /hydra agent can
643
676
  // replace the underlying agent process while keeping the same Session
644
677
  // record. agentMeta is the metadata returned by the agent at session/new
645
678
  // time; it gets refreshed on switch too.
@@ -654,9 +687,10 @@ var init_session = __esm({
654
687
  // stale-prone for snapshot-shaped events).
655
688
  currentModel;
656
689
  currentMode;
690
+ currentUsage;
657
691
  updatedAt;
692
+ createdAt;
658
693
  clients = /* @__PURE__ */ new Map();
659
- history = [];
660
694
  historyStore;
661
695
  promptQueue = [];
662
696
  promptInFlight = false;
@@ -672,6 +706,15 @@ var init_session = __esm({
672
706
  // True once we've observed our first session/prompt; gates the
673
707
  // first-prompt-seeded title so subsequent prompts don't churn it.
674
708
  firstPromptSeeded = false;
709
+ // Wall-clock when the active prompt started, undefined when idle.
710
+ // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
711
+ // Drives the mid-turn elapsed counter delivered to fresh attachers.
712
+ promptStartedAt;
713
+ // Counts appends since the last compaction. When it hits COMPACT_EVERY
714
+ // we ask the history store to trim the file to the most recent
715
+ // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
716
+ // file-size checks.
717
+ appendCount = 0;
675
718
  // Permission requests that have been broadcast to one or more
676
719
  // clients but have not yet resolved. Replayed to clients that
677
720
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -686,6 +729,12 @@ var init_session = __esm({
686
729
  internalPromptCapture;
687
730
  idleTimeoutMs;
688
731
  idleTimer;
732
+ // Time of the last recordable broadcast (or session creation, if
733
+ // none yet). Drives the inactivity-based idle close; deliberately
734
+ // does NOT include snapshot state pings (model/mode/title/commands)
735
+ // or attach/detach, which would otherwise let passive observers
736
+ // and noisy state churn keep a quiet session alive forever.
737
+ lastRecordedAt;
689
738
  spawnReplacementAgent;
690
739
  agentChangeHandlers = [];
691
740
  // Last available_commands_update we observed from the agent. Stored
@@ -700,6 +749,7 @@ var init_session = __esm({
700
749
  agentCommandsHandlers = [];
701
750
  modelHandlers = [];
702
751
  modeHandlers = [];
752
+ usageHandlers = [];
703
753
  constructor(init) {
704
754
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
705
755
  this.cwd = init.cwd;
@@ -711,6 +761,7 @@ var init_session = __esm({
711
761
  this.title = init.title;
712
762
  this.currentModel = init.currentModel;
713
763
  this.currentMode = init.currentMode;
764
+ this.currentUsage = init.currentUsage;
714
765
  if (init.agentCommands && init.agentCommands.length > 0) {
715
766
  this.agentAdvertisedCommands = [...init.agentCommands];
716
767
  }
@@ -720,11 +771,11 @@ var init_session = __esm({
720
771
  this.firstPromptSeeded = true;
721
772
  }
722
773
  this.historyStore = init.historyStore;
723
- if (init.seedHistory && init.seedHistory.length > 0) {
724
- this.history = [...init.seedHistory];
725
- }
726
774
  this.updatedAt = Date.now();
775
+ this.createdAt = init.createdAt ?? this.updatedAt;
776
+ this.lastRecordedAt = this.updatedAt;
727
777
  this.wireAgent(this.agent);
778
+ this.scheduleIdleCheck();
728
779
  }
729
780
  broadcastMergedCommands() {
730
781
  const merged = [
@@ -740,7 +791,7 @@ var init_session = __esm({
740
791
  });
741
792
  }
742
793
  // Register session/update, session/request_permission, and onExit
743
- // handlers on an agent connection. Re-run on every /hydra switch so
794
+ // handlers on an agent connection. Re-run on every /hydra agent so
744
795
  // the new agent is plumbed identically. The exit handler's identity
745
796
  // check is what makes switching safe: when the *old* agent exits as
746
797
  // part of a swap, this.agent has already been replaced, so we no-op
@@ -764,6 +815,10 @@ var init_session = __esm({
764
815
  this.recordAndBroadcast("session/update", params);
765
816
  return;
766
817
  }
818
+ if (this.maybeApplyAgentUsage(params)) {
819
+ this.recordAndBroadcast("session/update", params);
820
+ return;
821
+ }
767
822
  this.maybeApplyAgentSessionInfo(params);
768
823
  this.recordAndBroadcast("session/update", params);
769
824
  });
@@ -784,34 +839,20 @@ var init_session = __esm({
784
839
  return this.clients.size;
785
840
  }
786
841
  // Wall-clock when the in-flight agent turn began, or undefined when
787
- // idle. Derived from history: the most recent prompt_received without
788
- // a later turn_complete is the outstanding turn, and its recordedAt
789
- // is when the prompt was first broadcast. Used by buildResponseMeta
790
- // so a fresh client reattaching mid-turn boots up with the busy
791
- // banner showing real elapsed time.
842
+ // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
843
+ // so the daemon can hand a fresh attacher mid-turn the right elapsed
844
+ // time without scanning history.
792
845
  get turnStartedAt() {
793
- for (let i = this.history.length - 1; i >= 0; i--) {
794
- const entry = this.history[i];
795
- if (!entry) {
796
- continue;
797
- }
798
- const params = entry.params;
799
- const kind = params?.update?.sessionUpdate;
800
- if (kind === "turn_complete") {
801
- return void 0;
802
- }
803
- if (kind === "prompt_received") {
804
- return entry.recordedAt;
805
- }
806
- }
807
- return void 0;
846
+ return this.promptStartedAt;
808
847
  }
809
- // Snapshot of the current in-memory replay history. Used by the
810
- // HTTP history endpoint to deliver the "what's accumulated so far"
811
- // prefix before optionally tailing with onBroadcast. Returns a copy
812
- // so callers can't mutate our cache.
813
- getHistorySnapshot() {
814
- return [...this.history];
848
+ // Read the persisted history from disk. Returns [] if no history
849
+ // file exists (fresh session, never prompted). Used by attach() and
850
+ // the HTTP /history endpoint.
851
+ async getHistorySnapshot() {
852
+ if (!this.historyStore) {
853
+ return [];
854
+ }
855
+ return this.historyStore.load(this.sessionId).catch(() => []);
815
856
  }
816
857
  // Subscribe to recordable broadcast entries — fires once per entry
817
858
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -827,6 +868,10 @@ var init_session = __esm({
827
868
  }
828
869
  };
829
870
  }
871
+ // Register a client and (asynchronously) load the replay slice it
872
+ // should receive. Validation errors throw synchronously so callers
873
+ // can rely on either the registration being in effect or having
874
+ // thrown; the disk-load is the only async work.
830
875
  attach(client, historyPolicy) {
831
876
  if (this.closed) {
832
877
  throw withCode(
@@ -842,14 +887,10 @@ var init_session = __esm({
842
887
  }
843
888
  this.clients.set(client.clientId, client);
844
889
  this.updatedAt = Date.now();
845
- this.cancelIdleTimer();
846
- if (historyPolicy === "none") {
847
- return [];
848
- }
849
- if (historyPolicy === "pending_only") {
850
- return [];
890
+ if (historyPolicy === "none" || historyPolicy === "pending_only") {
891
+ return Promise.resolve([]);
851
892
  }
852
- return [...this.history];
893
+ return this.getHistorySnapshot();
853
894
  }
854
895
  // Dispatch in-flight permission requests to a freshly-attached
855
896
  // client. Called by the daemon's WS handler *after* it finishes
@@ -863,7 +904,6 @@ var init_session = __esm({
863
904
  detach(clientId) {
864
905
  if (this.clients.delete(clientId)) {
865
906
  this.updatedAt = Date.now();
866
- this.maybeStartIdleTimer();
867
907
  }
868
908
  }
869
909
  async prompt(clientId, params) {
@@ -909,6 +949,7 @@ var init_session = __esm({
909
949
  if (client.clientInfo?.version) {
910
950
  sentBy.version = client.clientInfo.version;
911
951
  }
952
+ this.promptStartedAt = Date.now();
912
953
  this.recordAndBroadcast(
913
954
  "session/update",
914
955
  {
@@ -945,6 +986,7 @@ var init_session = __esm({
945
986
  if (stopReason !== void 0) {
946
987
  update.stopReason = stopReason;
947
988
  }
989
+ this.promptStartedAt = void 0;
948
990
  this.recordAndBroadcast(
949
991
  "session/update",
950
992
  {
@@ -1094,6 +1136,49 @@ var init_session = __esm({
1094
1136
  }
1095
1137
  return true;
1096
1138
  }
1139
+ // usage_update carries any subset of {used, size, cost.amount,
1140
+ // cost.currency}. Merge non-undefined fields onto currentUsage so a
1141
+ // sparse update preserves prior values, and fire usage handlers only
1142
+ // if something actually changed.
1143
+ maybeApplyAgentUsage(params) {
1144
+ const obj = params ?? {};
1145
+ const update = obj.update ?? {};
1146
+ if (update.sessionUpdate !== "usage_update") {
1147
+ return false;
1148
+ }
1149
+ const next = { ...this.currentUsage ?? {} };
1150
+ let changed = false;
1151
+ if (typeof update.used === "number" && next.used !== update.used) {
1152
+ next.used = update.used;
1153
+ changed = true;
1154
+ }
1155
+ if (typeof update.size === "number" && next.size !== update.size) {
1156
+ next.size = update.size;
1157
+ changed = true;
1158
+ }
1159
+ if (update.cost && typeof update.cost === "object") {
1160
+ const cost = update.cost;
1161
+ if (typeof cost.amount === "number" && next.costAmount !== cost.amount) {
1162
+ next.costAmount = cost.amount;
1163
+ changed = true;
1164
+ }
1165
+ if (typeof cost.currency === "string" && next.costCurrency !== cost.currency) {
1166
+ next.costCurrency = cost.currency;
1167
+ changed = true;
1168
+ }
1169
+ }
1170
+ if (!changed) {
1171
+ return true;
1172
+ }
1173
+ this.currentUsage = next;
1174
+ for (const handler of this.usageHandlers) {
1175
+ try {
1176
+ handler(next);
1177
+ } catch {
1178
+ }
1179
+ }
1180
+ return true;
1181
+ }
1097
1182
  // Update the cached agent command list, fire persist handlers, and
1098
1183
  // broadcast the merged list to attached clients. Idempotent on a
1099
1184
  // structurally identical list so we don't churn meta.json on noisy
@@ -1124,12 +1209,21 @@ var init_session = __esm({
1124
1209
  onModeChange(handler) {
1125
1210
  this.modeHandlers.push(handler);
1126
1211
  }
1212
+ onUsageChange(handler) {
1213
+ this.usageHandlers.push(handler);
1214
+ }
1127
1215
  // Returns a freshly merged command list (hydra ∪ agent) for callers
1128
1216
  // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1129
1217
  // assembling the attach response.
1130
1218
  mergedAvailableCommands() {
1131
1219
  return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1132
1220
  }
1221
+ // The agent's own advertised commands (not merged with hydra verbs).
1222
+ // Used by SessionManager to persist into meta.json so cold resurrect
1223
+ // can re-deliver via the attach response _meta.
1224
+ agentOnlyAdvertisedCommands() {
1225
+ return [...this.agentAdvertisedCommands];
1226
+ }
1133
1227
  // Pick up an agent-emitted session_info_update and store its title
1134
1228
  // as our canonical record. The notification is also forwarded to
1135
1229
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1180,8 +1274,8 @@ var init_session = __esm({
1180
1274
  switch (verb) {
1181
1275
  case "title":
1182
1276
  return this.runTitleCommand(arg);
1183
- case "switch":
1184
- return this.runSwitchCommand(arg);
1277
+ case "agent":
1278
+ return this.runAgentCommand(arg);
1185
1279
  default: {
1186
1280
  const err = new Error(
1187
1281
  `no dispatcher for /hydra verb ${verb}`
@@ -1217,7 +1311,7 @@ var init_session = __esm({
1217
1311
  }
1218
1312
  // Send a prompt to the underlying agent and capture its reply chunks
1219
1313
  // privately (no fan-out to clients, no recording into history). Used
1220
- // by /hydra title's regen path and /hydra switch's transcript-injection
1314
+ // by /hydra title's regen path and /hydra agent's transcript-injection
1221
1315
  // path. Returns the joined agent_message_chunk text.
1222
1316
  async runInternalPrompt(text) {
1223
1317
  if (this.internalPromptCapture) {
@@ -1239,10 +1333,10 @@ var init_session = __esm({
1239
1333
  // record. Spawns the new agent first so a failure leaves the old one
1240
1334
  // intact; then injects a synthesized transcript so the new agent has
1241
1335
  // context for the next turn.
1242
- runSwitchCommand(newAgentId) {
1336
+ runAgentCommand(newAgentId) {
1243
1337
  if (!newAgentId) {
1244
1338
  throw withCode(
1245
- new Error("/hydra switch requires an agent id"),
1339
+ new Error("/hydra agent requires an agent id"),
1246
1340
  JsonRpcErrorCodes.InvalidParams
1247
1341
  );
1248
1342
  }
@@ -1261,7 +1355,7 @@ var init_session = __esm({
1261
1355
  const spawnAgent = this.spawnReplacementAgent;
1262
1356
  return this.enqueuePrompt(async () => {
1263
1357
  const oldAgentId = this.agentId;
1264
- const transcript = this.buildSwitchTranscript(oldAgentId);
1358
+ const transcript = await this.buildSwitchTranscript(oldAgentId);
1265
1359
  const fresh = await spawnAgent({
1266
1360
  agentId: newAgentId,
1267
1361
  cwd: this.cwd,
@@ -1293,15 +1387,20 @@ var init_session = __esm({
1293
1387
  return { stopReason: "end_turn" };
1294
1388
  });
1295
1389
  }
1296
- // Walk this.history (rewritten-for-clients notification cache) and
1297
- // produce a labeled transcript suitable for handing to a fresh agent.
1298
- // Includes user prompts, agent replies, and tool-call outcomes; skips
1299
- // hydra-synthesized markers (so multi-hop switches don't accumulate
1300
- // banners) and other update kinds we don't think the next agent
1301
- // benefits from re-seeing (plans, thoughts, mode/model/usage).
1302
- buildSwitchTranscript(prevAgentId) {
1390
+ // Walk the persisted history and produce a labeled transcript suitable
1391
+ // for handing to a fresh agent. Includes user prompts, agent replies,
1392
+ // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
1393
+ // switches don't accumulate banners) and other update kinds we don't
1394
+ // think the next agent benefits from re-seeing (plans, thoughts,
1395
+ // mode/model/usage).
1396
+ //
1397
+ // The header text defaults to the agent-swap framing; callers like
1398
+ // seedFromImport pass a custom header when the new agent is taking
1399
+ // over an imported session rather than swapping mid-conversation.
1400
+ async buildSwitchTranscript(prevAgentId, headerOverride) {
1303
1401
  const lines = [];
1304
- for (const note of this.history) {
1402
+ const history = await this.getHistorySnapshot();
1403
+ for (const note of history) {
1305
1404
  if (note.method !== "session/update") {
1306
1405
  continue;
1307
1406
  }
@@ -1355,29 +1454,53 @@ var init_session = __esm({
1355
1454
  if (current) {
1356
1455
  coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1357
1456
  }
1457
+ const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
1458
+ 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.`;
1358
1459
  return [
1359
- `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1360
- `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`,
1460
+ intro,
1461
+ followup,
1361
1462
  "",
1362
1463
  "--- begin transcript ---",
1363
1464
  ...coalesced,
1364
1465
  "--- end transcript ---"
1365
1466
  ].join("\n");
1366
1467
  }
1468
+ // Replay the persisted history into a freshly-spawned agent so an
1469
+ // imported session has context. Called by SessionManager.doResurrect
1470
+ // on the first wake-up of a session whose meta.json has an empty
1471
+ // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1472
+ // any user prompts arriving mid-seed queue behind it (mirrors the
1473
+ // /hydra agent path so the agent isn't asked to respond to a user
1474
+ // turn before it has absorbed the imported transcript). Best-effort:
1475
+ // if the agent fails to absorb the transcript we still leave the
1476
+ // session usable — the user just continues without context.
1477
+ async seedFromImport() {
1478
+ await this.enqueuePrompt(async () => {
1479
+ const transcript = await this.buildSwitchTranscript(this.agentId, {
1480
+ intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
1481
+ followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
1482
+ });
1483
+ if (!transcript) {
1484
+ return void 0;
1485
+ }
1486
+ await this.runInternalPrompt(transcript).catch(() => void 0);
1487
+ return void 0;
1488
+ });
1489
+ }
1367
1490
  // Tell every attached client (a) the agent identity has changed
1368
- // (session_info_update with an agentId field clients that already
1369
- // listen for title updates pick this up; older clients ignore unknown
1370
- // fields harmlessly) and (b) drop a visible banner into the transcript
1371
- // so users see the switch rather than just suddenly getting answers
1372
- // from a different agent. Both updates carry _meta["hydra-acp"].synthetic
1373
- // so a future /hydra switch's transcript builder filters them out.
1491
+ // (session_info_update carrying agentId inside _meta["hydra-acp"]
1492
+ // the ACP schema for session_info_update is just title/updatedAt/_meta,
1493
+ // so non-hydra clients harmlessly ignore the extension; hydra-aware
1494
+ // ones read it and relabel) and (b) drop a visible banner into the
1495
+ // transcript so users see the switch rather than just suddenly getting
1496
+ // answers from a different agent. Both updates carry synthetic=true
1497
+ // so a future /hydra agent's transcript builder filters them out.
1374
1498
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1375
1499
  this.recordAndBroadcast("session/update", {
1376
1500
  sessionId: this.sessionId,
1377
1501
  update: {
1378
1502
  sessionUpdate: "session_info_update",
1379
- agentId: newAgentId,
1380
- _meta: { "hydra-acp": { synthetic: true } }
1503
+ _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
1381
1504
  }
1382
1505
  });
1383
1506
  this.recordAndBroadcast("session/update", {
@@ -1408,22 +1531,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1408
1531
  handler(opts);
1409
1532
  }
1410
1533
  }
1411
- maybeStartIdleTimer() {
1412
- if (this.closed || this.clients.size > 0 || this.idleTimeoutMs <= 0) {
1534
+ // Last meaningful activity timestamp. Bumped only by recordable
1535
+ // broadcasts in recordAndBroadcast the same signal historyMtimeIso
1536
+ // uses for the picker. Initialized at construction (and seeded from
1537
+ // the newest entry on resurrect) so the inactivity window starts
1538
+ // ticking from a sensible floor when there's no history yet.
1539
+ get lastActivityAt() {
1540
+ return this.lastRecordedAt;
1541
+ }
1542
+ // (Re-)arm the idle timer to fire when the inactivity window
1543
+ // elapses past lastActivityAt. Called once at construction and after
1544
+ // every recorded broadcast. The previous design gated on
1545
+ // clients.size === 0; we drop that gate because extensions
1546
+ // (slack/notifier/approver/browser) hold persistent attaches that
1547
+ // would otherwise keep a quiet session alive forever.
1548
+ scheduleIdleCheck() {
1549
+ if (this.closed || this.idleTimeoutMs <= 0) {
1413
1550
  return;
1414
1551
  }
1552
+ const dueAt = this.lastActivityAt + this.idleTimeoutMs;
1553
+ this.armIdleTimer(Math.max(0, dueAt - Date.now()));
1554
+ }
1555
+ armIdleTimer(delay) {
1415
1556
  if (this.idleTimer) {
1416
- return;
1557
+ clearTimeout(this.idleTimer);
1417
1558
  }
1418
1559
  this.idleTimer = setTimeout(() => {
1419
1560
  this.idleTimer = void 0;
1420
- const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1421
- void this.close(opts).catch(() => void 0);
1422
- }, this.idleTimeoutMs);
1561
+ this.checkIdle();
1562
+ }, delay);
1423
1563
  if (typeof this.idleTimer.unref === "function") {
1424
1564
  this.idleTimer.unref();
1425
1565
  }
1426
1566
  }
1567
+ checkIdle() {
1568
+ if (this.closed || this.idleTimeoutMs <= 0) {
1569
+ return;
1570
+ }
1571
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
1572
+ this.armIdleTimer(this.idleTimeoutMs);
1573
+ return;
1574
+ }
1575
+ const idle = Date.now() - this.lastActivityAt;
1576
+ if (idle < this.idleTimeoutMs) {
1577
+ this.armIdleTimer(this.idleTimeoutMs - idle);
1578
+ return;
1579
+ }
1580
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1581
+ void this.close(opts).catch(() => void 0);
1582
+ }
1427
1583
  cancelIdleTimer() {
1428
1584
  if (this.idleTimer) {
1429
1585
  clearTimeout(this.idleTimer);
@@ -1448,17 +1604,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1448
1604
  params: rewritten,
1449
1605
  recordedAt: Date.now()
1450
1606
  };
1451
- this.history.push(entry);
1452
- let trimmed = false;
1453
- if (this.history.length > 1e3) {
1454
- this.history = this.history.slice(-500);
1455
- trimmed = true;
1456
- }
1607
+ this.lastRecordedAt = entry.recordedAt;
1608
+ this.appendCount += 1;
1457
1609
  if (this.historyStore) {
1458
- if (trimmed) {
1459
- void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1460
- } else {
1461
- void this.historyStore.append(this.sessionId, entry).catch(
1610
+ const store = this.historyStore;
1611
+ void store.append(this.sessionId, entry).catch(() => void 0);
1612
+ if (this.appendCount >= COMPACT_EVERY) {
1613
+ this.appendCount = 0;
1614
+ void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1462
1615
  () => void 0
1463
1616
  );
1464
1617
  }
@@ -1469,6 +1622,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1469
1622
  } catch {
1470
1623
  }
1471
1624
  }
1625
+ this.scheduleIdleCheck();
1472
1626
  }
1473
1627
  this.updatedAt = Date.now();
1474
1628
  for (const client of this.clients.values()) {
@@ -1487,7 +1641,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1487
1641
  );
1488
1642
  }
1489
1643
  const clientParams = this.rewriteForClient(params);
1490
- return new Promise((resolve2, reject) => {
1644
+ return new Promise((resolve5, reject) => {
1491
1645
  let settled = false;
1492
1646
  const outbound = [];
1493
1647
  const entry = { addClient: sendTo };
@@ -1522,7 +1676,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1522
1676
  result
1523
1677
  }).catch(() => void 0);
1524
1678
  }
1525
- resolve2(result);
1679
+ resolve5(result);
1526
1680
  });
1527
1681
  }).catch((err) => {
1528
1682
  settle(() => reject(err));
@@ -1534,16 +1688,16 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1534
1688
  });
1535
1689
  }
1536
1690
  async enqueuePrompt(task) {
1537
- return new Promise((resolve2, reject) => {
1538
- const run = async () => {
1691
+ return new Promise((resolve5, reject) => {
1692
+ const run2 = async () => {
1539
1693
  try {
1540
1694
  const result = await task();
1541
- resolve2(result);
1695
+ resolve5(result);
1542
1696
  } catch (err) {
1543
1697
  reject(err);
1544
1698
  }
1545
1699
  };
1546
- this.promptQueue.push(run);
1700
+ this.promptQueue.push(run2);
1547
1701
  void this.drainQueue();
1548
1702
  });
1549
1703
  }
@@ -1568,11 +1722,70 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1568
1722
  "session_info_update",
1569
1723
  "current_model_update",
1570
1724
  "current_mode_update",
1571
- "available_commands_update"
1725
+ "available_commands_update",
1726
+ "usage_update"
1572
1727
  ]);
1573
1728
  }
1574
1729
  });
1575
1730
 
1731
+ // src/tui/history.ts
1732
+ import { promises as fs7 } from "fs";
1733
+ import * as path4 from "path";
1734
+ async function loadHistory(file) {
1735
+ let text;
1736
+ try {
1737
+ text = await fs7.readFile(file, "utf8");
1738
+ } catch (err) {
1739
+ if (err.code === "ENOENT") {
1740
+ return [];
1741
+ }
1742
+ throw err;
1743
+ }
1744
+ return parseHistory(text);
1745
+ }
1746
+ function parseHistory(text) {
1747
+ const out = [];
1748
+ for (const rawLine of text.split("\n")) {
1749
+ if (rawLine.length === 0) {
1750
+ continue;
1751
+ }
1752
+ try {
1753
+ const decoded = JSON.parse(rawLine);
1754
+ if (typeof decoded === "string") {
1755
+ out.push(decoded);
1756
+ }
1757
+ } catch {
1758
+ }
1759
+ }
1760
+ return out;
1761
+ }
1762
+ function appendEntry(history, entry) {
1763
+ const trimmed = entry.replace(/\n+$/, "");
1764
+ if (trimmed.length === 0) {
1765
+ return history;
1766
+ }
1767
+ if (history.length > 0 && history[history.length - 1] === trimmed) {
1768
+ return history;
1769
+ }
1770
+ const out = history.concat(trimmed);
1771
+ if (out.length > HISTORY_CAP) {
1772
+ return out.slice(out.length - HISTORY_CAP);
1773
+ }
1774
+ return out;
1775
+ }
1776
+ async function saveHistory(file, history) {
1777
+ await fs7.mkdir(path4.dirname(file), { recursive: true });
1778
+ const lines = history.map((entry) => JSON.stringify(entry));
1779
+ await fs7.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1780
+ }
1781
+ var HISTORY_CAP;
1782
+ var init_history = __esm({
1783
+ "src/tui/history.ts"() {
1784
+ "use strict";
1785
+ HISTORY_CAP = 500;
1786
+ }
1787
+ });
1788
+
1576
1789
  // src/acp/ws-stream.ts
1577
1790
  function wsToMessageStream(ws) {
1578
1791
  const messageHandlers = [];
@@ -1618,13 +1831,13 @@ function wsToMessageStream(ws) {
1618
1831
  throw new Error("ws is closed");
1619
1832
  }
1620
1833
  const text = JSON.stringify(message);
1621
- await new Promise((resolve2, reject) => {
1834
+ await new Promise((resolve5, reject) => {
1622
1835
  ws.send(text, (err) => {
1623
1836
  if (err) {
1624
1837
  reject(err);
1625
1838
  return;
1626
1839
  }
1627
- resolve2();
1840
+ resolve5();
1628
1841
  });
1629
1842
  });
1630
1843
  },
@@ -1651,7 +1864,7 @@ var init_ws_stream = __esm({
1651
1864
  });
1652
1865
 
1653
1866
  // src/core/daemon-bootstrap.ts
1654
- import { spawn as spawn3 } from "child_process";
1867
+ import { spawn as spawn4 } from "child_process";
1655
1868
  import { setTimeout as sleep } from "timers/promises";
1656
1869
  async function ensureDaemonReachable(config) {
1657
1870
  if (await pingHealth(config)) {
@@ -1678,11 +1891,15 @@ function spawnDaemonDetached() {
1678
1891
  if (!cliPath) {
1679
1892
  throw new Error("Cannot determine hydra-acp binary path to spawn daemon");
1680
1893
  }
1681
- const child = spawn3(process.execPath, [cliPath, "daemon", "start"], {
1682
- detached: true,
1683
- stdio: "ignore",
1684
- env: process.env
1685
- });
1894
+ const child = spawn4(
1895
+ process.execPath,
1896
+ [cliPath, "daemon", "start", "--foreground"],
1897
+ {
1898
+ detached: true,
1899
+ stdio: "ignore",
1900
+ env: process.env
1901
+ }
1902
+ );
1686
1903
  child.unref();
1687
1904
  }
1688
1905
  async function waitForDaemonReady(config, timeoutMs = 15e3) {
@@ -1703,25 +1920,80 @@ var init_daemon_bootstrap = __esm({
1703
1920
  }
1704
1921
  });
1705
1922
 
1923
+ // src/core/agent-display.ts
1924
+ function shortenModel(model) {
1925
+ if (!model) {
1926
+ return void 0;
1927
+ }
1928
+ const idx = model.lastIndexOf("/");
1929
+ if (idx === -1) {
1930
+ return model;
1931
+ }
1932
+ return model.slice(idx + 1);
1933
+ }
1934
+ function formatAgentWithModel(agentId, model) {
1935
+ const agent = agentId ?? "?";
1936
+ const short = shortenModel(model);
1937
+ if (!short) {
1938
+ return agent;
1939
+ }
1940
+ return `${agent}${AGENT_MODEL_SEP}${short}`;
1941
+ }
1942
+ function formatAgentCell(agentId, model, usage) {
1943
+ const base = formatAgentWithModel(agentId, model);
1944
+ if (!usage || typeof usage.costAmount !== "number") {
1945
+ return base;
1946
+ }
1947
+ const compact = formatCostCompact(usage.costAmount, usage.costCurrency);
1948
+ if (compact === null) {
1949
+ return base;
1950
+ }
1951
+ return `${base} ${compact}`;
1952
+ }
1953
+ function formatCost(amount, currency) {
1954
+ const sign = currency === "USD" || currency === void 0 ? "$" : "";
1955
+ const decimals = amount >= 1 ? 2 : 4;
1956
+ return `${sign}${amount.toFixed(decimals)}${currency && currency !== "USD" ? ` ${currency}` : ""}`;
1957
+ }
1958
+ function formatCostCompact(amount, currency) {
1959
+ const whole = Math.round(amount);
1960
+ if (whole === 0) {
1961
+ return null;
1962
+ }
1963
+ const sign = currency === "USD" || currency === void 0 ? "$" : "";
1964
+ return `${sign}${whole}${currency && currency !== "USD" ? ` ${currency}` : ""}`;
1965
+ }
1966
+ var AGENT_MODEL_SEP;
1967
+ var init_agent_display = __esm({
1968
+ "src/core/agent-display.ts"() {
1969
+ "use strict";
1970
+ AGENT_MODEL_SEP = "\u2022";
1971
+ }
1972
+ });
1973
+
1706
1974
  // src/cli/session-row.ts
1707
1975
  function toRow(s, now = Date.now()) {
1708
1976
  return {
1709
1977
  session: stripHydraSessionPrefix(s.sessionId),
1710
1978
  upstream: s.upstreamSessionId ?? "-",
1711
- status: (s.status ?? "live").toUpperCase(),
1712
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
1713
- agent: s.agentId ?? "?",
1979
+ state: formatState(s.status, s.attachedClients),
1980
+ agent: formatAgentCell(s.agentId, s.currentModel, s.currentUsage),
1714
1981
  age: formatRelativeAge(s.updatedAt, now),
1715
1982
  title: s.title ?? "-",
1716
1983
  cwd: s.cwd
1717
1984
  };
1718
1985
  }
1986
+ function formatState(status, clients) {
1987
+ if (status === "cold") {
1988
+ return "COLD";
1989
+ }
1990
+ return `LIVE(${clients})`;
1991
+ }
1719
1992
  function computeWidths(rows) {
1720
1993
  return {
1721
1994
  session: maxLen(HEADER.session, rows.map((r) => r.session)),
1722
1995
  upstream: maxLen(HEADER.upstream, rows.map((r) => r.upstream)),
1723
- status: maxLen(HEADER.status, rows.map((r) => r.status)),
1724
- clients: maxLen(HEADER.clients, rows.map((r) => r.clients)),
1996
+ state: maxLen(HEADER.state, rows.map((r) => r.state)),
1725
1997
  agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
1726
1998
  age: maxLen(HEADER.age, rows.map((r) => r.age)),
1727
1999
  title: maxLen(HEADER.title, rows.map((r) => r.title))
@@ -1776,8 +2048,7 @@ function formatRow(r, w, maxWidth) {
1776
2048
  const fixed = [
1777
2049
  r.session.padEnd(w.session),
1778
2050
  r.upstream.padEnd(w.upstream),
1779
- r.status.padEnd(w.status),
1780
- r.clients.padStart(w.clients),
2051
+ r.state.padEnd(w.state),
1781
2052
  r.agent.padEnd(w.agent),
1782
2053
  r.age.padStart(w.age)
1783
2054
  ].join(SEP);
@@ -1827,12 +2098,12 @@ var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
1827
2098
  var init_session_row = __esm({
1828
2099
  "src/cli/session-row.ts"() {
1829
2100
  "use strict";
2101
+ init_agent_display();
1830
2102
  init_session();
1831
2103
  HEADER = {
1832
2104
  session: "SESSION",
1833
2105
  upstream: "UPSTREAM",
1834
- status: "STATUS",
1835
- clients: "CLIENTS",
2106
+ state: "STATE",
1836
2107
  agent: "AGENT",
1837
2108
  age: "AGE",
1838
2109
  title: "TITLE",
@@ -1845,6 +2116,8 @@ var init_session_row = __esm({
1845
2116
  });
1846
2117
 
1847
2118
  // src/cli/commands/sessions.ts
2119
+ import * as fs12 from "fs/promises";
2120
+ import * as path6 from "path";
1848
2121
  async function runSessionsList(opts = {}) {
1849
2122
  const config = await loadConfig();
1850
2123
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -1933,6 +2206,111 @@ async function runSessionsRm(id) {
1933
2206
  process.stdout.write(`Removed ${id}
1934
2207
  `);
1935
2208
  }
2209
+ async function runSessionsExport(id, outPath) {
2210
+ if (!id) {
2211
+ process.stderr.write(
2212
+ "Usage: hydra-acp sessions export <session-id> [--out <file>]\n"
2213
+ );
2214
+ process.exit(2);
2215
+ }
2216
+ const config = await loadConfig();
2217
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2218
+ const response = await fetch(
2219
+ `${baseUrl}/v1/sessions/${encodeURIComponent(id)}/export`,
2220
+ {
2221
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2222
+ }
2223
+ );
2224
+ if (!response.ok) {
2225
+ const text = await response.text().catch(() => "");
2226
+ process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
2227
+ `);
2228
+ process.exit(1);
2229
+ }
2230
+ const body = await response.text();
2231
+ if (!outPath) {
2232
+ process.stdout.write(body);
2233
+ if (!body.endsWith("\n")) {
2234
+ process.stdout.write("\n");
2235
+ }
2236
+ return;
2237
+ }
2238
+ const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
2239
+ await fs12.mkdir(path6.dirname(path6.resolve(resolved)), { recursive: true });
2240
+ await fs12.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2241
+ process.stdout.write(`Wrote ${resolved}
2242
+ `);
2243
+ }
2244
+ async function runSessionsImport(file, opts = {}) {
2245
+ if (!file) {
2246
+ process.stderr.write(
2247
+ "Usage: hydra-acp sessions import <file>|- [--replace]\n"
2248
+ );
2249
+ process.exit(2);
2250
+ }
2251
+ let body;
2252
+ if (file === "-") {
2253
+ body = await readStdin();
2254
+ } else {
2255
+ body = await fs12.readFile(file, "utf8");
2256
+ }
2257
+ let bundle;
2258
+ try {
2259
+ bundle = JSON.parse(body);
2260
+ } catch (err) {
2261
+ process.stderr.write(`Failed to parse bundle: ${err.message}
2262
+ `);
2263
+ process.exit(1);
2264
+ }
2265
+ const config = await loadConfig();
2266
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2267
+ const response = await fetch(`${baseUrl}/v1/sessions/import`, {
2268
+ method: "POST",
2269
+ headers: {
2270
+ "Content-Type": "application/json",
2271
+ Authorization: `Bearer ${config.daemon.authToken}`
2272
+ },
2273
+ body: JSON.stringify({ bundle, replace: opts.replace === true })
2274
+ });
2275
+ if (response.status === 409) {
2276
+ const detail = await response.json().catch(() => ({}));
2277
+ process.stderr.write(
2278
+ `Bundle already imported as ${detail.existingSessionId ?? "unknown"}. Use --replace to overwrite.
2279
+ `
2280
+ );
2281
+ process.exit(1);
2282
+ }
2283
+ if (!response.ok) {
2284
+ const text = await response.text().catch(() => "");
2285
+ process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
2286
+ `);
2287
+ process.exit(1);
2288
+ }
2289
+ const result = await response.json();
2290
+ process.stdout.write(
2291
+ result.replaced ? `Replaced ${result.sessionId} (from ${result.importedFromSessionId})
2292
+ ` : `Imported as ${result.sessionId} (from ${result.importedFromSessionId})
2293
+ `
2294
+ );
2295
+ }
2296
+ async function readStdin() {
2297
+ const chunks = [];
2298
+ for await (const chunk of process.stdin) {
2299
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
2300
+ }
2301
+ return Buffer.concat(chunks).toString("utf8");
2302
+ }
2303
+ function deriveFilenameFrom(response, id) {
2304
+ const cd = response.headers.get("content-disposition");
2305
+ if (cd) {
2306
+ const match = cd.match(/filename="([^"]+)"/);
2307
+ if (match) {
2308
+ return match[1];
2309
+ }
2310
+ }
2311
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2312
+ return `hydra-${id}-${stamp}.hydra`;
2313
+ }
1936
2314
  function httpBase(host, port, tls) {
1937
2315
  const protocol = tls ? "https" : "http";
1938
2316
  return `${protocol}://${host}:${port}`;
@@ -1952,11 +2330,11 @@ function isResponse(msg) {
1952
2330
  return !("method" in msg) && "id" in msg && msg.id !== void 0;
1953
2331
  }
1954
2332
  async function openWs(url, subprotocols) {
1955
- return new Promise((resolve2, reject) => {
2333
+ return new Promise((resolve5, reject) => {
1956
2334
  const ws = new WebSocket(url, subprotocols);
1957
2335
  const onOpen = () => {
1958
2336
  ws.off("error", onError);
1959
- resolve2(wsToMessageStream(ws));
2337
+ resolve5(wsToMessageStream(ws));
1960
2338
  };
1961
2339
  const onError = (err) => {
1962
2340
  ws.off("open", onOpen);
@@ -2027,8 +2405,8 @@ var init_resilient_ws = __esm({
2027
2405
  throw new Error("resilient ws stream not connected");
2028
2406
  }
2029
2407
  const id = message.id;
2030
- const promise = new Promise((resolve2, reject) => {
2031
- this.pendingRequests.set(id, { resolve: resolve2, reject });
2408
+ const promise = new Promise((resolve5, reject) => {
2409
+ this.pendingRequests.set(id, { resolve: resolve5, reject });
2032
2410
  });
2033
2411
  try {
2034
2412
  await this.current.send(message);
@@ -2056,8 +2434,8 @@ var init_resilient_ws = __esm({
2056
2434
  this.bindStream(stream);
2057
2435
  const wasFirst = this.firstConnect;
2058
2436
  this.firstConnect = false;
2059
- this.connectGate = new Promise((resolve2) => {
2060
- this.releaseConnectGate = resolve2;
2437
+ this.connectGate = new Promise((resolve5) => {
2438
+ this.releaseConnectGate = resolve5;
2061
2439
  });
2062
2440
  try {
2063
2441
  if (this.opts.onConnect) {
@@ -2180,64 +2558,6 @@ var init_resilient_ws = __esm({
2180
2558
  }
2181
2559
  });
2182
2560
 
2183
- // src/tui/history.ts
2184
- import { promises as fs10 } from "fs";
2185
- import * as path4 from "path";
2186
- async function loadHistory(file) {
2187
- let text;
2188
- try {
2189
- text = await fs10.readFile(file, "utf8");
2190
- } catch (err) {
2191
- if (err.code === "ENOENT") {
2192
- return [];
2193
- }
2194
- throw err;
2195
- }
2196
- return parseHistory(text);
2197
- }
2198
- function parseHistory(text) {
2199
- const out = [];
2200
- for (const rawLine of text.split("\n")) {
2201
- if (rawLine.length === 0) {
2202
- continue;
2203
- }
2204
- try {
2205
- const decoded = JSON.parse(rawLine);
2206
- if (typeof decoded === "string") {
2207
- out.push(decoded);
2208
- }
2209
- } catch {
2210
- }
2211
- }
2212
- return out;
2213
- }
2214
- function appendEntry(history, entry) {
2215
- const trimmed = entry.replace(/\n+$/, "");
2216
- if (trimmed.length === 0) {
2217
- return history;
2218
- }
2219
- if (history.length > 0 && history[history.length - 1] === trimmed) {
2220
- return history;
2221
- }
2222
- const out = history.concat(trimmed);
2223
- if (out.length > HISTORY_CAP) {
2224
- return out.slice(out.length - HISTORY_CAP);
2225
- }
2226
- return out;
2227
- }
2228
- async function saveHistory(file, history) {
2229
- await fs10.mkdir(path4.dirname(file), { recursive: true });
2230
- const lines = history.map((entry) => JSON.stringify(entry));
2231
- await fs10.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2232
- }
2233
- var HISTORY_CAP;
2234
- var init_history = __esm({
2235
- "src/tui/history.ts"() {
2236
- "use strict";
2237
- HISTORY_CAP = 500;
2238
- }
2239
- });
2240
-
2241
2561
  // src/tui/discovery.ts
2242
2562
  async function listSessions(config, opts = {}, fetchImpl = fetch) {
2243
2563
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -2266,6 +2586,8 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
2266
2586
  status: s.status ?? "live",
2267
2587
  upstreamSessionId: s.upstreamSessionId,
2268
2588
  agentId: s.agentId,
2589
+ currentModel: s.currentModel,
2590
+ currentUsage: s.currentUsage,
2269
2591
  title: s.title
2270
2592
  }));
2271
2593
  }
@@ -2468,7 +2790,7 @@ async function pickSession(term, opts) {
2468
2790
  };
2469
2791
  renderFromScratch();
2470
2792
  term.hideCursor();
2471
- return await new Promise((resolve2) => {
2793
+ return await new Promise((resolve5) => {
2472
2794
  let resolved = false;
2473
2795
  const onResize = () => {
2474
2796
  if (resolved) {
@@ -2590,6 +2912,11 @@ async function pickSession(term, opts) {
2590
2912
  }
2591
2913
  clearTransient();
2592
2914
  if (data?.isCharacter) {
2915
+ if (name === "r" || name === "R") {
2916
+ const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
2917
+ void refresh(currentId);
2918
+ return;
2919
+ }
2593
2920
  if ((name === "k" || name === "K") && selectedIdx > 0) {
2594
2921
  const session = visible[selectedIdx - 1];
2595
2922
  if (!session) {
@@ -2650,12 +2977,12 @@ async function pickSession(term, opts) {
2650
2977
  case "KP_ENTER": {
2651
2978
  cleanup();
2652
2979
  if (selectedIdx === 0) {
2653
- resolve2({ kind: "new" });
2980
+ resolve5({ kind: "new" });
2654
2981
  return;
2655
2982
  }
2656
2983
  const session = visible[selectedIdx - 1];
2657
2984
  if (!session) {
2658
- resolve2({ kind: "abort" });
2985
+ resolve5({ kind: "abort" });
2659
2986
  return;
2660
2987
  }
2661
2988
  const result = {
@@ -2665,13 +2992,13 @@ async function pickSession(term, opts) {
2665
2992
  if (session.agentId !== void 0) {
2666
2993
  result.agentId = session.agentId;
2667
2994
  }
2668
- resolve2(result);
2995
+ resolve5(result);
2669
2996
  return;
2670
2997
  }
2671
2998
  case "ESCAPE":
2672
2999
  case "CTRL_C":
2673
3000
  cleanup();
2674
- resolve2({ kind: "abort" });
3001
+ resolve5({ kind: "abort" });
2675
3002
  return;
2676
3003
  }
2677
3004
  };
@@ -2703,6 +3030,7 @@ var init_picker = __esm({
2703
3030
  });
2704
3031
 
2705
3032
  // src/tui/screen.ts
3033
+ import os3 from "os";
2706
3034
  import stringWidth from "string-width";
2707
3035
  import wrapAnsi from "wrap-ansi";
2708
3036
  function formattedLineSig(zone, width, line) {
@@ -2853,6 +3181,12 @@ function wrap(text, width) {
2853
3181
  if (text.length === 0) {
2854
3182
  return [""];
2855
3183
  }
3184
+ if (!NON_ASCII.test(text)) {
3185
+ return wrapAscii(text, width);
3186
+ }
3187
+ return wrapVisible(text, width);
3188
+ }
3189
+ function wrapAscii(text, width) {
2856
3190
  const out = [];
2857
3191
  let remaining = text;
2858
3192
  while (remaining.length > width) {
@@ -2875,14 +3209,91 @@ function wrap(text, width) {
2875
3209
  out.push(remaining);
2876
3210
  return out;
2877
3211
  }
3212
+ function wrapVisible(text, width) {
3213
+ const out = [];
3214
+ const graphemes = [];
3215
+ for (const { segment } of SEGMENTER.segment(text)) {
3216
+ graphemes.push({ seg: segment, w: stringWidth(segment) });
3217
+ }
3218
+ let i = 0;
3219
+ while (i < graphemes.length) {
3220
+ let chunk = "";
3221
+ let chunkW = 0;
3222
+ let lastSpaceI = -1;
3223
+ let chunkAtLastSpace = "";
3224
+ while (i < graphemes.length) {
3225
+ const g = graphemes[i];
3226
+ if (chunkW + g.w > width) {
3227
+ break;
3228
+ }
3229
+ if (g.seg === " ") {
3230
+ lastSpaceI = i;
3231
+ chunkAtLastSpace = chunk;
3232
+ }
3233
+ chunk += g.seg;
3234
+ chunkW += g.w;
3235
+ i += 1;
3236
+ }
3237
+ if (i >= graphemes.length) {
3238
+ out.push(chunk);
3239
+ break;
3240
+ }
3241
+ if (lastSpaceI >= 0) {
3242
+ out.push(chunkAtLastSpace);
3243
+ i = lastSpaceI + 1;
3244
+ } else if (chunk.length === 0) {
3245
+ out.push(graphemes[i].seg);
3246
+ i += 1;
3247
+ } else {
3248
+ out.push(chunk);
3249
+ }
3250
+ }
3251
+ return out;
3252
+ }
3253
+ function shortenHomePath(p) {
3254
+ const home = os3.homedir();
3255
+ if (!home) {
3256
+ return p;
3257
+ }
3258
+ if (p === home) {
3259
+ return "~";
3260
+ }
3261
+ if (p.startsWith(home + "/")) {
3262
+ return "~" + p.slice(home.length);
3263
+ }
3264
+ return p;
3265
+ }
2878
3266
  function truncate(text, max) {
2879
- if (text.length <= max) {
3267
+ if (max <= 0) {
3268
+ return "";
3269
+ }
3270
+ if (text.length <= max && !NON_ASCII.test(text)) {
3271
+ return text;
3272
+ }
3273
+ const visible = stringWidth(text);
3274
+ if (visible <= max) {
2880
3275
  return text;
2881
3276
  }
2882
3277
  if (max <= 1) {
2883
- return text.slice(0, max);
3278
+ return takeByWidth(text, max);
3279
+ }
3280
+ return takeByWidth(text, max - 1) + "\u2026";
3281
+ }
3282
+ function takeByWidth(text, budget) {
3283
+ if (budget <= 0) {
3284
+ return "";
3285
+ }
3286
+ let out = "";
3287
+ let used = 0;
3288
+ for (const { segment } of SEGMENTER.segment(text)) {
3289
+ const w = stringWidth(segment);
3290
+ if (used + w > budget) {
3291
+ break;
3292
+ }
3293
+ out += segment;
3294
+ used += w;
2884
3295
  }
2885
- return text.slice(0, max - 1) + "\u2026";
3296
+ return out;
2886
3297
  }
2887
3298
  function firstLine2(text) {
2888
3299
  const idx = text.indexOf("\n");
@@ -2930,11 +3341,6 @@ function formatTokens(n) {
2930
3341
  }
2931
3342
  return `${n}`;
2932
3343
  }
2933
- function formatCost(amount, currency) {
2934
- const sign = currency === "USD" || currency === void 0 ? "$" : "";
2935
- const decimals = amount >= 1 ? 2 : 4;
2936
- return `${sign}${amount.toFixed(decimals)}${currency && currency !== "USD" ? ` ${currency}` : ""}`;
2937
- }
2938
3344
  function mapKeyName(name) {
2939
3345
  switch (name) {
2940
3346
  case "ENTER":
@@ -2997,10 +3403,11 @@ function mapKeyName(name) {
2997
3403
  return null;
2998
3404
  }
2999
3405
  }
3000
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, shortId;
3406
+ 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;
3001
3407
  var init_screen = __esm({
3002
3408
  "src/tui/screen.ts"() {
3003
3409
  "use strict";
3410
+ init_agent_display();
3004
3411
  init_session();
3005
3412
  HEADER_ROWS = 2;
3006
3413
  BANNER_ROWS = 1;
@@ -3100,6 +3507,11 @@ var init_screen = __esm({
3100
3507
  }
3101
3508
  this.started = true;
3102
3509
  this.term.fullscreen(true);
3510
+ this.lastFrameRows.clear();
3511
+ this.lastFrameW = 0;
3512
+ this.lastFrameH = 0;
3513
+ this.lastWindowTitle = null;
3514
+ process.stdout.write("\x1B[?7l");
3103
3515
  this.term.grabInput({ mouse: "button" });
3104
3516
  this.term.hideCursor(false);
3105
3517
  this.term.on("key", this.keyHandler);
@@ -3119,6 +3531,7 @@ var init_screen = __esm({
3119
3531
  this.term.off("resize", this.resizeHandler);
3120
3532
  this.term.grabInput(false);
3121
3533
  this.term.hideCursor(false);
3534
+ process.stdout.write("\x1B[?7h");
3122
3535
  this.term.fullscreen(false);
3123
3536
  this.term("\n");
3124
3537
  }
@@ -3430,6 +3843,22 @@ var init_screen = __esm({
3430
3843
  redraw() {
3431
3844
  this.repaint();
3432
3845
  }
3846
+ // Forced clean-slate repaint. Drops the per-row signature cache, the
3847
+ // window-title cache, the wrap cache, and clears the terminal before
3848
+ // painting. Wired to ^L so the user can recover when something has
3849
+ // corrupted the visible state and the per-row sig check otherwise
3850
+ // short-circuits the re-emit.
3851
+ fullRedraw() {
3852
+ this.lastFrameRows.clear();
3853
+ this.lastFrameW = 0;
3854
+ this.lastFrameH = 0;
3855
+ this.lastWindowTitle = null;
3856
+ this.wrapCache.clear();
3857
+ this.wrapCacheWidth = 0;
3858
+ process.stdout.write("\x1B[?7l");
3859
+ this.term.clear();
3860
+ this.repaint();
3861
+ }
3433
3862
  // While paused, append* methods buffer state but don't repaint. Calls are
3434
3863
  // counter-based so they nest safely. Resume triggers exactly one repaint
3435
3864
  // if any was requested while paused.
@@ -3656,22 +4085,23 @@ var init_screen = __esm({
3656
4085
  const usage = formatUsage(this.header.usage);
3657
4086
  const sid = shortId(this.header.sessionId);
3658
4087
  const title = this.header.title?.trim();
3659
- const sig = `hdr|${w}|${this.header.agent}|${this.header.cwd}|${sid}|${title ?? ""}|${usage ?? ""}`;
4088
+ const agentCell = formatAgentWithModel(this.header.agent, this.header.model);
4089
+ const cwdDisplay = shortenHomePath(this.header.cwd);
4090
+ const sig = `hdr|${w}|${agentCell}|${cwdDisplay}|${sid}|${title ?? ""}|${usage ?? ""}`;
3660
4091
  this.paintRow(1, sig, () => {
3661
- const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
4092
+ const fixed = "hydra \xB7 ".length + agentCell.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
3662
4093
  const variableRoom = Math.max(8, w - fixed);
3663
4094
  let cwdRoom;
3664
4095
  let titleRoom;
3665
4096
  if (title) {
3666
- const cwdMin = Math.min(this.header.cwd.length, 12);
3667
- const titleCap = Math.max(8, variableRoom - cwdMin);
3668
- titleRoom = Math.min(title.length, titleCap);
3669
- cwdRoom = Math.max(8, variableRoom - titleRoom);
4097
+ const titleMin = Math.min(title.length, 8);
4098
+ cwdRoom = Math.min(cwdDisplay.length, Math.max(8, variableRoom - titleMin));
4099
+ titleRoom = Math.max(0, variableRoom - cwdRoom);
3670
4100
  } else {
3671
4101
  titleRoom = 0;
3672
4102
  cwdRoom = variableRoom;
3673
4103
  }
3674
- this.term.bold("hydra")(" \xB7 ").cyan.noFormat(this.header.agent)(" \xB7 ").dim.noFormat(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
4104
+ this.term.bold("hydra")(" \xB7 ").cyan.noFormat(agentCell)(" \xB7 ").dim.noFormat(truncate(cwdDisplay, cwdRoom))(" \xB7 ").yellow(sid);
3675
4105
  if (title) {
3676
4106
  this.term(" \xB7 ").bold.noFormat(truncate(title, titleRoom));
3677
4107
  }
@@ -4083,6 +4513,8 @@ var init_screen = __esm({
4083
4513
  }
4084
4514
  }
4085
4515
  };
4516
+ NON_ASCII = /[^\x20-\x7e]/;
4517
+ SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
4086
4518
  shortId = stripHydraSessionPrefix;
4087
4519
  }
4088
4520
  });
@@ -4457,6 +4889,10 @@ var init_input = __esm({
4457
4889
  });
4458
4890
 
4459
4891
  // src/tui/render-update.ts
4892
+ import stripAnsi from "strip-ansi";
4893
+ function sanitizeWireText(text) {
4894
+ return stripAnsi(text).replace(STRIP_CONTROLS, "");
4895
+ }
4460
4896
  function mapUpdate(update) {
4461
4897
  if (!update || typeof update !== "object") {
4462
4898
  return null;
@@ -4499,11 +4935,30 @@ function mapUpdate(update) {
4499
4935
  }
4500
4936
  }
4501
4937
  function mapSessionInfo(u) {
4502
- const title = readString(u, "title");
4503
- if (title === void 0) {
4938
+ const rawTitle = readString(u, "title");
4939
+ const title = rawTitle !== void 0 ? sanitizeWireText(rawTitle) : void 0;
4940
+ const meta = u._meta;
4941
+ let agentId;
4942
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4943
+ const ns = meta["hydra-acp"];
4944
+ if (ns && typeof ns === "object" && !Array.isArray(ns)) {
4945
+ const candidate = ns.agentId;
4946
+ if (typeof candidate === "string") {
4947
+ agentId = candidate;
4948
+ }
4949
+ }
4950
+ }
4951
+ if (title === void 0 && agentId === void 0) {
4504
4952
  return null;
4505
4953
  }
4506
- return { kind: "session-info", title };
4954
+ const event = { kind: "session-info" };
4955
+ if (title !== void 0) {
4956
+ event.title = title;
4957
+ }
4958
+ if (agentId !== void 0) {
4959
+ event.agentId = agentId;
4960
+ }
4961
+ return event;
4507
4962
  }
4508
4963
  function mapAvailableCommands(u) {
4509
4964
  const list = u.availableCommands ?? u.commands;
@@ -4519,10 +4974,10 @@ function mapAvailableCommands(u) {
4519
4974
  if (typeof c.name !== "string" || c.name.length === 0) {
4520
4975
  continue;
4521
4976
  }
4522
- const name = c.name.startsWith("/") ? c.name : `/${c.name}`;
4523
- const cmd = { name };
4977
+ const rawName = c.name.startsWith("/") ? c.name : `/${c.name}`;
4978
+ const cmd = { name: sanitizeWireText(rawName) };
4524
4979
  if (typeof c.description === "string") {
4525
- cmd.description = c.description;
4980
+ cmd.description = sanitizeWireText(c.description);
4526
4981
  }
4527
4982
  out.push(cmd);
4528
4983
  }
@@ -4555,7 +5010,7 @@ function mapAgentText(u) {
4555
5010
  return { kind: "agent-text", text };
4556
5011
  }
4557
5012
  function mapAgentThought(u) {
4558
- const text = typeof u.text === "string" ? u.text : extractContentText(u.content);
5013
+ const text = typeof u.text === "string" ? sanitizeWireText(u.text) : extractContentText(u.content);
4559
5014
  if (text === null) {
4560
5015
  return null;
4561
5016
  }
@@ -4587,7 +5042,8 @@ function mapToolCall(u) {
4587
5042
  if (!toolCallId) {
4588
5043
  return null;
4589
5044
  }
4590
- const title = readString(u, "title") ?? readString(u, "name") ?? readString(u, "label") ?? "tool call";
5045
+ const rawTitle = readString(u, "title") ?? readString(u, "name") ?? readString(u, "label") ?? "tool call";
5046
+ const title = sanitizeWireText(rawTitle);
4591
5047
  const status = readString(u, "status");
4592
5048
  const rawKind = readString(u, "kind");
4593
5049
  const event = { kind: "tool-call", toolCallId, title };
@@ -4604,7 +5060,8 @@ function mapToolCallUpdate(u) {
4604
5060
  if (!toolCallId) {
4605
5061
  return null;
4606
5062
  }
4607
- const title = readString(u, "title");
5063
+ const rawTitle = readString(u, "title");
5064
+ const title = rawTitle !== void 0 ? sanitizeWireText(rawTitle) : void 0;
4608
5065
  const status = readString(u, "status");
4609
5066
  const meaningful = title !== void 0 || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
4610
5067
  if (!meaningful) {
@@ -4630,7 +5087,7 @@ function mapPlan(u) {
4630
5087
  continue;
4631
5088
  }
4632
5089
  const e = raw;
4633
- const content = typeof e.content === "string" ? e.content : void 0;
5090
+ const content = typeof e.content === "string" ? sanitizeWireText(e.content) : void 0;
4634
5091
  if (!content) {
4635
5092
  continue;
4636
5093
  }
@@ -4650,14 +5107,14 @@ function mapMode(u) {
4650
5107
  if (!mode) {
4651
5108
  return null;
4652
5109
  }
4653
- return { kind: "mode-changed", mode };
5110
+ return { kind: "mode-changed", mode: sanitizeWireText(mode) };
4654
5111
  }
4655
5112
  function mapModel(u) {
4656
5113
  const model = readString(u, "currentModel") ?? readString(u, "model");
4657
5114
  if (!model) {
4658
5115
  return null;
4659
5116
  }
4660
- return { kind: "model-changed", model };
5117
+ return { kind: "model-changed", model: sanitizeWireText(model) };
4661
5118
  }
4662
5119
  function mapTurnComplete(u) {
4663
5120
  const stopReason = readString(u, "stopReason");
@@ -4665,17 +5122,17 @@ function mapTurnComplete(u) {
4665
5122
  }
4666
5123
  function extractContentText(content) {
4667
5124
  if (typeof content === "string") {
4668
- return content;
5125
+ return sanitizeWireText(content);
4669
5126
  }
4670
5127
  if (!content || typeof content !== "object") {
4671
5128
  return null;
4672
5129
  }
4673
5130
  const c = content;
4674
5131
  if (c.type === "text" && typeof c.text === "string") {
4675
- return c.text;
5132
+ return sanitizeWireText(c.text);
4676
5133
  }
4677
5134
  if (typeof c.text === "string") {
4678
- return c.text;
5135
+ return sanitizeWireText(c.text);
4679
5136
  }
4680
5137
  return null;
4681
5138
  }
@@ -4699,9 +5156,11 @@ function readString(u, key) {
4699
5156
  const v = u[key];
4700
5157
  return typeof v === "string" ? v : void 0;
4701
5158
  }
5159
+ var STRIP_CONTROLS;
4702
5160
  var init_render_update = __esm({
4703
5161
  "src/tui/render-update.ts"() {
4704
5162
  "use strict";
5163
+ STRIP_CONTROLS = /[\x00-\x08\x0b-\x1f\x7f]/g;
4705
5164
  }
4706
5165
  });
4707
5166
 
@@ -4907,8 +5366,9 @@ function formatToolLine(state) {
4907
5366
  title = `${initial} \xB7 ${latest}`;
4908
5367
  }
4909
5368
  return {
4910
- prefix: " ",
4911
- body: `${toolStatusIcon(state.status)} ${title}`,
5369
+ prefix: ` ${toolStatusIcon(state.status)} `,
5370
+ prefixStyle: toolIconStyle(state.status),
5371
+ body: title,
4912
5372
  bodyStyle: toolStatusStyle(state.status)
4913
5373
  };
4914
5374
  }
@@ -4933,6 +5393,22 @@ function toolStatusIcon(status) {
4933
5393
  return "\u25D0";
4934
5394
  }
4935
5395
  }
5396
+ function toolIconStyle(status) {
5397
+ switch (status) {
5398
+ case "completed":
5399
+ case "succeeded":
5400
+ case "ok":
5401
+ return "tool-status-ok";
5402
+ case "failed":
5403
+ case "error":
5404
+ case "rejected":
5405
+ return "tool-status-fail";
5406
+ case "cancelled":
5407
+ return "tool-status-cancelled";
5408
+ default:
5409
+ return "tool-status-running";
5410
+ }
5411
+ }
4936
5412
  function formatPlan(event) {
4937
5413
  if (event.entries.length === 0) {
4938
5414
  return [
@@ -5019,12 +5495,18 @@ async function runTuiApp(opts) {
5019
5495
  const config = await ensureConfig();
5020
5496
  await ensureDaemonReachable(config);
5021
5497
  const term = termkit.terminal;
5498
+ const exitHint = {};
5022
5499
  let nextOpts = opts;
5023
5500
  while (nextOpts !== null) {
5024
- nextOpts = await runSession(term, config, nextOpts);
5501
+ nextOpts = await runSession(term, config, nextOpts, exitHint);
5502
+ }
5503
+ if (exitHint.sessionId) {
5504
+ const short = stripHydraSessionPrefix(exitHint.sessionId);
5505
+ process.stdout.write(`To resume: hydra-acp tui --resume ${short}
5506
+ `);
5025
5507
  }
5026
5508
  }
5027
- async function runSession(term, config, opts) {
5509
+ async function runSession(term, config, opts, exitHint) {
5028
5510
  const ctx = await resolveSession(term, config, opts);
5029
5511
  if (!ctx) {
5030
5512
  term.grabInput(false);
@@ -5132,10 +5614,10 @@ async function runSession(term, config, opts) {
5132
5614
  if (pendingPermission.toolCallId && toolCallId && pendingPermission.toolCallId !== toolCallId) {
5133
5615
  return;
5134
5616
  }
5135
- const resolve2 = pendingPermission.resolve;
5617
+ const resolve5 = pendingPermission.resolve;
5136
5618
  pendingPermission = null;
5137
5619
  screen.setPermissionPrompt(null);
5138
- resolve2(result ?? { outcome: { outcome: "cancelled" } });
5620
+ resolve5(result ?? { outcome: { outcome: "cancelled" } });
5139
5621
  };
5140
5622
  const maybeDismissPermissionByToolUpdate = (update) => {
5141
5623
  if (!pendingPermission?.toolCallId) {
@@ -5168,20 +5650,26 @@ async function runSession(term, config, opts) {
5168
5650
  if (!pendingPermission) {
5169
5651
  return;
5170
5652
  }
5171
- const { options, resolve: resolve2 } = pendingPermission;
5653
+ const { options, resolve: resolve5 } = pendingPermission;
5172
5654
  pendingPermission = null;
5173
5655
  screen.setPermissionPrompt(null);
5174
5656
  if (optionId === null) {
5175
- resolve2({ outcome: { outcome: "cancelled" } });
5657
+ resolve5({ outcome: { outcome: "cancelled" } });
5176
5658
  return;
5177
5659
  }
5178
- resolve2({ outcome: { outcome: "selected", optionId } });
5660
+ resolve5({ outcome: { outcome: "selected", optionId } });
5179
5661
  void options;
5180
5662
  };
5181
5663
  conn.onRequest("session/request_permission", async (params) => {
5182
5664
  const p = params ?? {};
5183
- const options = Array.isArray(p.options) ? p.options : [];
5184
- const title = p.toolCall?.title ?? p.toolCall?.name ?? "tool";
5665
+ const rawOptions = Array.isArray(p.options) ? p.options : [];
5666
+ const options = rawOptions.map((o) => ({
5667
+ optionId: o.optionId,
5668
+ name: sanitizeWireText(o.name ?? ""),
5669
+ ...o.kind !== void 0 ? { kind: o.kind } : {}
5670
+ }));
5671
+ const rawTitle = p.toolCall?.title ?? p.toolCall?.name ?? "tool";
5672
+ const title = sanitizeWireText(rawTitle);
5185
5673
  const toolCallId = p.toolCall?.toolCallId;
5186
5674
  if (options.length === 0) {
5187
5675
  screen.appendLines([
@@ -5193,12 +5681,12 @@ async function runSession(term, config, opts) {
5193
5681
  ]);
5194
5682
  return { outcome: { outcome: "cancelled" } };
5195
5683
  }
5196
- return new Promise((resolve2) => {
5684
+ return new Promise((resolve5) => {
5197
5685
  pendingPermission = {
5198
5686
  title,
5199
5687
  options,
5200
5688
  selectedIndex: 0,
5201
- resolve: resolve2,
5689
+ resolve: resolve5,
5202
5690
  toolCallId
5203
5691
  };
5204
5692
  refreshPermissionPrompt();
@@ -5236,6 +5724,7 @@ async function runSession(term, config, opts) {
5236
5724
  ...opts.name ? { _meta: { [HYDRA_META_KEY]: { name: opts.name } } } : {}
5237
5725
  });
5238
5726
  resolvedSessionId = created.sessionId;
5727
+ exitHint.sessionId = resolvedSessionId;
5239
5728
  const hydraMeta = extractHydraMeta(created._meta ?? void 0);
5240
5729
  upstreamSessionId = hydraMeta.upstreamSessionId;
5241
5730
  if (hydraMeta.agentId) {
@@ -5262,6 +5751,7 @@ async function runSession(term, config, opts) {
5262
5751
  clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
5263
5752
  });
5264
5753
  resolvedSessionId = attached.sessionId;
5754
+ exitHint.sessionId = resolvedSessionId;
5265
5755
  const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
5266
5756
  upstreamSessionId = hydraMeta.upstreamSessionId;
5267
5757
  if (hydraMeta.agentId) {
@@ -5449,17 +5939,15 @@ async function runSession(term, config, opts) {
5449
5939
  agent: headerName,
5450
5940
  cwd: resolvedCwd,
5451
5941
  sessionId: resolvedSessionId,
5452
- title: resolvedTitle
5942
+ title: resolvedTitle,
5943
+ model: initialModel
5453
5944
  });
5454
5945
  if (initialMode) {
5455
5946
  screen.appendLines(formatEvent({ kind: "mode-changed", mode: initialMode }));
5456
5947
  }
5457
- if (initialModel) {
5458
- screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
5459
- }
5460
5948
  let finishSession = null;
5461
- const sessionDone = new Promise((resolve2) => {
5462
- finishSession = resolve2;
5949
+ const sessionDone = new Promise((resolve5) => {
5950
+ finishSession = resolve5;
5463
5951
  });
5464
5952
  const cancelRemoteTurn = () => {
5465
5953
  conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
@@ -5631,7 +6119,7 @@ async function runSession(term, config, opts) {
5631
6119
  screen.setBanner({});
5632
6120
  return;
5633
6121
  case "redraw":
5634
- screen.redraw();
6122
+ screen.fullRedraw();
5635
6123
  return;
5636
6124
  case "switch-session":
5637
6125
  void switchSession();
@@ -5957,6 +6445,10 @@ async function runSession(term, config, opts) {
5957
6445
  if (event.title !== void 0) {
5958
6446
  screen.setHeader({ title: event.title });
5959
6447
  }
6448
+ if (event.agentId !== void 0 && event.agentId !== resolvedAgentId) {
6449
+ resolvedAgentId = event.agentId;
6450
+ screen.setHeader({ agent: event.agentId });
6451
+ }
5960
6452
  return;
5961
6453
  }
5962
6454
  if (event.kind === "usage-update") {
@@ -6028,6 +6520,9 @@ async function runSession(term, config, opts) {
6028
6520
  renderToolsBlock();
6029
6521
  return;
6030
6522
  }
6523
+ if (event.kind === "model-changed") {
6524
+ screen.setHeader({ model: event.model });
6525
+ }
6031
6526
  const formatted = formatEvent(event);
6032
6527
  if (formatted.length > 0) {
6033
6528
  screen.appendLines(formatted);
@@ -6077,10 +6572,10 @@ async function runSession(term, config, opts) {
6077
6572
  }
6078
6573
  const resetInFlightUiState = () => {
6079
6574
  if (pendingPermission) {
6080
- const resolve2 = pendingPermission.resolve;
6575
+ const resolve5 = pendingPermission.resolve;
6081
6576
  pendingPermission = null;
6082
6577
  screen.setPermissionPrompt(null);
6083
- resolve2({ outcome: { outcome: "cancelled" } });
6578
+ resolve5({ outcome: { outcome: "cancelled" } });
6084
6579
  }
6085
6580
  closeAgentText();
6086
6581
  if (toolsBlockStartedAt !== null) {
@@ -6268,6 +6763,7 @@ var init_app = __esm({
6268
6763
  init_resilient_ws();
6269
6764
  init_config();
6270
6765
  init_daemon_bootstrap();
6766
+ init_session();
6271
6767
  init_paths();
6272
6768
  init_history();
6273
6769
  init_discovery();
@@ -6293,6 +6789,11 @@ var init_tui = __esm({
6293
6789
  }
6294
6790
  });
6295
6791
 
6792
+ // src/cli.ts
6793
+ import { readFileSync } from "fs";
6794
+ import { fileURLToPath } from "url";
6795
+ import { dirname as dirname4, resolve as resolve4 } from "path";
6796
+
6296
6797
  // src/cli/parse-args.ts
6297
6798
  function parseArgs(argv) {
6298
6799
  const positional = [];
@@ -6362,8 +6863,7 @@ async function runInit(flags) {
6362
6863
  existing = void 0;
6363
6864
  }
6364
6865
  if (!existing) {
6365
- const config = defaultConfig();
6366
- await writeConfig(config);
6866
+ const config = await writeMinimalInitConfig();
6367
6867
  process.stdout.write(
6368
6868
  `Initialized ${paths.config()}
6369
6869
  Auth token: ${config.daemon.authToken}
@@ -6372,11 +6872,14 @@ Auth token: ${config.daemon.authToken}
6372
6872
  return;
6373
6873
  }
6374
6874
  if (flagBool(flags, "rotate-token")) {
6375
- existing.daemon.authToken = generateAuthToken();
6376
- await writeConfig(existing);
6875
+ const newToken = generateAuthToken();
6876
+ await updateConfigField((raw) => {
6877
+ const daemon = raw.daemon ??= {};
6878
+ daemon.authToken = newToken;
6879
+ });
6377
6880
  process.stdout.write(
6378
6881
  `Rotated token in ${paths.config()}
6379
- New token: ${existing.daemon.authToken}
6882
+ New token: ${newToken}
6380
6883
  `
6381
6884
  );
6382
6885
  return;
@@ -6389,13 +6892,13 @@ New token: ${existing.daemon.authToken}
6389
6892
  // src/cli/commands/daemon.ts
6390
6893
  init_paths();
6391
6894
  init_config();
6392
- import * as fsp4 from "fs/promises";
6895
+ import * as fsp5 from "fs/promises";
6393
6896
  import { setTimeout as sleep2 } from "timers/promises";
6394
6897
 
6395
6898
  // src/daemon/server.ts
6396
6899
  init_config();
6397
- import * as fs8 from "fs";
6398
- import * as fsp2 from "fs/promises";
6900
+ import * as fs10 from "fs";
6901
+ import * as fsp3 from "fs/promises";
6399
6902
  import Fastify from "fastify";
6400
6903
  import websocketPlugin from "@fastify/websocket";
6401
6904
  import pino from "pino";
@@ -6403,8 +6906,214 @@ import createPinoRoll from "pino-roll";
6403
6906
 
6404
6907
  // src/core/registry.ts
6405
6908
  init_paths();
6406
- import * as fs3 from "fs/promises";
6909
+ import * as fs4 from "fs/promises";
6407
6910
  import { z as z2 } from "zod";
6911
+
6912
+ // src/core/binary-install.ts
6913
+ init_paths();
6914
+ import * as fs3 from "fs";
6915
+ import * as fsp from "fs/promises";
6916
+ import * as path2 from "path";
6917
+ import { spawn } from "child_process";
6918
+ import { Readable } from "stream";
6919
+ function currentPlatformKey() {
6920
+ const osPart = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : void 0;
6921
+ const archPart = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : void 0;
6922
+ if (!osPart || !archPart) {
6923
+ return void 0;
6924
+ }
6925
+ return `${osPart}-${archPart}`;
6926
+ }
6927
+ function pickBinaryTarget(distribution, platformKey = currentPlatformKey()) {
6928
+ if (!platformKey) {
6929
+ return void 0;
6930
+ }
6931
+ return distribution[platformKey];
6932
+ }
6933
+ var logSink = (msg) => {
6934
+ process.stderr.write(msg + "\n");
6935
+ };
6936
+ function setBinaryInstallLogger(log) {
6937
+ logSink = log ?? ((msg) => process.stderr.write(msg + "\n"));
6938
+ }
6939
+ async function ensureBinary(args) {
6940
+ if (!args.target.archive) {
6941
+ throw new Error(
6942
+ `Agent ${args.agentId} has no archive URL for ${currentPlatformKey() ?? "this platform"}`
6943
+ );
6944
+ }
6945
+ if (!args.target.cmd) {
6946
+ throw new Error(`Agent ${args.agentId} has no cmd in its binary target`);
6947
+ }
6948
+ const platformKey = currentPlatformKey();
6949
+ if (!platformKey) {
6950
+ throw new Error(
6951
+ `Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
6952
+ );
6953
+ }
6954
+ const installDir = paths.agentInstallDir(
6955
+ args.agentId,
6956
+ platformKey,
6957
+ args.version
6958
+ );
6959
+ const cmdPath = path2.resolve(installDir, args.target.cmd);
6960
+ if (await fileExists(cmdPath)) {
6961
+ return cmdPath;
6962
+ }
6963
+ await downloadAndExtract({
6964
+ agentId: args.agentId,
6965
+ archiveUrl: args.target.archive,
6966
+ installDir
6967
+ });
6968
+ if (!await fileExists(cmdPath)) {
6969
+ throw new Error(
6970
+ `Agent ${args.agentId}: extracted archive did not contain ${args.target.cmd} (looked in ${installDir})`
6971
+ );
6972
+ }
6973
+ if (process.platform !== "win32") {
6974
+ await fsp.chmod(cmdPath, 493).catch(() => void 0);
6975
+ }
6976
+ return cmdPath;
6977
+ }
6978
+ async function downloadAndExtract(args) {
6979
+ await fsp.mkdir(path2.dirname(args.installDir), { recursive: true });
6980
+ const tempDir = await fsp.mkdtemp(`${args.installDir}.partial-`);
6981
+ try {
6982
+ logSink(`hydra-acp: downloading ${args.agentId} from ${args.archiveUrl}`);
6983
+ const archivePath = await downloadTo({
6984
+ url: args.archiveUrl,
6985
+ dir: tempDir,
6986
+ agentId: args.agentId
6987
+ });
6988
+ logSink(`hydra-acp: extracting ${args.agentId}`);
6989
+ await extract(archivePath, tempDir);
6990
+ await fsp.unlink(archivePath).catch(() => void 0);
6991
+ try {
6992
+ await fsp.rename(tempDir, args.installDir);
6993
+ } catch (err) {
6994
+ const e = err;
6995
+ if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists(args.installDir)) {
6996
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(
6997
+ () => void 0
6998
+ );
6999
+ return;
7000
+ }
7001
+ throw err;
7002
+ }
7003
+ logSink(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
7004
+ } catch (err) {
7005
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
7006
+ throw err;
7007
+ }
7008
+ }
7009
+ async function downloadTo(args) {
7010
+ const filename = inferArchiveName(args.url);
7011
+ const dest = path2.join(args.dir, filename);
7012
+ const response = await fetch(args.url, { redirect: "follow" });
7013
+ if (!response.ok || !response.body) {
7014
+ throw new Error(
7015
+ `Failed to download ${args.url}: HTTP ${response.status} ${response.statusText}`
7016
+ );
7017
+ }
7018
+ const total = Number(response.headers.get("content-length") ?? "0");
7019
+ const out = fs3.createWriteStream(dest);
7020
+ const nodeStream = Readable.fromWeb(response.body);
7021
+ let received = 0;
7022
+ let lastEmit = Date.now();
7023
+ const EMIT_INTERVAL_MS = 2e3;
7024
+ nodeStream.on("data", (chunk) => {
7025
+ received += chunk.length;
7026
+ const now = Date.now();
7027
+ if (now - lastEmit < EMIT_INTERVAL_MS) {
7028
+ return;
7029
+ }
7030
+ lastEmit = now;
7031
+ logSink(formatProgress(args.agentId, received, total));
7032
+ });
7033
+ await new Promise((resolve5, reject) => {
7034
+ nodeStream.on("error", reject);
7035
+ out.on("error", reject);
7036
+ out.on("finish", () => resolve5());
7037
+ nodeStream.pipe(out);
7038
+ });
7039
+ logSink(formatProgress(
7040
+ args.agentId,
7041
+ received,
7042
+ total,
7043
+ /* done */
7044
+ true
7045
+ ));
7046
+ return dest;
7047
+ }
7048
+ function formatProgress(agentId, received, total, done = false) {
7049
+ const rxMb = (received / 1e6).toFixed(1);
7050
+ if (total > 0) {
7051
+ const totalMb = (total / 1e6).toFixed(1);
7052
+ const pct = Math.min(100, Math.floor(received / total * 100));
7053
+ const tag2 = done ? "downloaded" : "downloading";
7054
+ return `hydra-acp: ${tag2} ${agentId} ${rxMb}/${totalMb} MB (${pct}%)`;
7055
+ }
7056
+ const tag = done ? "downloaded" : "downloading";
7057
+ return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
7058
+ }
7059
+ function inferArchiveName(url) {
7060
+ const u = new URL(url);
7061
+ const base = path2.posix.basename(u.pathname);
7062
+ return base || "archive";
7063
+ }
7064
+ async function extract(archivePath, dest) {
7065
+ const lower = archivePath.toLowerCase();
7066
+ if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
7067
+ await run("tar", ["-xf", archivePath, "-C", dest]);
7068
+ return;
7069
+ }
7070
+ if (lower.endsWith(".zip")) {
7071
+ if (await hasCommand("unzip")) {
7072
+ await run("unzip", ["-q", archivePath, "-d", dest]);
7073
+ return;
7074
+ }
7075
+ await run("tar", ["-xf", archivePath, "-C", dest]);
7076
+ return;
7077
+ }
7078
+ throw new Error(`Unsupported archive format: ${archivePath}`);
7079
+ }
7080
+ function run(cmd, args) {
7081
+ return new Promise((resolve5, reject) => {
7082
+ const child = spawn(cmd, args, {
7083
+ stdio: ["ignore", "ignore", "inherit"]
7084
+ });
7085
+ child.on("error", reject);
7086
+ child.on("exit", (code, signal) => {
7087
+ if (code === 0) {
7088
+ resolve5();
7089
+ return;
7090
+ }
7091
+ reject(
7092
+ new Error(
7093
+ `${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
7094
+ )
7095
+ );
7096
+ });
7097
+ });
7098
+ }
7099
+ async function hasCommand(name) {
7100
+ return new Promise((resolve5) => {
7101
+ const finder = process.platform === "win32" ? "where" : "which";
7102
+ const child = spawn(finder, [name], { stdio: "ignore" });
7103
+ child.on("error", () => resolve5(false));
7104
+ child.on("exit", (code) => resolve5(code === 0));
7105
+ });
7106
+ }
7107
+ async function fileExists(p) {
7108
+ try {
7109
+ await fsp.access(p);
7110
+ return true;
7111
+ } catch {
7112
+ return false;
7113
+ }
7114
+ }
7115
+
7116
+ // src/core/registry.ts
6408
7117
  var NpxDistribution = z2.object({
6409
7118
  package: z2.string(),
6410
7119
  args: z2.array(z2.string()).optional(),
@@ -6412,7 +7121,9 @@ var NpxDistribution = z2.object({
6412
7121
  });
6413
7122
  var BinaryTarget = z2.object({
6414
7123
  archive: z2.string().url().optional(),
6415
- cmd: z2.string().optional()
7124
+ cmd: z2.string().optional(),
7125
+ args: z2.array(z2.string()).optional(),
7126
+ env: z2.record(z2.string()).optional()
6416
7127
  });
6417
7128
  var BinaryDistribution = z2.object({
6418
7129
  "darwin-aarch64": BinaryTarget.optional(),
@@ -6501,34 +7212,59 @@ var Registry = class {
6501
7212
  if (!response.ok) {
6502
7213
  throw new Error(`Registry fetch failed: HTTP ${response.status}`);
6503
7214
  }
6504
- const json = await response.json();
6505
- const data = RegistryDocument.parse(json);
6506
- return { fetchedAt: Date.now(), data };
7215
+ const raw = await response.json();
7216
+ const data = RegistryDocument.parse(raw);
7217
+ return { fetchedAt: Date.now(), raw, data };
6507
7218
  }
6508
7219
  async readDiskCache() {
7220
+ let text;
6509
7221
  try {
6510
- const raw = await fs3.readFile(paths.registryCache(), "utf8");
6511
- const parsed = JSON.parse(raw);
6512
- if (typeof parsed.fetchedAt === "number" && parsed.data && Array.isArray(parsed.data.agents)) {
6513
- return parsed;
6514
- }
7222
+ text = await fs4.readFile(paths.registryCache(), "utf8");
6515
7223
  } catch (err) {
6516
7224
  const e = err;
6517
- if (e.code !== "ENOENT") {
6518
- throw err;
7225
+ if (e.code === "ENOENT") {
7226
+ return void 0;
6519
7227
  }
7228
+ throw err;
7229
+ }
7230
+ try {
7231
+ const parsed = JSON.parse(text);
7232
+ if (typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
7233
+ return void 0;
7234
+ }
7235
+ const data = RegistryDocument.parse(parsed.data);
7236
+ return { fetchedAt: parsed.fetchedAt, raw: parsed.data, data };
7237
+ } catch {
7238
+ return void 0;
6520
7239
  }
6521
- return void 0;
6522
7240
  }
7241
+ // Atomic write: dump to a sibling temp path, then rename onto the
7242
+ // target. POSIX rename is atomic within a filesystem, so readers
7243
+ // either see the old file or the fully-written new file — never a
7244
+ // truncated middle. This also makes simultaneous writers safe
7245
+ // without a lock file: the loser of the rename race just gets its
7246
+ // version replaced by the winner's.
6523
7247
  async writeDiskCache(cache) {
6524
- await fs3.mkdir(paths.home(), { recursive: true });
6525
- await fs3.writeFile(
6526
- paths.registryCache(),
6527
- JSON.stringify(cache, null, 2) + "\n",
6528
- "utf8"
6529
- );
7248
+ await fs4.mkdir(paths.home(), { recursive: true });
7249
+ const final = paths.registryCache();
7250
+ const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
7251
+ const body = JSON.stringify(
7252
+ { fetchedAt: cache.fetchedAt, data: cache.raw },
7253
+ null,
7254
+ 2
7255
+ ) + "\n";
7256
+ try {
7257
+ await fs4.writeFile(tmp, body, "utf8");
7258
+ await fs4.rename(tmp, final);
7259
+ } catch (err) {
7260
+ await fs4.unlink(tmp).catch(() => void 0);
7261
+ throw err;
7262
+ }
6530
7263
  }
6531
7264
  };
7265
+ function randSuffix() {
7266
+ return Math.random().toString(36).slice(2, 10);
7267
+ }
6532
7268
  function npxPackageBasename(agent) {
6533
7269
  const pkg = agent.distribution.npx?.package;
6534
7270
  if (!pkg) {
@@ -6539,7 +7275,7 @@ function npxPackageBasename(agent) {
6539
7275
  const atIdx = afterSlash.lastIndexOf("@");
6540
7276
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
6541
7277
  }
6542
- function planSpawn(agent, extraArgs = []) {
7278
+ async function planSpawn(agent, extraArgs = []) {
6543
7279
  if (agent.distribution.npx) {
6544
7280
  const npx = agent.distribution.npx;
6545
7281
  const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
@@ -6550,9 +7286,22 @@ function planSpawn(agent, extraArgs = []) {
6550
7286
  };
6551
7287
  }
6552
7288
  if (agent.distribution.binary) {
6553
- throw new Error(
6554
- `Agent ${agent.id} uses binary distribution; not yet supported in hydra-acp. PRs welcome.`
6555
- );
7289
+ const target = pickBinaryTarget(agent.distribution.binary);
7290
+ if (!target) {
7291
+ throw new Error(
7292
+ `Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
7293
+ );
7294
+ }
7295
+ const cmdPath = await ensureBinary({
7296
+ agentId: agent.id,
7297
+ version: agent.version ?? "current",
7298
+ target
7299
+ });
7300
+ return {
7301
+ command: cmdPath,
7302
+ args: [...target.args ?? [], ...extraArgs],
7303
+ env: target.env ?? {}
7304
+ };
6556
7305
  }
6557
7306
  if (agent.distribution.uvx) {
6558
7307
  const uvx = agent.distribution.uvx;
@@ -6567,10 +7316,11 @@ function planSpawn(agent, extraArgs = []) {
6567
7316
  }
6568
7317
 
6569
7318
  // src/core/session-manager.ts
6570
- import * as fs6 from "fs/promises";
7319
+ import * as fs8 from "fs/promises";
7320
+ import { customAlphabet as customAlphabet3 } from "nanoid";
6571
7321
 
6572
7322
  // src/core/agent-instance.ts
6573
- import { spawn } from "child_process";
7323
+ import { spawn as spawn2 } from "child_process";
6574
7324
 
6575
7325
  // src/acp/framing.ts
6576
7326
  init_types();
@@ -6626,13 +7376,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
6626
7376
  throw new Error("stream is closed");
6627
7377
  }
6628
7378
  const line = JSON.stringify(message) + "\n";
6629
- await new Promise((resolve2, reject) => {
7379
+ await new Promise((resolve5, reject) => {
6630
7380
  stdin.write(line, (err) => {
6631
7381
  if (err) {
6632
7382
  reject(err);
6633
7383
  return;
6634
7384
  }
6635
- resolve2();
7385
+ resolve5();
6636
7386
  });
6637
7387
  });
6638
7388
  },
@@ -6684,7 +7434,7 @@ var AgentInstance = class _AgentInstance {
6684
7434
  ...opts.plan.env,
6685
7435
  ...opts.extraEnv ?? {}
6686
7436
  };
6687
- const child = spawn(opts.plan.command, opts.plan.args, {
7437
+ const child = spawn2(opts.plan.command, opts.plan.args, {
6688
7438
  cwd: opts.cwd,
6689
7439
  env,
6690
7440
  stdio: ["pipe", "pipe", "pipe"]
@@ -6711,17 +7461,43 @@ init_session();
6711
7461
 
6712
7462
  // src/core/session-store.ts
6713
7463
  init_paths();
6714
- import * as fs4 from "fs/promises";
6715
- import * as path2 from "path";
7464
+ import * as fs5 from "fs/promises";
7465
+ import * as path3 from "path";
7466
+ import { customAlphabet as customAlphabet2 } from "nanoid";
6716
7467
  import { z as z4 } from "zod";
7468
+ var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7469
+ var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
7470
+ var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
7471
+ function generateLineageId() {
7472
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
7473
+ }
6717
7474
  var PersistedAgentCommand = z4.object({
6718
7475
  name: z4.string(),
6719
7476
  description: z4.string().optional()
6720
7477
  });
7478
+ var PersistedUsage = z4.object({
7479
+ used: z4.number().optional(),
7480
+ size: z4.number().optional(),
7481
+ costAmount: z4.number().optional(),
7482
+ costCurrency: z4.string().optional()
7483
+ });
6721
7484
  var SessionRecord = z4.object({
6722
7485
  version: z4.literal(1),
6723
7486
  sessionId: z4.string(),
7487
+ // Optional for back-compat with records written before this field
7488
+ // existed; mergeForPersistence generates one on next write so any
7489
+ // touched session converges to having a lineageId. A record that
7490
+ // never gets written again (truly cold and untouched) just won't
7491
+ // participate in lineage-based dedup, which is correct — it was
7492
+ // never exported, so no incoming bundle can claim its lineage.
7493
+ lineageId: z4.string().optional(),
6724
7494
  upstreamSessionId: z4.string(),
7495
+ // When non-empty, marks a session that was created by import and is
7496
+ // waiting for its first attach to bootstrap a fresh upstream agent
7497
+ // and replay the imported history as a takeover transcript. The
7498
+ // origin's local id at export time, kept for debuggability and as a
7499
+ // breadcrumb in `sessions list` (informational, not used for routing).
7500
+ importedFromSessionId: z4.string().optional(),
6725
7501
  agentId: z4.string(),
6726
7502
  cwd: z4.string(),
6727
7503
  title: z4.string().optional(),
@@ -6732,6 +7508,7 @@ var SessionRecord = z4.object({
6732
7508
  // replay of a snapshot-shaped notification.
6733
7509
  currentModel: z4.string().optional(),
6734
7510
  currentMode: z4.string().optional(),
7511
+ currentUsage: PersistedUsage.optional(),
6735
7512
  agentCommands: z4.array(PersistedAgentCommand).optional(),
6736
7513
  createdAt: z4.string(),
6737
7514
  updatedAt: z4.string()
@@ -6745,9 +7522,9 @@ function assertSafeId(id) {
6745
7522
  var SessionStore = class {
6746
7523
  async write(record) {
6747
7524
  assertSafeId(record.sessionId);
6748
- await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
7525
+ await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
6749
7526
  const full = { version: 1, ...record };
6750
- await fs4.writeFile(
7527
+ await fs5.writeFile(
6751
7528
  paths.sessionFile(record.sessionId),
6752
7529
  JSON.stringify(full, null, 2) + "\n",
6753
7530
  { encoding: "utf8", mode: 384 }
@@ -6759,7 +7536,7 @@ var SessionStore = class {
6759
7536
  }
6760
7537
  let raw;
6761
7538
  try {
6762
- raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
7539
+ raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
6763
7540
  } catch (err) {
6764
7541
  const e = err;
6765
7542
  if (e.code === "ENOENT") {
@@ -6778,7 +7555,7 @@ var SessionStore = class {
6778
7555
  return;
6779
7556
  }
6780
7557
  try {
6781
- await fs4.unlink(paths.sessionFile(sessionId));
7558
+ await fs5.unlink(paths.sessionFile(sessionId));
6782
7559
  } catch (err) {
6783
7560
  const e = err;
6784
7561
  if (e.code !== "ENOENT") {
@@ -6786,7 +7563,7 @@ var SessionStore = class {
6786
7563
  }
6787
7564
  }
6788
7565
  try {
6789
- await fs4.rmdir(paths.sessionDir(sessionId));
7566
+ await fs5.rmdir(paths.sessionDir(sessionId));
6790
7567
  } catch (err) {
6791
7568
  const e = err;
6792
7569
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -6794,10 +7571,29 @@ var SessionStore = class {
6794
7571
  }
6795
7572
  }
6796
7573
  }
7574
+ // Find a persisted session by lineageId. Used by SessionManager.import
7575
+ // to detect bundles that have already been imported (lineageId match)
7576
+ // so we can either error out or, with replace:true, overwrite.
7577
+ // Returns undefined if no record has that lineageId. Records that
7578
+ // pre-date the lineageId field simply don't match — which is
7579
+ // correct: they were never exported, so no incoming bundle can
7580
+ // legitimately claim their lineage.
7581
+ async findByLineageId(lineageId) {
7582
+ if (lineageId.length === 0) {
7583
+ return void 0;
7584
+ }
7585
+ const all = await this.list().catch(() => []);
7586
+ for (const record of all) {
7587
+ if (record.lineageId === lineageId) {
7588
+ return record;
7589
+ }
7590
+ }
7591
+ return void 0;
7592
+ }
6797
7593
  async list() {
6798
7594
  let entries;
6799
7595
  try {
6800
- entries = await fs4.readdir(paths.sessionsDir());
7596
+ entries = await fs5.readdir(paths.sessionsDir());
6801
7597
  } catch (err) {
6802
7598
  const e = err;
6803
7599
  if (e.code === "ENOENT") {
@@ -6819,13 +7615,16 @@ function recordFromMemorySession(args) {
6819
7615
  const now = (/* @__PURE__ */ new Date()).toISOString();
6820
7616
  return {
6821
7617
  sessionId: args.sessionId,
7618
+ lineageId: args.lineageId,
6822
7619
  upstreamSessionId: args.upstreamSessionId,
7620
+ importedFromSessionId: args.importedFromSessionId,
6823
7621
  agentId: args.agentId,
6824
7622
  cwd: args.cwd,
6825
7623
  title: args.title,
6826
7624
  agentArgs: args.agentArgs,
6827
7625
  currentModel: args.currentModel,
6828
7626
  currentMode: args.currentMode,
7627
+ currentUsage: args.currentUsage,
6829
7628
  agentCommands: args.agentCommands,
6830
7629
  createdAt: args.createdAt ?? now,
6831
7630
  updatedAt: args.updatedAt ?? now
@@ -6834,7 +7633,7 @@ function recordFromMemorySession(args) {
6834
7633
 
6835
7634
  // src/core/history-store.ts
6836
7635
  init_paths();
6837
- import * as fs5 from "fs/promises";
7636
+ import * as fs6 from "fs/promises";
6838
7637
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
6839
7638
  var MAX_ENTRIES = 1e3;
6840
7639
  var HistoryStore = class {
@@ -6847,9 +7646,9 @@ var HistoryStore = class {
6847
7646
  return;
6848
7647
  }
6849
7648
  return this.enqueue(sessionId, async () => {
6850
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
7649
+ await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
6851
7650
  const line = JSON.stringify(entry) + "\n";
6852
- await fs5.appendFile(paths.historyFile(sessionId), line, {
7651
+ await fs6.appendFile(paths.historyFile(sessionId), line, {
6853
7652
  encoding: "utf8",
6854
7653
  mode: 384
6855
7654
  });
@@ -6860,9 +7659,39 @@ var HistoryStore = class {
6860
7659
  return;
6861
7660
  }
6862
7661
  return this.enqueue(sessionId, async () => {
6863
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
7662
+ await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
6864
7663
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
6865
- await fs5.writeFile(paths.historyFile(sessionId), body, {
7664
+ await fs6.writeFile(paths.historyFile(sessionId), body, {
7665
+ encoding: "utf8",
7666
+ mode: 384
7667
+ });
7668
+ });
7669
+ }
7670
+ // Trim the on-disk history file to the most recent maxEntries lines.
7671
+ // Runs through the same per-session write queue as append/rewrite so
7672
+ // it's safe to invoke alongside ongoing writes; a no-op if the file is
7673
+ // already at or below the cap.
7674
+ async compact(sessionId, maxEntries) {
7675
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
7676
+ return;
7677
+ }
7678
+ return this.enqueue(sessionId, async () => {
7679
+ let raw;
7680
+ try {
7681
+ raw = await fs6.readFile(paths.historyFile(sessionId), "utf8");
7682
+ } catch (err) {
7683
+ const e = err;
7684
+ if (e.code === "ENOENT") {
7685
+ return;
7686
+ }
7687
+ throw err;
7688
+ }
7689
+ const lines = raw.split("\n").filter((l) => l.length > 0);
7690
+ if (lines.length <= maxEntries) {
7691
+ return;
7692
+ }
7693
+ const trimmed = lines.slice(-maxEntries);
7694
+ await fs6.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
6866
7695
  encoding: "utf8",
6867
7696
  mode: 384
6868
7697
  });
@@ -6878,7 +7707,7 @@ var HistoryStore = class {
6878
7707
  }
6879
7708
  let raw;
6880
7709
  try {
6881
- raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
7710
+ raw = await fs6.readFile(paths.historyFile(sessionId), "utf8");
6882
7711
  } catch (err) {
6883
7712
  const e = err;
6884
7713
  if (e.code === "ENOENT") {
@@ -6924,7 +7753,7 @@ var HistoryStore = class {
6924
7753
  }
6925
7754
  return this.enqueue(sessionId, async () => {
6926
7755
  try {
6927
- await fs5.unlink(paths.historyFile(sessionId));
7756
+ await fs6.unlink(paths.historyFile(sessionId));
6928
7757
  } catch (err) {
6929
7758
  const e = err;
6930
7759
  if (e.code !== "ENOENT") {
@@ -6932,7 +7761,7 @@ var HistoryStore = class {
6932
7761
  }
6933
7762
  }
6934
7763
  try {
6935
- await fs5.rmdir(paths.sessionDir(sessionId));
7764
+ await fs6.rmdir(paths.sessionDir(sessionId));
6936
7765
  } catch (err) {
6937
7766
  const e = err;
6938
7767
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -6957,7 +7786,10 @@ var HistoryStore = class {
6957
7786
 
6958
7787
  // src/core/session-manager.ts
6959
7788
  init_paths();
7789
+ init_history();
6960
7790
  init_types();
7791
+ var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7792
+ var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
6961
7793
  var SessionManager = class {
6962
7794
  constructor(registry, spawner, store, options = {}) {
6963
7795
  this.registry = registry;
@@ -6965,6 +7797,7 @@ var SessionManager = class {
6965
7797
  this.store = store ?? new SessionStore();
6966
7798
  this.histories = new HistoryStore();
6967
7799
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
7800
+ this.defaultModels = options.defaultModels ?? {};
6968
7801
  }
6969
7802
  registry;
6970
7803
  sessions = /* @__PURE__ */ new Map();
@@ -6973,6 +7806,7 @@ var SessionManager = class {
6973
7806
  store;
6974
7807
  histories;
6975
7808
  idleTimeoutMs;
7809
+ defaultModels;
6976
7810
  // Serialize meta.json read-modify-write operations per session id so
6977
7811
  // concurrent snapshot updates (e.g. an agent emitting model + mode
6978
7812
  // back-to-back) don't lose writes via interleaved reads.
@@ -6994,7 +7828,8 @@ var SessionManager = class {
6994
7828
  agentArgs: params.agentArgs,
6995
7829
  idleTimeoutMs: this.idleTimeoutMs,
6996
7830
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
6997
- historyStore: this.histories
7831
+ historyStore: this.histories,
7832
+ currentModel: fresh.initialModel
6998
7833
  });
6999
7834
  await this.attachManagerHooks(session);
7000
7835
  return session;
@@ -7036,7 +7871,10 @@ var SessionManager = class {
7036
7871
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
7037
7872
  throw err;
7038
7873
  }
7039
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
7874
+ if (params.upstreamSessionId === "") {
7875
+ return this.doResurrectFromImport(params);
7876
+ }
7877
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
7040
7878
  const agent = this.spawner({
7041
7879
  agentId: params.agentId,
7042
7880
  cwd: params.cwd,
@@ -7049,11 +7887,14 @@ var SessionManager = class {
7049
7887
  });
7050
7888
  let loadResult;
7051
7889
  try {
7052
- loadResult = await agent.connection.request("session/load", {
7053
- sessionId: params.upstreamSessionId,
7054
- cwd: params.cwd,
7055
- mcpServers: []
7056
- });
7890
+ loadResult = await agent.connection.request(
7891
+ "session/load",
7892
+ {
7893
+ sessionId: params.upstreamSessionId,
7894
+ cwd: params.cwd,
7895
+ mcpServers: []
7896
+ }
7897
+ );
7057
7898
  } catch (err) {
7058
7899
  await agent.kill().catch(() => void 0);
7059
7900
  throw new Error(
@@ -7072,17 +7913,65 @@ var SessionManager = class {
7072
7913
  idleTimeoutMs: this.idleTimeoutMs,
7073
7914
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7074
7915
  historyStore: this.histories,
7075
- seedHistory: params.seedHistory,
7076
- currentModel: params.currentModel,
7916
+ // Prefer what we previously stored from a current_model_update; if
7917
+ // we never captured one (e.g. old opencode sessions on disk before
7918
+ // this fix), fall back to the model the agent ships in its
7919
+ // session/load response body.
7920
+ currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
7921
+ currentMode: params.currentMode,
7922
+ currentUsage: params.currentUsage,
7923
+ agentCommands: params.agentCommands,
7924
+ // Only gate the first-prompt title heuristic when we actually have
7925
+ // a title to preserve. A title-less session (lost to a write race
7926
+ // or never seeded) should re-derive from the next prompt rather
7927
+ // than stay stuck.
7928
+ firstPromptSeeded: !!params.title,
7929
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
7930
+ });
7931
+ await this.attachManagerHooks(session);
7932
+ return session;
7933
+ }
7934
+ // First-attach path for a session that was created via import(). The
7935
+ // on-disk meta.json carries upstreamSessionId="" as the import
7936
+ // marker; bootstrap a fresh agent (gets a real upstream id) and kick
7937
+ // off seedFromImport so the agent absorbs the historical transcript.
7938
+ // attachManagerHooks rewrites meta.json with the new upstreamSessionId,
7939
+ // so subsequent resurrects of this session use the normal session/load
7940
+ // path.
7941
+ async doResurrectFromImport(params) {
7942
+ const fresh = await this.bootstrapAgent({
7943
+ agentId: params.agentId,
7944
+ cwd: params.cwd,
7945
+ agentArgs: params.agentArgs,
7946
+ mcpServers: []
7947
+ });
7948
+ const session = new Session({
7949
+ sessionId: params.hydraSessionId,
7950
+ cwd: params.cwd,
7951
+ agentId: params.agentId,
7952
+ agent: fresh.agent,
7953
+ upstreamSessionId: fresh.upstreamSessionId,
7954
+ agentMeta: fresh.agentMeta,
7955
+ title: params.title,
7956
+ agentArgs: params.agentArgs,
7957
+ idleTimeoutMs: this.idleTimeoutMs,
7958
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7959
+ historyStore: this.histories,
7960
+ // Prefer the stored value (set by a previous current_model_update);
7961
+ // fall back to whatever the agent ships in its session/new response.
7962
+ currentModel: params.currentModel ?? fresh.initialModel,
7077
7963
  currentMode: params.currentMode,
7964
+ currentUsage: params.currentUsage,
7078
7965
  agentCommands: params.agentCommands,
7079
- firstPromptSeeded: true
7966
+ firstPromptSeeded: !!params.title,
7967
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
7080
7968
  });
7081
7969
  await this.attachManagerHooks(session);
7970
+ void session.seedFromImport().catch(() => void 0);
7082
7971
  return session;
7083
7972
  }
7084
7973
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
7085
- // → session/new. Shared by create() and the /hydra switch path so both
7974
+ // → session/new. Shared by create() and the /hydra agent path so both
7086
7975
  // go through the same env / capabilities / error-handling.
7087
7976
  async bootstrapAgent(params) {
7088
7977
  const agentDef = await this.registry.getAgent(params.agentId);
@@ -7093,7 +7982,7 @@ var SessionManager = class {
7093
7982
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
7094
7983
  throw err;
7095
7984
  }
7096
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
7985
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
7097
7986
  const agent = this.spawner({
7098
7987
  agentId: params.agentId,
7099
7988
  cwd: params.cwd,
@@ -7105,14 +7994,36 @@ var SessionManager = class {
7105
7994
  clientCapabilities: {},
7106
7995
  clientInfo: { name: "hydra", version: "0.1.0" }
7107
7996
  });
7108
- const newResult = await agent.connection.request("session/new", {
7109
- cwd: params.cwd,
7110
- mcpServers: params.mcpServers ?? []
7111
- });
7997
+ const newResult = await agent.connection.request(
7998
+ "session/new",
7999
+ {
8000
+ cwd: params.cwd,
8001
+ mcpServers: params.mcpServers ?? []
8002
+ }
8003
+ );
8004
+ const sessionIdRaw = newResult.sessionId;
8005
+ if (typeof sessionIdRaw !== "string") {
8006
+ throw new Error(
8007
+ `agent ${params.agentId} returned a non-string sessionId from session/new`
8008
+ );
8009
+ }
8010
+ let initialModel = extractInitialModel(newResult);
8011
+ const desired = this.defaultModels[params.agentId];
8012
+ if (desired && desired !== initialModel) {
8013
+ try {
8014
+ await agent.connection.request("session/set_model", {
8015
+ sessionId: sessionIdRaw,
8016
+ modelId: desired
8017
+ });
8018
+ initialModel = desired;
8019
+ } catch {
8020
+ }
8021
+ }
7112
8022
  return {
7113
8023
  agent,
7114
- upstreamSessionId: newResult.sessionId,
7115
- agentMeta: newResult._meta
8024
+ upstreamSessionId: sessionIdRaw,
8025
+ agentMeta: newResult._meta,
8026
+ initialModel
7116
8027
  };
7117
8028
  } catch (err) {
7118
8029
  await agent.kill().catch(() => void 0);
@@ -7123,7 +8034,7 @@ var SessionManager = class {
7123
8034
  // bookkeeping. Called from both create() and resurrect() so the same
7124
8035
  // session record + lifecycle handlers are wired regardless of origin.
7125
8036
  // Returns once the initial disk record is written — callers should
7126
- // await so a subsequent /hydra switch's persistAgentChange (which
8037
+ // await so a subsequent /hydra agent's persistAgentChange (which
7127
8038
  // does read-then-write) finds the file in place.
7128
8039
  async attachManagerHooks(session) {
7129
8040
  session.onClose(({ deleteRecord }) => {
@@ -7151,6 +8062,11 @@ var SessionManager = class {
7151
8062
  () => void 0
7152
8063
  );
7153
8064
  });
8065
+ session.onUsageChange((usage) => {
8066
+ void this.persistSnapshot(session.sessionId, {
8067
+ currentUsage: usageSnapshotToPersisted(usage)
8068
+ }).catch(() => void 0);
8069
+ });
7154
8070
  session.onAgentCommandsChange((commands) => {
7155
8071
  void this.persistSnapshot(session.sessionId, {
7156
8072
  agentCommands: commands.map((c) => ({
@@ -7160,28 +8076,20 @@ var SessionManager = class {
7160
8076
  }).catch(() => void 0);
7161
8077
  });
7162
8078
  this.sessions.set(session.sessionId, session);
7163
- await this.store.write(
7164
- recordFromMemorySession({
7165
- sessionId: session.sessionId,
7166
- upstreamSessionId: session.upstreamSessionId,
7167
- agentId: session.agentId,
7168
- cwd: session.cwd,
7169
- title: session.title,
7170
- agentArgs: session.agentArgs,
7171
- currentModel: session.currentModel,
7172
- currentMode: session.currentMode
7173
- })
7174
- ).catch(() => void 0);
8079
+ await this.enqueueMetaWrite(session.sessionId, async () => {
8080
+ const existing = await this.store.read(session.sessionId);
8081
+ const merged = mergeForPersistence(session, existing);
8082
+ await this.store.write(merged);
8083
+ }).catch(() => void 0);
7175
8084
  }
7176
8085
  // Resolve a session's recorded history without forcing a resurrect.
7177
- // Returns the in-memory snapshot if the session is hot, falls back
7178
- // to the on-disk history file otherwise. Returns undefined if the
7179
- // session id is unknown to both the live map and disk store, so the
7180
- // caller can distinguish "no history yet" (empty array) from "404".
8086
+ // Always loads from disk that's the source of truth whether the
8087
+ // session is hot or cold. Returns undefined if the session id is
8088
+ // unknown to both the live map and disk store, so the caller can
8089
+ // distinguish "no history yet" (empty array) from "404".
7181
8090
  async getHistory(sessionId) {
7182
- const live = this.sessions.get(sessionId);
7183
- if (live) {
7184
- return live.getHistorySnapshot();
8091
+ if (this.sessions.has(sessionId)) {
8092
+ return this.histories.load(sessionId).catch(() => []);
7185
8093
  }
7186
8094
  const record = await this.store.read(sessionId);
7187
8095
  if (!record) {
@@ -7194,20 +8102,42 @@ var SessionManager = class {
7194
8102
  if (!record) {
7195
8103
  return void 0;
7196
8104
  }
7197
- const seedHistory = await this.histories.load(sessionId).catch(() => []);
8105
+ let title = record.title;
8106
+ if (!title) {
8107
+ title = await this.deriveTitleFromHistory(sessionId);
8108
+ }
7198
8109
  return {
7199
8110
  hydraSessionId: record.sessionId,
7200
8111
  upstreamSessionId: record.upstreamSessionId,
7201
8112
  agentId: record.agentId,
7202
8113
  cwd: record.cwd,
7203
- title: record.title,
8114
+ title,
7204
8115
  agentArgs: record.agentArgs,
7205
- seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
7206
8116
  currentModel: record.currentModel,
7207
8117
  currentMode: record.currentMode,
7208
- agentCommands: record.agentCommands
8118
+ currentUsage: persistedUsageToSnapshot(record.currentUsage),
8119
+ agentCommands: record.agentCommands,
8120
+ createdAt: record.createdAt
7209
8121
  };
7210
8122
  }
8123
+ // Best-effort: peek at the persisted history's first prompt and use
8124
+ // its first line (capped to 200 chars) as a session title. Returns
8125
+ // undefined if no usable prompt is found or any I/O fails.
8126
+ async deriveTitleFromHistory(sessionId) {
8127
+ const history = await this.histories.load(sessionId).catch(() => []);
8128
+ for (const entry of history) {
8129
+ const params = entry.params;
8130
+ if (params?.update?.sessionUpdate !== "prompt_received") {
8131
+ continue;
8132
+ }
8133
+ const text = extractPromptText(params.update.prompt);
8134
+ const line = firstLine(text, 200);
8135
+ if (line) {
8136
+ return line;
8137
+ }
8138
+ }
8139
+ return void 0;
8140
+ }
7211
8141
  get(sessionId) {
7212
8142
  return this.sessions.get(sessionId);
7213
8143
  }
@@ -7254,6 +8184,8 @@ var SessionManager = class {
7254
8184
  cwd: session.cwd,
7255
8185
  title: session.title,
7256
8186
  agentId: session.agentId,
8187
+ currentModel: session.currentModel,
8188
+ currentUsage: session.currentUsage,
7257
8189
  updatedAt: used,
7258
8190
  attachedClients: session.attachedCount,
7259
8191
  status: "live"
@@ -7274,6 +8206,8 @@ var SessionManager = class {
7274
8206
  cwd: r.cwd,
7275
8207
  title: r.title,
7276
8208
  agentId: r.agentId,
8209
+ currentModel: r.currentModel,
8210
+ currentUsage: r.currentUsage,
7277
8211
  updatedAt: used,
7278
8212
  attachedClients: 0,
7279
8213
  status: "cold"
@@ -7282,6 +8216,112 @@ var SessionManager = class {
7282
8216
  entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
7283
8217
  return entries;
7284
8218
  }
8219
+ // Build an export bundle for a session, reading meta + history from
8220
+ // disk. Backfills lineageId if the on-disk record pre-dates that
8221
+ // field. Returns undefined if the session doesn't exist. Callers
8222
+ // populate the bundle's exportedFrom metadata themselves.
8223
+ async exportBundle(sessionId) {
8224
+ const record = await this.store.read(sessionId);
8225
+ if (!record) {
8226
+ return void 0;
8227
+ }
8228
+ let withLineage;
8229
+ if (record.lineageId) {
8230
+ withLineage = record;
8231
+ } else {
8232
+ const lineageId = generateLineageId();
8233
+ const backfilled = { ...record, lineageId };
8234
+ await this.enqueueMetaWrite(sessionId, async () => {
8235
+ const latest = await this.store.read(sessionId);
8236
+ if (!latest) {
8237
+ return;
8238
+ }
8239
+ if (latest.lineageId) {
8240
+ return;
8241
+ }
8242
+ await this.store.write({ ...latest, lineageId });
8243
+ }).catch(() => void 0);
8244
+ withLineage = backfilled;
8245
+ }
8246
+ const history = await this.histories.load(sessionId).catch(() => []);
8247
+ const promptHistory = await loadPromptHistorySafely(sessionId);
8248
+ return { record: withLineage, history, promptHistory };
8249
+ }
8250
+ // Create a local session from an imported bundle. Without `replace`,
8251
+ // a bundle with a lineageId we already have on disk throws
8252
+ // BundleAlreadyImported citing the existing local id. With
8253
+ // `replace: true`, the existing record is overwritten in-place (its
8254
+ // local sessionId is preserved so bookmarks/Slack thread links still
8255
+ // resolve), and any live in-memory session is closed so the next
8256
+ // attach triggers the import-reseed path.
8257
+ async importBundle(bundle, opts = {}) {
8258
+ const existing = await this.store.findByLineageId(bundle.session.lineageId);
8259
+ if (existing) {
8260
+ if (!opts.replace) {
8261
+ const err = new Error(
8262
+ `bundle already imported as ${existing.sessionId}`
8263
+ );
8264
+ err.code = JsonRpcErrorCodes.BundleAlreadyImported;
8265
+ err.existingSessionId = existing.sessionId;
8266
+ throw err;
8267
+ }
8268
+ const live = this.sessions.get(existing.sessionId);
8269
+ if (live) {
8270
+ await live.close({ deleteRecord: false }).catch(() => void 0);
8271
+ }
8272
+ await this.writeImportedRecord({
8273
+ sessionId: existing.sessionId,
8274
+ bundle,
8275
+ preservedCreatedAt: existing.createdAt
8276
+ });
8277
+ return {
8278
+ sessionId: existing.sessionId,
8279
+ importedFromSessionId: bundle.session.sessionId,
8280
+ replaced: true
8281
+ };
8282
+ }
8283
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
8284
+ await this.writeImportedRecord({ sessionId: newId, bundle });
8285
+ return {
8286
+ sessionId: newId,
8287
+ importedFromSessionId: bundle.session.sessionId,
8288
+ replaced: false
8289
+ };
8290
+ }
8291
+ // Write the imported bundle's history.jsonl, prompt-history (if
8292
+ // present), and meta.json. upstreamSessionId is left empty as the
8293
+ // marker that the first attach should bootstrap a fresh agent and
8294
+ // run seedFromImport rather than calling session/load.
8295
+ async writeImportedRecord(args) {
8296
+ await this.histories.rewrite(
8297
+ args.sessionId,
8298
+ args.bundle.history
8299
+ );
8300
+ if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
8301
+ await saveHistory(
8302
+ paths.tuiHistoryFile(args.sessionId),
8303
+ args.bundle.promptHistory
8304
+ ).catch(() => void 0);
8305
+ }
8306
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8307
+ await this.enqueueMetaWrite(args.sessionId, async () => {
8308
+ await this.store.write({
8309
+ sessionId: args.sessionId,
8310
+ lineageId: args.bundle.session.lineageId,
8311
+ upstreamSessionId: "",
8312
+ importedFromSessionId: args.bundle.session.sessionId,
8313
+ agentId: args.bundle.session.agentId,
8314
+ cwd: args.bundle.session.cwd,
8315
+ title: args.bundle.session.title,
8316
+ currentModel: args.bundle.session.currentModel,
8317
+ currentMode: args.bundle.session.currentMode,
8318
+ currentUsage: args.bundle.session.currentUsage,
8319
+ agentCommands: args.bundle.session.agentCommands,
8320
+ createdAt: args.preservedCreatedAt ?? now,
8321
+ updatedAt: now
8322
+ });
8323
+ });
8324
+ }
7285
8325
  async deleteRecord(sessionId) {
7286
8326
  const record = await this.store.read(sessionId);
7287
8327
  if (!record) {
@@ -7311,7 +8351,7 @@ var SessionManager = class {
7311
8351
  });
7312
8352
  });
7313
8353
  }
7314
- // Persist an agent swap from /hydra switch. The on-disk record's
8354
+ // Persist an agent swap from /hydra agent. The on-disk record's
7315
8355
  // agentId + upstreamSessionId both rotate so a daemon restart (and
7316
8356
  // later resurrect) brings the session back up on the agent the user
7317
8357
  // most recently switched to, not the one it was originally created on.
@@ -7343,6 +8383,7 @@ var SessionManager = class {
7343
8383
  ...record,
7344
8384
  ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
7345
8385
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
8386
+ ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
7346
8387
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
7347
8388
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7348
8389
  });
@@ -7367,10 +8408,124 @@ var SessionManager = class {
7367
8408
  await Promise.allSettled(sessions.map((s) => s.close()));
7368
8409
  this.sessions.clear();
7369
8410
  }
8411
+ // Wait for every pending meta.json write to settle. Daemon shutdown
8412
+ // hooks call this so a SIGTERM doesn't kill the process mid-write
8413
+ // and lose a freshly-set title (or model/mode/commands).
8414
+ async flushMetaWrites() {
8415
+ const pending = [...this.metaWriteQueues.values()];
8416
+ if (pending.length === 0) {
8417
+ return;
8418
+ }
8419
+ await Promise.allSettled(pending);
8420
+ }
7370
8421
  };
8422
+ function mergeForPersistence(session, existing) {
8423
+ const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
8424
+ if (c.description !== void 0) {
8425
+ return { name: c.name, description: c.description };
8426
+ }
8427
+ return { name: c.name };
8428
+ }) : void 0;
8429
+ const agentCommands = persistedCommands ?? existing?.agentCommands;
8430
+ return recordFromMemorySession({
8431
+ sessionId: session.sessionId,
8432
+ lineageId: existing?.lineageId ?? generateLineageId(),
8433
+ upstreamSessionId: session.upstreamSessionId,
8434
+ importedFromSessionId: existing?.importedFromSessionId,
8435
+ agentId: session.agentId,
8436
+ cwd: session.cwd,
8437
+ title: session.title,
8438
+ agentArgs: session.agentArgs,
8439
+ currentModel: session.currentModel ?? existing?.currentModel,
8440
+ currentMode: session.currentMode ?? existing?.currentMode,
8441
+ currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
8442
+ agentCommands,
8443
+ createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
8444
+ });
8445
+ }
8446
+ function usageSnapshotToPersisted(usage) {
8447
+ if (!usage) {
8448
+ return void 0;
8449
+ }
8450
+ const out = {};
8451
+ if (usage.used !== void 0) {
8452
+ out.used = usage.used;
8453
+ }
8454
+ if (usage.size !== void 0) {
8455
+ out.size = usage.size;
8456
+ }
8457
+ if (usage.costAmount !== void 0) {
8458
+ out.costAmount = usage.costAmount;
8459
+ }
8460
+ if (usage.costCurrency !== void 0) {
8461
+ out.costCurrency = usage.costCurrency;
8462
+ }
8463
+ return Object.keys(out).length > 0 ? out : void 0;
8464
+ }
8465
+ function persistedUsageToSnapshot(usage) {
8466
+ return usage ? { ...usage } : void 0;
8467
+ }
8468
+ function extractInitialModel(result) {
8469
+ const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
8470
+ if (direct) {
8471
+ return direct;
8472
+ }
8473
+ const models = result.models;
8474
+ if (models && typeof models === "object" && !Array.isArray(models)) {
8475
+ const m = asString(models.currentModelId) ?? asString(models.currentModel);
8476
+ if (m) {
8477
+ return m;
8478
+ }
8479
+ }
8480
+ const meta = result._meta;
8481
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
8482
+ for (const [key, value] of Object.entries(
8483
+ meta
8484
+ )) {
8485
+ if (key === "hydra-acp") {
8486
+ continue;
8487
+ }
8488
+ if (value && typeof value === "object" && !Array.isArray(value)) {
8489
+ const m = asString(value.modelId) ?? asString(value.model) ?? asString(value.currentModelId);
8490
+ if (m) {
8491
+ return m;
8492
+ }
8493
+ }
8494
+ }
8495
+ }
8496
+ return void 0;
8497
+ }
8498
+ function asString(value) {
8499
+ if (typeof value !== "string") {
8500
+ return void 0;
8501
+ }
8502
+ const trimmed = value.trim();
8503
+ return trimmed.length > 0 ? trimmed : void 0;
8504
+ }
8505
+ async function loadPromptHistorySafely(sessionId) {
8506
+ try {
8507
+ const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
8508
+ const out = [];
8509
+ for (const line of raw.split("\n")) {
8510
+ if (line.length === 0) {
8511
+ continue;
8512
+ }
8513
+ try {
8514
+ const decoded = JSON.parse(line);
8515
+ if (typeof decoded === "string") {
8516
+ out.push(decoded);
8517
+ }
8518
+ } catch {
8519
+ }
8520
+ }
8521
+ return out;
8522
+ } catch {
8523
+ return [];
8524
+ }
8525
+ }
7371
8526
  async function historyMtimeIso(sessionId) {
7372
8527
  try {
7373
- const st = await fs6.stat(paths.historyFile(sessionId));
8528
+ const st = await fs8.stat(paths.historyFile(sessionId));
7374
8529
  return new Date(st.mtimeMs).toISOString();
7375
8530
  } catch {
7376
8531
  return void 0;
@@ -7379,10 +8534,10 @@ async function historyMtimeIso(sessionId) {
7379
8534
 
7380
8535
  // src/core/extensions.ts
7381
8536
  init_paths();
7382
- import { spawn as spawn2 } from "child_process";
7383
- import * as fs7 from "fs";
7384
- import * as fsp from "fs/promises";
7385
- import * as path3 from "path";
8537
+ import { spawn as spawn3 } from "child_process";
8538
+ import * as fs9 from "fs";
8539
+ import * as fsp2 from "fs/promises";
8540
+ import * as path5 from "path";
7386
8541
  var RESTART_BASE_MS = 1e3;
7387
8542
  var RESTART_CAP_MS = 6e4;
7388
8543
  var STOP_GRACE_MS = 3e3;
@@ -7403,7 +8558,7 @@ var ExtensionManager = class {
7403
8558
  if (!this.context) {
7404
8559
  throw new Error("ExtensionManager: setContext must be called before start");
7405
8560
  }
7406
- await fsp.mkdir(paths.extensionsDir(), { recursive: true });
8561
+ await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
7407
8562
  await this.reapOrphans();
7408
8563
  for (const entry of this.entries.values()) {
7409
8564
  if (!entry.config.enabled) {
@@ -7429,9 +8584,9 @@ var ExtensionManager = class {
7429
8584
  } catch {
7430
8585
  }
7431
8586
  tasks.push(
7432
- new Promise((resolve2) => {
8587
+ new Promise((resolve5) => {
7433
8588
  if (child.exitCode !== null || child.signalCode !== null) {
7434
- resolve2();
8589
+ resolve5();
7435
8590
  return;
7436
8591
  }
7437
8592
  const timer = setTimeout(() => {
@@ -7439,11 +8594,11 @@ var ExtensionManager = class {
7439
8594
  child.kill("SIGKILL");
7440
8595
  } catch {
7441
8596
  }
7442
- resolve2();
8597
+ resolve5();
7443
8598
  }, STOP_GRACE_MS);
7444
8599
  child.on("exit", () => {
7445
8600
  clearTimeout(timer);
7446
- resolve2();
8601
+ resolve5();
7447
8602
  });
7448
8603
  })
7449
8604
  );
@@ -7551,8 +8706,8 @@ var ExtensionManager = class {
7551
8706
  if (child.exitCode !== null || child.signalCode !== null) {
7552
8707
  return;
7553
8708
  }
7554
- const exited = new Promise((resolve2) => {
7555
- entry.exitWaiters.push(resolve2);
8709
+ const exited = new Promise((resolve5) => {
8710
+ entry.exitWaiters.push(resolve5);
7556
8711
  });
7557
8712
  try {
7558
8713
  child.kill("SIGTERM");
@@ -7612,7 +8767,7 @@ var ExtensionManager = class {
7612
8767
  async reapOrphans() {
7613
8768
  let entries;
7614
8769
  try {
7615
- entries = await fsp.readdir(paths.extensionsDir());
8770
+ entries = await fsp2.readdir(paths.extensionsDir());
7616
8771
  } catch (err) {
7617
8772
  const e = err;
7618
8773
  if (e.code === "ENOENT") {
@@ -7624,10 +8779,10 @@ var ExtensionManager = class {
7624
8779
  if (!entry.endsWith(".pid")) {
7625
8780
  continue;
7626
8781
  }
7627
- const pidPath = path3.join(paths.extensionsDir(), entry);
8782
+ const pidPath = path5.join(paths.extensionsDir(), entry);
7628
8783
  let pid;
7629
8784
  try {
7630
- const raw = await fsp.readFile(pidPath, "utf8");
8785
+ const raw = await fsp2.readFile(pidPath, "utf8");
7631
8786
  const parsed = Number.parseInt(raw.trim(), 10);
7632
8787
  if (Number.isInteger(parsed) && parsed > 0) {
7633
8788
  pid = parsed;
@@ -7650,7 +8805,7 @@ var ExtensionManager = class {
7650
8805
  }
7651
8806
  }
7652
8807
  }
7653
- await fsp.unlink(pidPath).catch(() => void 0);
8808
+ await fsp2.unlink(pidPath).catch(() => void 0);
7654
8809
  }
7655
8810
  }
7656
8811
  spawn(entry, attempt) {
@@ -7663,7 +8818,7 @@ var ExtensionManager = class {
7663
8818
  }
7664
8819
  const ext = entry.config;
7665
8820
  const command = ext.command.length > 0 ? ext.command : [ext.name];
7666
- const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
8821
+ const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
7667
8822
  flags: "a"
7668
8823
  });
7669
8824
  logStream.write(
@@ -7691,7 +8846,7 @@ var ExtensionManager = class {
7691
8846
  const args = [...baseArgs, ...ext.args];
7692
8847
  let child;
7693
8848
  try {
7694
- child = spawn2(cmd, args, {
8849
+ child = spawn3(cmd, args, {
7695
8850
  env,
7696
8851
  stdio: ["ignore", "pipe", "pipe"],
7697
8852
  detached: false
@@ -7713,7 +8868,7 @@ var ExtensionManager = class {
7713
8868
  }
7714
8869
  if (typeof child.pid === "number") {
7715
8870
  try {
7716
- fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
8871
+ fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7717
8872
  `, {
7718
8873
  encoding: "utf8",
7719
8874
  mode: 384
@@ -7738,7 +8893,7 @@ var ExtensionManager = class {
7738
8893
  });
7739
8894
  child.on("exit", (code, signal) => {
7740
8895
  try {
7741
- fs7.unlinkSync(paths.extensionPidFile(ext.name));
8896
+ fs9.unlinkSync(paths.extensionPidFile(ext.name));
7742
8897
  } catch {
7743
8898
  }
7744
8899
  logStream.write(
@@ -7749,8 +8904,8 @@ var ExtensionManager = class {
7749
8904
  entry.pid = void 0;
7750
8905
  entry.lastExitCode = typeof code === "number" ? code : void 0;
7751
8906
  const waiters = entry.exitWaiters.splice(0);
7752
- for (const resolve2 of waiters) {
7753
- resolve2();
8907
+ for (const resolve5 of waiters) {
8908
+ resolve5();
7754
8909
  }
7755
8910
  if (this.stopping || entry.manuallyStopped) {
7756
8911
  try {
@@ -7851,6 +9006,78 @@ function constantTimeEqual(a, b) {
7851
9006
 
7852
9007
  // src/daemon/routes/sessions.ts
7853
9008
  init_config();
9009
+ import * as os2 from "os";
9010
+
9011
+ // src/core/bundle.ts
9012
+ import { z as z5 } from "zod";
9013
+ var HistoryEntrySchema = z5.object({
9014
+ method: z5.string(),
9015
+ params: z5.unknown(),
9016
+ recordedAt: z5.number()
9017
+ });
9018
+ var BundleSession = z5.object({
9019
+ // The exporter's local id. Regenerated fresh on import (sessionId is
9020
+ // the local namespace; lineageId is what survives across hops).
9021
+ sessionId: z5.string(),
9022
+ // Required on bundles — the export path backfills if the source
9023
+ // record was written before lineageId existed.
9024
+ lineageId: z5.string(),
9025
+ agentId: z5.string(),
9026
+ cwd: z5.string(),
9027
+ title: z5.string().optional(),
9028
+ currentModel: z5.string().optional(),
9029
+ currentMode: z5.string().optional(),
9030
+ currentUsage: PersistedUsage.optional(),
9031
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
9032
+ createdAt: z5.string(),
9033
+ updatedAt: z5.string()
9034
+ });
9035
+ var Bundle = z5.object({
9036
+ version: z5.literal(1),
9037
+ exportedAt: z5.string(),
9038
+ exportedFrom: z5.object({
9039
+ hydraVersion: z5.string(),
9040
+ machine: z5.string()
9041
+ }),
9042
+ session: BundleSession,
9043
+ history: z5.array(HistoryEntrySchema),
9044
+ promptHistory: z5.array(z5.string()).optional()
9045
+ });
9046
+ function encodeBundle(params) {
9047
+ const bundle = {
9048
+ version: 1,
9049
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9050
+ exportedFrom: {
9051
+ hydraVersion: params.hydraVersion,
9052
+ machine: params.machine
9053
+ },
9054
+ session: {
9055
+ sessionId: params.record.sessionId,
9056
+ lineageId: params.record.lineageId,
9057
+ agentId: params.record.agentId,
9058
+ cwd: params.record.cwd,
9059
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
9060
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
9061
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
9062
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
9063
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
9064
+ createdAt: params.record.createdAt,
9065
+ updatedAt: params.record.updatedAt
9066
+ },
9067
+ history: params.history
9068
+ };
9069
+ if (params.promptHistory !== void 0) {
9070
+ bundle.promptHistory = params.promptHistory;
9071
+ }
9072
+ return bundle;
9073
+ }
9074
+ function decodeBundle(raw) {
9075
+ return Bundle.parse(raw);
9076
+ }
9077
+
9078
+ // src/daemon/routes/sessions.ts
9079
+ init_types();
9080
+ var HYDRA_VERSION = "0.1.0";
7854
9081
  function registerSessionRoutes(app, manager, defaults) {
7855
9082
  app.get("/v1/sessions", async (request) => {
7856
9083
  const query = request.query;
@@ -7908,6 +9135,61 @@ function registerSessionRoutes(app, manager, defaults) {
7908
9135
  }
7909
9136
  reply.code(204).send();
7910
9137
  });
9138
+ app.get("/v1/sessions/:id/export", async (request, reply) => {
9139
+ const raw = request.params.id;
9140
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
9141
+ const exported = await manager.exportBundle(id);
9142
+ if (!exported) {
9143
+ reply.code(404).send({ error: "session not found" });
9144
+ return;
9145
+ }
9146
+ const bundle = encodeBundle({
9147
+ record: exported.record,
9148
+ history: exported.history,
9149
+ promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
9150
+ hydraVersion: HYDRA_VERSION,
9151
+ machine: os2.hostname()
9152
+ });
9153
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9154
+ reply.header(
9155
+ "Content-Disposition",
9156
+ `attachment; filename="hydra-${id}-${stamp}.hydra"`
9157
+ );
9158
+ reply.code(200).send(bundle);
9159
+ });
9160
+ app.post("/v1/sessions/import", async (request, reply) => {
9161
+ const body = request.body ?? {};
9162
+ if (body.bundle === void 0) {
9163
+ reply.code(400).send({ error: "missing bundle" });
9164
+ return;
9165
+ }
9166
+ let bundle;
9167
+ try {
9168
+ bundle = decodeBundle(body.bundle);
9169
+ } catch (err) {
9170
+ reply.code(400).send({
9171
+ error: "invalid bundle",
9172
+ details: err.message
9173
+ });
9174
+ return;
9175
+ }
9176
+ try {
9177
+ const result = await manager.importBundle(bundle, {
9178
+ replace: body.replace === true
9179
+ });
9180
+ reply.code(201).send(result);
9181
+ } catch (err) {
9182
+ const e = err;
9183
+ if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
9184
+ reply.code(409).send({
9185
+ error: "bundle already imported",
9186
+ existingSessionId: e.existingSessionId
9187
+ });
9188
+ return;
9189
+ }
9190
+ reply.code(500).send({ error: e.message });
9191
+ }
9192
+ });
7911
9193
  app.get("/v1/sessions/:id/history", async (request, reply) => {
7912
9194
  const raw = request.params.id;
7913
9195
  const query = request.query;
@@ -7916,16 +9198,22 @@ function registerSessionRoutes(app, manager, defaults) {
7916
9198
  const live = manager.get(id);
7917
9199
  let snapshot;
7918
9200
  let unsubscribe;
9201
+ let snapshotDone = false;
9202
+ const pending = [];
7919
9203
  if (live) {
7920
- snapshot = live.getHistorySnapshot();
7921
9204
  if (follow) {
7922
9205
  unsubscribe = live.onBroadcast((entry) => {
7923
9206
  if (reply.raw.writableEnded) {
7924
9207
  return;
7925
9208
  }
7926
- reply.raw.write(JSON.stringify(entry) + "\n");
9209
+ if (snapshotDone) {
9210
+ reply.raw.write(JSON.stringify(entry) + "\n");
9211
+ } else {
9212
+ pending.push(entry);
9213
+ }
7927
9214
  });
7928
9215
  }
9216
+ snapshot = await live.getHistorySnapshot();
7929
9217
  } else {
7930
9218
  const cold = await manager.getHistory(id);
7931
9219
  if (cold === void 0) {
@@ -7937,9 +9225,23 @@ function registerSessionRoutes(app, manager, defaults) {
7937
9225
  reply.raw.setHeader("Content-Type", "application/x-ndjson");
7938
9226
  reply.raw.setHeader("Cache-Control", "no-cache");
7939
9227
  reply.raw.statusCode = 200;
9228
+ const snapshotKeys = /* @__PURE__ */ new Set();
7940
9229
  for (const entry of snapshot ?? []) {
7941
9230
  reply.raw.write(JSON.stringify(entry) + "\n");
9231
+ const e = entry;
9232
+ if (typeof e.recordedAt === "number") {
9233
+ snapshotKeys.add(String(e.recordedAt));
9234
+ }
9235
+ }
9236
+ for (const entry of pending) {
9237
+ const e = entry;
9238
+ const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
9239
+ if (key && snapshotKeys.has(key)) {
9240
+ continue;
9241
+ }
9242
+ reply.raw.write(JSON.stringify(entry) + "\n");
7942
9243
  }
9244
+ snapshotDone = true;
7943
9245
  if (!unsubscribe) {
7944
9246
  reply.raw.end();
7945
9247
  return reply;
@@ -8114,7 +9416,7 @@ init_connection();
8114
9416
  init_ws_stream();
8115
9417
  init_types();
8116
9418
  import { nanoid as nanoid2 } from "nanoid";
8117
- var HYDRA_VERSION = "0.1.0";
9419
+ var HYDRA_VERSION2 = "0.1.0";
8118
9420
  var HYDRA_PROTOCOL_VERSION = 1;
8119
9421
  function registerAcpWsEndpoint(app, deps) {
8120
9422
  app.get("/acp", { websocket: true }, (socket, request) => {
@@ -8160,7 +9462,7 @@ function registerAcpWsEndpoint(app, deps) {
8160
9462
  agentArgs: hydraMeta.agentArgs
8161
9463
  });
8162
9464
  const client = bindClientToSession(connection, session, state);
8163
- session.attach(client, "full");
9465
+ await session.attach(client, "full");
8164
9466
  state.attached.set(session.sessionId, {
8165
9467
  sessionId: session.sessionId,
8166
9468
  clientId: client.clientId
@@ -8179,14 +9481,22 @@ function registerAcpWsEndpoint(app, deps) {
8179
9481
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
8180
9482
  let session = deps.manager.get(lookupId);
8181
9483
  if (!session) {
8182
- let resurrectParams = hydraHints ? {
8183
- hydraSessionId: params.sessionId,
8184
- upstreamSessionId: hydraHints.upstreamSessionId,
8185
- agentId: hydraHints.agentId,
8186
- cwd: hydraHints.cwd,
8187
- title: hydraHints.title,
8188
- agentArgs: hydraHints.agentArgs
8189
- } : await deps.manager.loadFromDisk(lookupId);
9484
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
9485
+ let resurrectParams = fromDisk;
9486
+ if (hydraHints) {
9487
+ resurrectParams = {
9488
+ hydraSessionId: params.sessionId,
9489
+ upstreamSessionId: hydraHints.upstreamSessionId,
9490
+ agentId: hydraHints.agentId,
9491
+ cwd: hydraHints.cwd,
9492
+ title: hydraHints.title ?? fromDisk?.title,
9493
+ agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
9494
+ currentModel: fromDisk?.currentModel,
9495
+ currentMode: fromDisk?.currentMode,
9496
+ agentCommands: fromDisk?.agentCommands,
9497
+ createdAt: fromDisk?.createdAt
9498
+ };
9499
+ }
8190
9500
  if (!resurrectParams) {
8191
9501
  const err = new Error(
8192
9502
  `session ${params.sessionId} not found and no resume hints provided`
@@ -8202,13 +9512,13 @@ function registerAcpWsEndpoint(app, deps) {
8202
9512
  state,
8203
9513
  params.clientInfo
8204
9514
  );
8205
- const replay = session.attach(client, params.historyPolicy);
9515
+ const replay = await session.attach(client, params.historyPolicy);
8206
9516
  state.attached.set(session.sessionId, {
8207
9517
  sessionId: session.sessionId,
8208
9518
  clientId: client.clientId
8209
9519
  });
8210
9520
  app.log.info(
8211
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
9521
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
8212
9522
  );
8213
9523
  for (const note of replay) {
8214
9524
  await connection.notify(note.method, note.params);
@@ -8304,7 +9614,7 @@ function registerAcpWsEndpoint(app, deps) {
8304
9614
  session = await deps.manager.resurrect(fromDisk);
8305
9615
  }
8306
9616
  const client = bindClientToSession(connection, session, state);
8307
- const replay = session.attach(client, "pending_only");
9617
+ const replay = await session.attach(client, "pending_only");
8308
9618
  state.attached.set(session.sessionId, {
8309
9619
  sessionId: session.sessionId,
8310
9620
  clientId: client.clientId
@@ -8370,7 +9680,7 @@ function buildResponseMeta(session) {
8370
9680
  function buildInitializeResult() {
8371
9681
  return {
8372
9682
  protocolVersion: HYDRA_PROTOCOL_VERSION,
8373
- agentInfo: { name: "hydra", version: HYDRA_VERSION },
9683
+ agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
8374
9684
  agentCapabilities: {
8375
9685
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
8376
9686
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -8409,14 +9719,14 @@ function bindClientToSession(connection, session, state, clientInfo) {
8409
9719
  }
8410
9720
 
8411
9721
  // src/daemon/server.ts
8412
- var HYDRA_VERSION2 = "0.1.0";
9722
+ var HYDRA_VERSION3 = "0.1.0";
8413
9723
  async function startDaemon(config) {
8414
9724
  ensureLoopbackOrTls(config);
8415
9725
  const httpsOptions = config.daemon.tls ? {
8416
- key: await fsp2.readFile(config.daemon.tls.key),
8417
- cert: await fsp2.readFile(config.daemon.tls.cert)
9726
+ key: await fsp3.readFile(config.daemon.tls.key),
9727
+ cert: await fsp3.readFile(config.daemon.tls.cert)
8418
9728
  } : void 0;
8419
- await fsp2.mkdir(paths.home(), { recursive: true });
9729
+ await fsp3.mkdir(paths.home(), { recursive: true });
8420
9730
  const { stream: logStream, fileStream } = await buildLogStream(
8421
9731
  config.daemon.logLevel
8422
9732
  );
@@ -8428,6 +9738,9 @@ async function startDaemon(config) {
8428
9738
  https: httpsOptions ?? null
8429
9739
  });
8430
9740
  await app.register(websocketPlugin);
9741
+ setBinaryInstallLogger((msg) => {
9742
+ app.log.info(msg);
9743
+ });
8431
9744
  const auth = bearerAuth({ config });
8432
9745
  app.addHook("onRequest", async (request, reply) => {
8433
9746
  if (request.routeOptions.config?.skipAuth) {
@@ -8440,10 +9753,11 @@ async function startDaemon(config) {
8440
9753
  });
8441
9754
  const registry = new Registry(config);
8442
9755
  const manager = new SessionManager(registry, void 0, void 0, {
8443
- idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
9756
+ idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
9757
+ defaultModels: config.defaultModels
8444
9758
  });
8445
9759
  const extensions = new ExtensionManager(extensionList(config));
8446
- registerHealthRoutes(app, HYDRA_VERSION2);
9760
+ registerHealthRoutes(app, HYDRA_VERSION3);
8447
9761
  registerSessionRoutes(app, manager, {
8448
9762
  agentId: config.defaultAgent,
8449
9763
  cwd: config.defaultCwd
@@ -8462,8 +9776,8 @@ async function startDaemon(config) {
8462
9776
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
8463
9777
  const address = app.server.address();
8464
9778
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
8465
- await fsp2.mkdir(paths.home(), { recursive: true });
8466
- await fsp2.writeFile(
9779
+ await fsp3.mkdir(paths.home(), { recursive: true });
9780
+ await fsp3.writeFile(
8467
9781
  paths.pidFile(),
8468
9782
  JSON.stringify({
8469
9783
  pid: process.pid,
@@ -8487,9 +9801,11 @@ async function startDaemon(config) {
8487
9801
  const shutdown = async () => {
8488
9802
  await extensions.stop();
8489
9803
  await manager.closeAll();
9804
+ await manager.flushMetaWrites();
9805
+ setBinaryInstallLogger(null);
8490
9806
  await app.close();
8491
9807
  try {
8492
- fs8.unlinkSync(paths.pidFile());
9808
+ fs10.unlinkSync(paths.pidFile());
8493
9809
  } catch {
8494
9810
  }
8495
9811
  try {
@@ -8528,13 +9844,13 @@ function ensureLoopbackOrTls(config) {
8528
9844
  init_daemon_bootstrap();
8529
9845
 
8530
9846
  // src/cli/commands/log-tail.ts
8531
- import * as fs9 from "fs";
8532
- import * as fsp3 from "fs/promises";
9847
+ import * as fs11 from "fs";
9848
+ import * as fsp4 from "fs/promises";
8533
9849
  async function runLogTail(logPath, argv, notFoundMessage) {
8534
9850
  const opts = parseLogTailFlags(argv);
8535
9851
  let stat3;
8536
9852
  try {
8537
- stat3 = await fsp3.stat(logPath);
9853
+ stat3 = await fsp4.stat(logPath);
8538
9854
  } catch (err) {
8539
9855
  const e = err;
8540
9856
  if (e.code === "ENOENT") {
@@ -8552,7 +9868,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
8552
9868
  process.stdout.write(`-- following ${logPath} --
8553
9869
  `);
8554
9870
  let pending = false;
8555
- const watcher = fs9.watch(logPath, () => {
9871
+ const watcher = fs11.watch(logPath, () => {
8556
9872
  if (pending) {
8557
9873
  return;
8558
9874
  }
@@ -8560,14 +9876,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
8560
9876
  setImmediate(async () => {
8561
9877
  pending = false;
8562
9878
  try {
8563
- const s = await fsp3.stat(logPath);
9879
+ const s = await fsp4.stat(logPath);
8564
9880
  if (s.size <= position) {
8565
9881
  if (s.size < position) {
8566
9882
  position = s.size;
8567
9883
  }
8568
9884
  return;
8569
9885
  }
8570
- const fd = await fsp3.open(logPath, "r");
9886
+ const fd = await fsp4.open(logPath, "r");
8571
9887
  try {
8572
9888
  const buf = Buffer.alloc(s.size - position);
8573
9889
  await fd.read(buf, 0, buf.length, position);
@@ -8580,10 +9896,10 @@ async function runLogTail(logPath, argv, notFoundMessage) {
8580
9896
  }
8581
9897
  });
8582
9898
  });
8583
- await new Promise((resolve2) => {
9899
+ await new Promise((resolve5) => {
8584
9900
  const finish = () => {
8585
9901
  watcher.close();
8586
- resolve2();
9902
+ resolve5();
8587
9903
  };
8588
9904
  process.once("SIGINT", finish);
8589
9905
  process.once("SIGTERM", finish);
@@ -8594,7 +9910,7 @@ async function printTail(logPath, fileSize, lines) {
8594
9910
  return fileSize;
8595
9911
  }
8596
9912
  const CHUNK = 64 * 1024;
8597
- const fd = await fsp3.open(logPath, "r");
9913
+ const fd = await fsp4.open(logPath, "r");
8598
9914
  try {
8599
9915
  let position = fileSize;
8600
9916
  let collected = "";
@@ -8651,20 +9967,37 @@ function parseLogTailFlags(argv) {
8651
9967
  }
8652
9968
 
8653
9969
  // src/cli/commands/daemon.ts
8654
- async function runDaemonStart() {
9970
+ async function runDaemonStart(flags = {}) {
8655
9971
  const config = await ensureConfig();
8656
- const handle = await startDaemon(config);
8657
- process.stdout.write(
8658
- `hydra-acp daemon listening on ${config.daemon.host}:${config.daemon.port}
9972
+ if (await pingHealth(config)) {
9973
+ const info2 = await readPidFile();
9974
+ process.stdout.write(
9975
+ `Daemon already running${info2 ? ` (pid ${info2.pid})` : ""}. Run \`hydra-acp daemon restart\` to restart it.
9976
+ `
9977
+ );
9978
+ return;
9979
+ }
9980
+ if (flagBool(flags, "foreground")) {
9981
+ const handle = await startDaemon(config);
9982
+ process.stdout.write(
9983
+ `hydra-acp daemon listening on ${config.daemon.host}:${config.daemon.port}
8659
9984
  `
9985
+ );
9986
+ const shutdown = async () => {
9987
+ process.stdout.write("Shutting down...\n");
9988
+ await handle.shutdown();
9989
+ process.exit(0);
9990
+ };
9991
+ process.on("SIGINT", () => void shutdown());
9992
+ process.on("SIGTERM", () => void shutdown());
9993
+ return;
9994
+ }
9995
+ spawnDaemonDetached();
9996
+ await waitForDaemonReady(config);
9997
+ const info = await readPidFile();
9998
+ process.stdout.write(
9999
+ `Daemon started on ${config.daemon.host}:${config.daemon.port}` + (info ? ` pid=${info.pid}` : "") + "\n"
8660
10000
  );
8661
- const shutdown = async () => {
8662
- process.stdout.write("Shutting down...\n");
8663
- await handle.shutdown();
8664
- process.exit(0);
8665
- };
8666
- process.on("SIGINT", () => void shutdown());
8667
- process.on("SIGTERM", () => void shutdown());
8668
10001
  }
8669
10002
  async function runDaemonStop() {
8670
10003
  const info = await readPidFile();
@@ -8746,7 +10079,7 @@ async function runDaemonStatus() {
8746
10079
  }
8747
10080
  async function readPidFile() {
8748
10081
  try {
8749
- const raw = await fsp4.readFile(paths.pidFile(), "utf8");
10082
+ const raw = await fsp5.readFile(paths.pidFile(), "utf8");
8750
10083
  return JSON.parse(raw);
8751
10084
  } catch (err) {
8752
10085
  const e = err;
@@ -8771,7 +10104,7 @@ init_sessions();
8771
10104
  // src/cli/commands/extensions.ts
8772
10105
  init_config();
8773
10106
  init_paths();
8774
- import * as fsp5 from "fs/promises";
10107
+ import * as fsp6 from "fs/promises";
8775
10108
  init_sessions();
8776
10109
  async function runExtensionsList() {
8777
10110
  const config = await loadConfig();
@@ -8912,11 +10245,7 @@ async function runExtensionsAdd(name, argv) {
8912
10245
  `Daemon refused to register ${name} (HTTP ${r.status}${detail}). Restart the daemon to apply.
8913
10246
  `
8914
10247
  );
8915
- } catch (err) {
8916
- process.stderr.write(
8917
- `Daemon not reachable (${err.message}). Config saved; the new extension will start on next daemon launch.
8918
- `
8919
- );
10248
+ } catch {
8920
10249
  }
8921
10250
  }
8922
10251
  async function runExtensionsRemove(name) {
@@ -8971,11 +10300,11 @@ async function runExtensionsRemove(name) {
8971
10300
  }
8972
10301
  }
8973
10302
  async function readRawConfig() {
8974
- const raw = await fsp5.readFile(paths.config(), "utf8");
10303
+ const raw = await fsp6.readFile(paths.config(), "utf8");
8975
10304
  return JSON.parse(raw);
8976
10305
  }
8977
10306
  async function writeRawConfig(raw) {
8978
- await fsp5.writeFile(
10307
+ await fsp6.writeFile(
8979
10308
  paths.config(),
8980
10309
  JSON.stringify(raw, null, 2) + "\n",
8981
10310
  { encoding: "utf8", mode: 384 }
@@ -9655,14 +10984,16 @@ async function main() {
9655
10984
  process.exit(2);
9656
10985
  return;
9657
10986
  }
9658
- const sessionId2 = resolveOption(flags2, "session-id");
10987
+ const launchResume = flags2.resume;
10988
+ const sessionId2 = typeof launchResume === "string" ? launchResume : resolveOption(flags2, "session-id");
9659
10989
  const name2 = resolveOption(flags2, "name");
9660
10990
  await runShim({ sessionId: sessionId2, agentId, agentArgs, name: name2 });
9661
10991
  return;
9662
10992
  }
9663
10993
  const { positional, flags } = parseArgs(argv);
9664
10994
  if (flags.version === true || positional[0] === "--version") {
9665
- process.stdout.write("hydra-acp 0.1.0\n");
10995
+ process.stdout.write(`hydra-acp ${readVersion()}
10996
+ `);
9666
10997
  return;
9667
10998
  }
9668
10999
  if (flags.help === true) {
@@ -9670,7 +11001,8 @@ async function main() {
9670
11001
  return;
9671
11002
  }
9672
11003
  const subcommand = positional[0];
9673
- const sessionId = resolveOption(flags, "session-id");
11004
+ const resumeFlag = flags.resume;
11005
+ const sessionId = typeof resumeFlag === "string" ? resumeFlag : resolveOption(flags, "session-id");
9674
11006
  const name = resolveOption(flags, "name");
9675
11007
  const agentIdFromFlag = resolveOption(flags, "agent-id");
9676
11008
  if (!subcommand) {
@@ -9697,7 +11029,7 @@ async function main() {
9697
11029
  const tail = argv.slice(daemonIdx + 1);
9698
11030
  const sub = tail[0];
9699
11031
  if (sub === "start" || sub === void 0) {
9700
- await runDaemonStart();
11032
+ await runDaemonStart(flags);
9701
11033
  return;
9702
11034
  }
9703
11035
  if (sub === "stop") {
@@ -9735,6 +11067,17 @@ async function main() {
9735
11067
  await runSessionsRm(positional[2]);
9736
11068
  return;
9737
11069
  }
11070
+ if (sub === "export") {
11071
+ const out = resolveOption(flags, "out");
11072
+ await runSessionsExport(positional[2], out);
11073
+ return;
11074
+ }
11075
+ if (sub === "import") {
11076
+ await runSessionsImport(positional[2], {
11077
+ replace: flags.replace === true
11078
+ });
11079
+ return;
11080
+ }
9738
11081
  process.stderr.write(`Unknown sessions subcommand: ${sub}
9739
11082
  `);
9740
11083
  process.exit(2);
@@ -9828,6 +11171,17 @@ async function dispatchTui(flags, base) {
9828
11171
  }
9829
11172
  await runTui(tuiOpts);
9830
11173
  }
11174
+ function readVersion() {
11175
+ try {
11176
+ const here = dirname4(fileURLToPath(import.meta.url));
11177
+ const pkg = JSON.parse(
11178
+ readFileSync(resolve4(here, "../package.json"), "utf8")
11179
+ );
11180
+ return pkg.version ?? "unknown";
11181
+ } catch {
11182
+ return "unknown";
11183
+ }
11184
+ }
9831
11185
  function printHelp() {
9832
11186
  process.stdout.write(
9833
11187
  [
@@ -9841,13 +11195,18 @@ function printHelp() {
9841
11195
  " Shim mode, force daemon to spawn <agent-id>",
9842
11196
  " from the registry. Args after <agent-id>",
9843
11197
  " are forwarded to the agent's command.",
9844
- " hydra-acp --session-id <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
11198
+ " hydra-acp --resume <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
9845
11199
  " hydra-acp init [--rotate-token] Initialize ~/.hydra-acp/config.json",
9846
- " hydra-acp daemon start|stop|restart|status",
11200
+ " hydra-acp daemon start [--foreground] Start daemon (detached by default; --foreground to attach)",
11201
+ " hydra-acp daemon stop|restart|status",
9847
11202
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
9848
11203
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
9849
11204
  " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
9850
11205
  " hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
11206
+ " hydra-acp sessions export <id> [--out <file>|.]",
11207
+ " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
11208
+ " hydra-acp sessions import <file>|- [--replace]",
11209
+ " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live)",
9851
11210
  " hydra-acp extensions list List configured extensions and live state",
9852
11211
  " hydra-acp extensions add <name> [opts] Add an extension to config",
9853
11212
  " hydra-acp extensions remove <name> Remove an extension from config",
@@ -9855,15 +11214,16 @@ function printHelp() {
9855
11214
  " hydra-acp extensions logs <name> [-f] [-n N]Tail or follow an extension's log",
9856
11215
  " hydra-acp agents [list] List agents in the cached registry",
9857
11216
  " hydra-acp agents refresh Force a registry re-fetch",
9858
- " hydra-acp tui flags: [--session-id <id>] [--resume] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
9859
- " Smart default: picks an existing live session if any exist in cwd, else creates a new one",
11217
+ " hydra-acp tui flags: [--resume [<id>]] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
11218
+ " --resume <id> attaches to a specific session; bare --resume picks the most-recent",
11219
+ " in cwd. Smart default (no flags): picks if any live sessions exist, else new.",
9860
11220
  " hydra-acp --version Print version",
9861
11221
  " hydra-acp --help Show this help",
9862
11222
  "",
9863
11223
  "Config knob flags accept env-var equivalents (flag wins):",
9864
- " --agent-id HYDRA_ACP_AGENT_ID",
9865
- " --session-id HYDRA_ACP_SESSION_ID",
9866
- " --name HYDRA_ACP_NAME",
11224
+ " --agent-id HYDRA_ACP_AGENT_ID",
11225
+ " --resume / --session-id HYDRA_ACP_SESSION_ID",
11226
+ " --name HYDRA_ACP_NAME",
9867
11227
  ""
9868
11228
  ].join("\n")
9869
11229
  );