@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/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/daemon/server.ts
2
- import * as fs7 from "fs";
3
- import * as fsp2 from "fs/promises";
2
+ import * as fs9 from "fs";
3
+ import * as fsp3 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
6
6
  import pino from "pino";
@@ -35,7 +35,11 @@ var paths = {
35
35
  currentLogFile: () => path.join(hydraHome(), "current.log"),
36
36
  registryCache: () => path.join(hydraHome(), "registry.json"),
37
37
  agentsDir: () => path.join(hydraHome(), "agents"),
38
- agentDir: (id) => path.join(hydraHome(), "agents", id),
38
+ // <platformKey>/<agentId>/<version>/ platform at the top so a Hydra
39
+ // home shared between machines (NFS, rsync'd dotfiles) keeps each
40
+ // machine's binaries cleanly separated. `ls agents/` immediately
41
+ // shows which platforms have ever installed anything.
42
+ agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
39
43
  sessionsDir: () => path.join(hydraHome(), "sessions"),
40
44
  // One directory per session id under sessions/. Co-locates the
41
45
  // session record, its transcript, and any future per-session state
@@ -62,7 +66,7 @@ var DaemonConfig = z.object({
62
66
  authToken: z.string().min(16),
63
67
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
64
68
  tls: TlsConfig.optional(),
65
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
69
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
66
70
  });
67
71
  var RegistryConfig = z.object({
68
72
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -95,6 +99,14 @@ var HydraConfig = z.object({
95
99
  daemon: DaemonConfig,
96
100
  registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
97
101
  defaultAgent: z.string().default("claude-acp"),
102
+ // Optional per-agent default model id. When a brand-new agent process
103
+ // is spawned (session/new path), hydra issues session/set_model with
104
+ // the matching entry so the user lands on their preferred model from
105
+ // the first prompt. Not applied on resurrect — those sessions keep
106
+ // whatever the user last selected. Keys are agent ids; values are the
107
+ // raw model id strings the agent expects (claude-acp: "claude-opus-4-7",
108
+ // opencode: "openai/gpt-5-codex" or "ncp-anthropic/claude-opus-4-7", …).
109
+ defaultModels: z.record(z.string(), z.string()).default({}),
98
110
  // Where new sessions land when POST /v1/sessions omits cwd. Stored as
99
111
  // a literal string ("~", "~/dev", "$HOME/work") so the config file is
100
112
  // portable across machines; expanded via expandHome at use time.
@@ -137,8 +149,7 @@ async function ensureConfig() {
137
149
  if (e.code !== "ENOENT") {
138
150
  throw err;
139
151
  }
140
- const config = defaultConfig();
141
- await writeConfig(config);
152
+ const config = await writeMinimalInitConfig();
142
153
  process.stderr.write(
143
154
  `hydra-acp: initialized ${paths.config()} with a fresh auth token.
144
155
  `
@@ -154,6 +165,16 @@ async function writeConfig(config) {
154
165
  mode: 384
155
166
  });
156
167
  }
168
+ async function writeMinimalInitConfig(authToken) {
169
+ const token = authToken ?? generateAuthToken();
170
+ const minimal = { daemon: { authToken: token } };
171
+ await fs.mkdir(paths.home(), { recursive: true });
172
+ await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
173
+ encoding: "utf8",
174
+ mode: 384
175
+ });
176
+ return HydraConfig.parse(minimal);
177
+ }
157
178
  function generateAuthToken() {
158
179
  const bytes = new Uint8Array(32);
159
180
  crypto.getRandomValues(bytes);
@@ -184,8 +205,213 @@ function expandHome(p) {
184
205
  }
185
206
 
186
207
  // src/core/registry.ts
187
- import * as fs2 from "fs/promises";
208
+ import * as fs3 from "fs/promises";
188
209
  import { z as z2 } from "zod";
210
+
211
+ // src/core/binary-install.ts
212
+ import * as fs2 from "fs";
213
+ import * as fsp from "fs/promises";
214
+ import * as path2 from "path";
215
+ import { spawn } from "child_process";
216
+ import { Readable } from "stream";
217
+ function currentPlatformKey() {
218
+ const osPart = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : void 0;
219
+ const archPart = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : void 0;
220
+ if (!osPart || !archPart) {
221
+ return void 0;
222
+ }
223
+ return `${osPart}-${archPart}`;
224
+ }
225
+ function pickBinaryTarget(distribution, platformKey = currentPlatformKey()) {
226
+ if (!platformKey) {
227
+ return void 0;
228
+ }
229
+ return distribution[platformKey];
230
+ }
231
+ var logSink = (msg) => {
232
+ process.stderr.write(msg + "\n");
233
+ };
234
+ function setBinaryInstallLogger(log) {
235
+ logSink = log ?? ((msg) => process.stderr.write(msg + "\n"));
236
+ }
237
+ async function ensureBinary(args) {
238
+ if (!args.target.archive) {
239
+ throw new Error(
240
+ `Agent ${args.agentId} has no archive URL for ${currentPlatformKey() ?? "this platform"}`
241
+ );
242
+ }
243
+ if (!args.target.cmd) {
244
+ throw new Error(`Agent ${args.agentId} has no cmd in its binary target`);
245
+ }
246
+ const platformKey = currentPlatformKey();
247
+ if (!platformKey) {
248
+ throw new Error(
249
+ `Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
250
+ );
251
+ }
252
+ const installDir = paths.agentInstallDir(
253
+ args.agentId,
254
+ platformKey,
255
+ args.version
256
+ );
257
+ const cmdPath = path2.resolve(installDir, args.target.cmd);
258
+ if (await fileExists(cmdPath)) {
259
+ return cmdPath;
260
+ }
261
+ await downloadAndExtract({
262
+ agentId: args.agentId,
263
+ archiveUrl: args.target.archive,
264
+ installDir
265
+ });
266
+ if (!await fileExists(cmdPath)) {
267
+ throw new Error(
268
+ `Agent ${args.agentId}: extracted archive did not contain ${args.target.cmd} (looked in ${installDir})`
269
+ );
270
+ }
271
+ if (process.platform !== "win32") {
272
+ await fsp.chmod(cmdPath, 493).catch(() => void 0);
273
+ }
274
+ return cmdPath;
275
+ }
276
+ async function downloadAndExtract(args) {
277
+ await fsp.mkdir(path2.dirname(args.installDir), { recursive: true });
278
+ const tempDir = await fsp.mkdtemp(`${args.installDir}.partial-`);
279
+ try {
280
+ logSink(`hydra-acp: downloading ${args.agentId} from ${args.archiveUrl}`);
281
+ const archivePath = await downloadTo({
282
+ url: args.archiveUrl,
283
+ dir: tempDir,
284
+ agentId: args.agentId
285
+ });
286
+ logSink(`hydra-acp: extracting ${args.agentId}`);
287
+ await extract(archivePath, tempDir);
288
+ await fsp.unlink(archivePath).catch(() => void 0);
289
+ try {
290
+ await fsp.rename(tempDir, args.installDir);
291
+ } catch (err) {
292
+ const e = err;
293
+ if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists(args.installDir)) {
294
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(
295
+ () => void 0
296
+ );
297
+ return;
298
+ }
299
+ throw err;
300
+ }
301
+ logSink(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
302
+ } catch (err) {
303
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
304
+ throw err;
305
+ }
306
+ }
307
+ async function downloadTo(args) {
308
+ const filename = inferArchiveName(args.url);
309
+ const dest = path2.join(args.dir, filename);
310
+ const response = await fetch(args.url, { redirect: "follow" });
311
+ if (!response.ok || !response.body) {
312
+ throw new Error(
313
+ `Failed to download ${args.url}: HTTP ${response.status} ${response.statusText}`
314
+ );
315
+ }
316
+ const total = Number(response.headers.get("content-length") ?? "0");
317
+ const out = fs2.createWriteStream(dest);
318
+ const nodeStream = Readable.fromWeb(response.body);
319
+ let received = 0;
320
+ let lastEmit = Date.now();
321
+ const EMIT_INTERVAL_MS = 2e3;
322
+ nodeStream.on("data", (chunk) => {
323
+ received += chunk.length;
324
+ const now = Date.now();
325
+ if (now - lastEmit < EMIT_INTERVAL_MS) {
326
+ return;
327
+ }
328
+ lastEmit = now;
329
+ logSink(formatProgress(args.agentId, received, total));
330
+ });
331
+ await new Promise((resolve3, reject) => {
332
+ nodeStream.on("error", reject);
333
+ out.on("error", reject);
334
+ out.on("finish", () => resolve3());
335
+ nodeStream.pipe(out);
336
+ });
337
+ logSink(formatProgress(
338
+ args.agentId,
339
+ received,
340
+ total,
341
+ /* done */
342
+ true
343
+ ));
344
+ return dest;
345
+ }
346
+ function formatProgress(agentId, received, total, done = false) {
347
+ const rxMb = (received / 1e6).toFixed(1);
348
+ if (total > 0) {
349
+ const totalMb = (total / 1e6).toFixed(1);
350
+ const pct = Math.min(100, Math.floor(received / total * 100));
351
+ const tag2 = done ? "downloaded" : "downloading";
352
+ return `hydra-acp: ${tag2} ${agentId} ${rxMb}/${totalMb} MB (${pct}%)`;
353
+ }
354
+ const tag = done ? "downloaded" : "downloading";
355
+ return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
356
+ }
357
+ function inferArchiveName(url) {
358
+ const u = new URL(url);
359
+ const base = path2.posix.basename(u.pathname);
360
+ return base || "archive";
361
+ }
362
+ async function extract(archivePath, dest) {
363
+ const lower = archivePath.toLowerCase();
364
+ if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
365
+ await run("tar", ["-xf", archivePath, "-C", dest]);
366
+ return;
367
+ }
368
+ if (lower.endsWith(".zip")) {
369
+ if (await hasCommand("unzip")) {
370
+ await run("unzip", ["-q", archivePath, "-d", dest]);
371
+ return;
372
+ }
373
+ await run("tar", ["-xf", archivePath, "-C", dest]);
374
+ return;
375
+ }
376
+ throw new Error(`Unsupported archive format: ${archivePath}`);
377
+ }
378
+ function run(cmd, args) {
379
+ return new Promise((resolve3, reject) => {
380
+ const child = spawn(cmd, args, {
381
+ stdio: ["ignore", "ignore", "inherit"]
382
+ });
383
+ child.on("error", reject);
384
+ child.on("exit", (code, signal) => {
385
+ if (code === 0) {
386
+ resolve3();
387
+ return;
388
+ }
389
+ reject(
390
+ new Error(
391
+ `${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
392
+ )
393
+ );
394
+ });
395
+ });
396
+ }
397
+ async function hasCommand(name) {
398
+ return new Promise((resolve3) => {
399
+ const finder = process.platform === "win32" ? "where" : "which";
400
+ const child = spawn(finder, [name], { stdio: "ignore" });
401
+ child.on("error", () => resolve3(false));
402
+ child.on("exit", (code) => resolve3(code === 0));
403
+ });
404
+ }
405
+ async function fileExists(p) {
406
+ try {
407
+ await fsp.access(p);
408
+ return true;
409
+ } catch {
410
+ return false;
411
+ }
412
+ }
413
+
414
+ // src/core/registry.ts
189
415
  var NpxDistribution = z2.object({
190
416
  package: z2.string(),
191
417
  args: z2.array(z2.string()).optional(),
@@ -193,7 +419,9 @@ var NpxDistribution = z2.object({
193
419
  });
194
420
  var BinaryTarget = z2.object({
195
421
  archive: z2.string().url().optional(),
196
- cmd: z2.string().optional()
422
+ cmd: z2.string().optional(),
423
+ args: z2.array(z2.string()).optional(),
424
+ env: z2.record(z2.string()).optional()
197
425
  });
198
426
  var BinaryDistribution = z2.object({
199
427
  "darwin-aarch64": BinaryTarget.optional(),
@@ -282,34 +510,59 @@ var Registry = class {
282
510
  if (!response.ok) {
283
511
  throw new Error(`Registry fetch failed: HTTP ${response.status}`);
284
512
  }
285
- const json = await response.json();
286
- const data = RegistryDocument.parse(json);
287
- return { fetchedAt: Date.now(), data };
513
+ const raw = await response.json();
514
+ const data = RegistryDocument.parse(raw);
515
+ return { fetchedAt: Date.now(), raw, data };
288
516
  }
289
517
  async readDiskCache() {
518
+ let text;
290
519
  try {
291
- const raw = await fs2.readFile(paths.registryCache(), "utf8");
292
- const parsed = JSON.parse(raw);
293
- if (typeof parsed.fetchedAt === "number" && parsed.data && Array.isArray(parsed.data.agents)) {
294
- return parsed;
295
- }
520
+ text = await fs3.readFile(paths.registryCache(), "utf8");
296
521
  } catch (err) {
297
522
  const e = err;
298
- if (e.code !== "ENOENT") {
299
- throw err;
523
+ if (e.code === "ENOENT") {
524
+ return void 0;
300
525
  }
526
+ throw err;
527
+ }
528
+ try {
529
+ const parsed = JSON.parse(text);
530
+ if (typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
531
+ return void 0;
532
+ }
533
+ const data = RegistryDocument.parse(parsed.data);
534
+ return { fetchedAt: parsed.fetchedAt, raw: parsed.data, data };
535
+ } catch {
536
+ return void 0;
301
537
  }
302
- return void 0;
303
538
  }
539
+ // Atomic write: dump to a sibling temp path, then rename onto the
540
+ // target. POSIX rename is atomic within a filesystem, so readers
541
+ // either see the old file or the fully-written new file — never a
542
+ // truncated middle. This also makes simultaneous writers safe
543
+ // without a lock file: the loser of the rename race just gets its
544
+ // version replaced by the winner's.
304
545
  async writeDiskCache(cache) {
305
- await fs2.mkdir(paths.home(), { recursive: true });
306
- await fs2.writeFile(
307
- paths.registryCache(),
308
- JSON.stringify(cache, null, 2) + "\n",
309
- "utf8"
310
- );
546
+ await fs3.mkdir(paths.home(), { recursive: true });
547
+ const final = paths.registryCache();
548
+ const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
549
+ const body = JSON.stringify(
550
+ { fetchedAt: cache.fetchedAt, data: cache.raw },
551
+ null,
552
+ 2
553
+ ) + "\n";
554
+ try {
555
+ await fs3.writeFile(tmp, body, "utf8");
556
+ await fs3.rename(tmp, final);
557
+ } catch (err) {
558
+ await fs3.unlink(tmp).catch(() => void 0);
559
+ throw err;
560
+ }
311
561
  }
312
562
  };
563
+ function randSuffix() {
564
+ return Math.random().toString(36).slice(2, 10);
565
+ }
313
566
  function npxPackageBasename(agent) {
314
567
  const pkg = agent.distribution.npx?.package;
315
568
  if (!pkg) {
@@ -320,7 +573,7 @@ function npxPackageBasename(agent) {
320
573
  const atIdx = afterSlash.lastIndexOf("@");
321
574
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
322
575
  }
323
- function planSpawn(agent, extraArgs = []) {
576
+ async function planSpawn(agent, extraArgs = []) {
324
577
  if (agent.distribution.npx) {
325
578
  const npx = agent.distribution.npx;
326
579
  const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
@@ -331,9 +584,22 @@ function planSpawn(agent, extraArgs = []) {
331
584
  };
332
585
  }
333
586
  if (agent.distribution.binary) {
334
- throw new Error(
335
- `Agent ${agent.id} uses binary distribution; not yet supported in hydra-acp. PRs welcome.`
336
- );
587
+ const target = pickBinaryTarget(agent.distribution.binary);
588
+ if (!target) {
589
+ throw new Error(
590
+ `Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
591
+ );
592
+ }
593
+ const cmdPath = await ensureBinary({
594
+ agentId: agent.id,
595
+ version: agent.version ?? "current",
596
+ target
597
+ });
598
+ return {
599
+ command: cmdPath,
600
+ args: [...target.args ?? [], ...extraArgs],
601
+ env: target.env ?? {}
602
+ };
337
603
  }
338
604
  if (agent.distribution.uvx) {
339
605
  const uvx = agent.distribution.uvx;
@@ -348,10 +614,11 @@ function planSpawn(agent, extraArgs = []) {
348
614
  }
349
615
 
350
616
  // src/core/session-manager.ts
351
- import * as fs5 from "fs/promises";
617
+ import * as fs7 from "fs/promises";
618
+ import { customAlphabet as customAlphabet3 } from "nanoid";
352
619
 
353
620
  // src/core/agent-instance.ts
354
- import { spawn } from "child_process";
621
+ import { spawn as spawn2 } from "child_process";
355
622
 
356
623
  // src/acp/types.ts
357
624
  import { z as z3 } from "zod";
@@ -364,7 +631,8 @@ var JsonRpcErrorCodes = {
364
631
  SessionNotFound: -32001,
365
632
  PermissionDenied: -32002,
366
633
  AlreadyAttached: -32003,
367
- AgentNotInstalled: -32005
634
+ AgentNotInstalled: -32005,
635
+ BundleAlreadyImported: -32010
368
636
  };
369
637
  var InitializeParams = z3.object({
370
638
  protocolVersion: z3.number().optional(),
@@ -470,12 +738,24 @@ var SessionListParams = z3.object({
470
738
  cursor: z3.string().optional(),
471
739
  limit: z3.number().int().positive().max(200).optional()
472
740
  });
741
+ var SessionListUsage = z3.object({
742
+ used: z3.number().optional(),
743
+ size: z3.number().optional(),
744
+ costAmount: z3.number().optional(),
745
+ costCurrency: z3.string().optional()
746
+ });
473
747
  var SessionListEntry = z3.object({
474
748
  sessionId: z3.string(),
475
749
  upstreamSessionId: z3.string().optional(),
476
750
  cwd: z3.string(),
477
751
  title: z3.string().optional(),
478
752
  agentId: z3.string().optional(),
753
+ // Last-known model id, so list views can render `<agent>(<model>)`
754
+ // without resurrecting cold sessions to look it up.
755
+ currentModel: z3.string().optional(),
756
+ // Last-known usage snapshot so list views can show per-session cost
757
+ // (and tokens, in callers that care) without resurrecting cold sessions.
758
+ currentUsage: SessionListUsage.optional(),
479
759
  updatedAt: z3.string(),
480
760
  attachedClients: z3.number().int().nonnegative(),
481
761
  status: z3.enum(["live", "cold"]).default("live"),
@@ -557,13 +837,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
557
837
  throw new Error("stream is closed");
558
838
  }
559
839
  const line = JSON.stringify(message) + "\n";
560
- await new Promise((resolve2, reject) => {
840
+ await new Promise((resolve3, reject) => {
561
841
  stdin.write(line, (err) => {
562
842
  if (err) {
563
843
  reject(err);
564
844
  return;
565
845
  }
566
- resolve2();
846
+ resolve3();
567
847
  });
568
848
  });
569
849
  },
@@ -622,9 +902,9 @@ var JsonRpcConnection = class {
622
902
  }
623
903
  const id = nanoid();
624
904
  const message = { jsonrpc: "2.0", id, method, params };
625
- const response = new Promise((resolve2, reject) => {
905
+ const response = new Promise((resolve3, reject) => {
626
906
  this.pending.set(id, {
627
- resolve: (result) => resolve2(result),
907
+ resolve: (result) => resolve3(result),
628
908
  reject
629
909
  });
630
910
  this.stream.send(message).catch((err) => {
@@ -762,7 +1042,7 @@ var AgentInstance = class _AgentInstance {
762
1042
  ...opts.plan.env,
763
1043
  ...opts.extraEnv ?? {}
764
1044
  };
765
- const child = spawn(opts.plan.command, opts.plan.args, {
1045
+ const child = spawn2(opts.plan.command, opts.plan.args, {
766
1046
  cwd: opts.cwd,
767
1047
  env,
768
1048
  stdio: ["pipe", "pipe", "pipe"]
@@ -795,8 +1075,8 @@ var HYDRA_COMMANDS = [
795
1075
  description: "Regenerate the session title via the agent (or set manually with an arg)"
796
1076
  },
797
1077
  {
798
- verb: "switch",
799
- name: "/hydra switch",
1078
+ verb: "agent",
1079
+ name: "/hydra agent",
800
1080
  argsHint: "<agent>",
801
1081
  description: "Swap the agent backing this session, preserving context"
802
1082
  }
@@ -813,10 +1093,12 @@ function hydraCommandsAsAdvertised() {
813
1093
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
814
1094
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
815
1095
  var HYDRA_SESSION_PREFIX = "hydra_session_";
1096
+ var MAX_HISTORY_ENTRIES = 1e3;
1097
+ var COMPACT_EVERY = 200;
816
1098
  var Session = class {
817
1099
  sessionId;
818
1100
  cwd;
819
- // agent / agentId / upstreamSessionId are mutable so /hydra switch can
1101
+ // agent / agentId / upstreamSessionId are mutable so /hydra agent can
820
1102
  // replace the underlying agent process while keeping the same Session
821
1103
  // record. agentMeta is the metadata returned by the agent at session/new
822
1104
  // time; it gets refreshed on switch too.
@@ -831,9 +1113,10 @@ var Session = class {
831
1113
  // stale-prone for snapshot-shaped events).
832
1114
  currentModel;
833
1115
  currentMode;
1116
+ currentUsage;
834
1117
  updatedAt;
1118
+ createdAt;
835
1119
  clients = /* @__PURE__ */ new Map();
836
- history = [];
837
1120
  historyStore;
838
1121
  promptQueue = [];
839
1122
  promptInFlight = false;
@@ -849,6 +1132,15 @@ var Session = class {
849
1132
  // True once we've observed our first session/prompt; gates the
850
1133
  // first-prompt-seeded title so subsequent prompts don't churn it.
851
1134
  firstPromptSeeded = false;
1135
+ // Wall-clock when the active prompt started, undefined when idle.
1136
+ // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
1137
+ // Drives the mid-turn elapsed counter delivered to fresh attachers.
1138
+ promptStartedAt;
1139
+ // Counts appends since the last compaction. When it hits COMPACT_EVERY
1140
+ // we ask the history store to trim the file to the most recent
1141
+ // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
1142
+ // file-size checks.
1143
+ appendCount = 0;
852
1144
  // Permission requests that have been broadcast to one or more
853
1145
  // clients but have not yet resolved. Replayed to clients that
854
1146
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -863,6 +1155,12 @@ var Session = class {
863
1155
  internalPromptCapture;
864
1156
  idleTimeoutMs;
865
1157
  idleTimer;
1158
+ // Time of the last recordable broadcast (or session creation, if
1159
+ // none yet). Drives the inactivity-based idle close; deliberately
1160
+ // does NOT include snapshot state pings (model/mode/title/commands)
1161
+ // or attach/detach, which would otherwise let passive observers
1162
+ // and noisy state churn keep a quiet session alive forever.
1163
+ lastRecordedAt;
866
1164
  spawnReplacementAgent;
867
1165
  agentChangeHandlers = [];
868
1166
  // Last available_commands_update we observed from the agent. Stored
@@ -877,6 +1175,7 @@ var Session = class {
877
1175
  agentCommandsHandlers = [];
878
1176
  modelHandlers = [];
879
1177
  modeHandlers = [];
1178
+ usageHandlers = [];
880
1179
  constructor(init) {
881
1180
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
882
1181
  this.cwd = init.cwd;
@@ -888,6 +1187,7 @@ var Session = class {
888
1187
  this.title = init.title;
889
1188
  this.currentModel = init.currentModel;
890
1189
  this.currentMode = init.currentMode;
1190
+ this.currentUsage = init.currentUsage;
891
1191
  if (init.agentCommands && init.agentCommands.length > 0) {
892
1192
  this.agentAdvertisedCommands = [...init.agentCommands];
893
1193
  }
@@ -897,11 +1197,11 @@ var Session = class {
897
1197
  this.firstPromptSeeded = true;
898
1198
  }
899
1199
  this.historyStore = init.historyStore;
900
- if (init.seedHistory && init.seedHistory.length > 0) {
901
- this.history = [...init.seedHistory];
902
- }
903
1200
  this.updatedAt = Date.now();
1201
+ this.createdAt = init.createdAt ?? this.updatedAt;
1202
+ this.lastRecordedAt = this.updatedAt;
904
1203
  this.wireAgent(this.agent);
1204
+ this.scheduleIdleCheck();
905
1205
  }
906
1206
  broadcastMergedCommands() {
907
1207
  const merged = [
@@ -917,7 +1217,7 @@ var Session = class {
917
1217
  });
918
1218
  }
919
1219
  // Register session/update, session/request_permission, and onExit
920
- // handlers on an agent connection. Re-run on every /hydra switch so
1220
+ // handlers on an agent connection. Re-run on every /hydra agent so
921
1221
  // the new agent is plumbed identically. The exit handler's identity
922
1222
  // check is what makes switching safe: when the *old* agent exits as
923
1223
  // part of a swap, this.agent has already been replaced, so we no-op
@@ -941,6 +1241,10 @@ var Session = class {
941
1241
  this.recordAndBroadcast("session/update", params);
942
1242
  return;
943
1243
  }
1244
+ if (this.maybeApplyAgentUsage(params)) {
1245
+ this.recordAndBroadcast("session/update", params);
1246
+ return;
1247
+ }
944
1248
  this.maybeApplyAgentSessionInfo(params);
945
1249
  this.recordAndBroadcast("session/update", params);
946
1250
  });
@@ -961,34 +1265,20 @@ var Session = class {
961
1265
  return this.clients.size;
962
1266
  }
963
1267
  // Wall-clock when the in-flight agent turn began, or undefined when
964
- // idle. Derived from history: the most recent prompt_received without
965
- // a later turn_complete is the outstanding turn, and its recordedAt
966
- // is when the prompt was first broadcast. Used by buildResponseMeta
967
- // so a fresh client reattaching mid-turn boots up with the busy
968
- // banner showing real elapsed time.
1268
+ // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
1269
+ // so the daemon can hand a fresh attacher mid-turn the right elapsed
1270
+ // time without scanning history.
969
1271
  get turnStartedAt() {
970
- for (let i = this.history.length - 1; i >= 0; i--) {
971
- const entry = this.history[i];
972
- if (!entry) {
973
- continue;
974
- }
975
- const params = entry.params;
976
- const kind = params?.update?.sessionUpdate;
977
- if (kind === "turn_complete") {
978
- return void 0;
979
- }
980
- if (kind === "prompt_received") {
981
- return entry.recordedAt;
982
- }
983
- }
984
- return void 0;
1272
+ return this.promptStartedAt;
985
1273
  }
986
- // Snapshot of the current in-memory replay history. Used by the
987
- // HTTP history endpoint to deliver the "what's accumulated so far"
988
- // prefix before optionally tailing with onBroadcast. Returns a copy
989
- // so callers can't mutate our cache.
990
- getHistorySnapshot() {
991
- return [...this.history];
1274
+ // Read the persisted history from disk. Returns [] if no history
1275
+ // file exists (fresh session, never prompted). Used by attach() and
1276
+ // the HTTP /history endpoint.
1277
+ async getHistorySnapshot() {
1278
+ if (!this.historyStore) {
1279
+ return [];
1280
+ }
1281
+ return this.historyStore.load(this.sessionId).catch(() => []);
992
1282
  }
993
1283
  // Subscribe to recordable broadcast entries — fires once per entry
994
1284
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -1004,6 +1294,10 @@ var Session = class {
1004
1294
  }
1005
1295
  };
1006
1296
  }
1297
+ // Register a client and (asynchronously) load the replay slice it
1298
+ // should receive. Validation errors throw synchronously so callers
1299
+ // can rely on either the registration being in effect or having
1300
+ // thrown; the disk-load is the only async work.
1007
1301
  attach(client, historyPolicy) {
1008
1302
  if (this.closed) {
1009
1303
  throw withCode(
@@ -1019,14 +1313,10 @@ var Session = class {
1019
1313
  }
1020
1314
  this.clients.set(client.clientId, client);
1021
1315
  this.updatedAt = Date.now();
1022
- this.cancelIdleTimer();
1023
- if (historyPolicy === "none") {
1024
- return [];
1025
- }
1026
- if (historyPolicy === "pending_only") {
1027
- return [];
1316
+ if (historyPolicy === "none" || historyPolicy === "pending_only") {
1317
+ return Promise.resolve([]);
1028
1318
  }
1029
- return [...this.history];
1319
+ return this.getHistorySnapshot();
1030
1320
  }
1031
1321
  // Dispatch in-flight permission requests to a freshly-attached
1032
1322
  // client. Called by the daemon's WS handler *after* it finishes
@@ -1040,7 +1330,6 @@ var Session = class {
1040
1330
  detach(clientId) {
1041
1331
  if (this.clients.delete(clientId)) {
1042
1332
  this.updatedAt = Date.now();
1043
- this.maybeStartIdleTimer();
1044
1333
  }
1045
1334
  }
1046
1335
  async prompt(clientId, params) {
@@ -1086,6 +1375,7 @@ var Session = class {
1086
1375
  if (client.clientInfo?.version) {
1087
1376
  sentBy.version = client.clientInfo.version;
1088
1377
  }
1378
+ this.promptStartedAt = Date.now();
1089
1379
  this.recordAndBroadcast(
1090
1380
  "session/update",
1091
1381
  {
@@ -1122,6 +1412,7 @@ var Session = class {
1122
1412
  if (stopReason !== void 0) {
1123
1413
  update.stopReason = stopReason;
1124
1414
  }
1415
+ this.promptStartedAt = void 0;
1125
1416
  this.recordAndBroadcast(
1126
1417
  "session/update",
1127
1418
  {
@@ -1271,6 +1562,49 @@ var Session = class {
1271
1562
  }
1272
1563
  return true;
1273
1564
  }
1565
+ // usage_update carries any subset of {used, size, cost.amount,
1566
+ // cost.currency}. Merge non-undefined fields onto currentUsage so a
1567
+ // sparse update preserves prior values, and fire usage handlers only
1568
+ // if something actually changed.
1569
+ maybeApplyAgentUsage(params) {
1570
+ const obj = params ?? {};
1571
+ const update = obj.update ?? {};
1572
+ if (update.sessionUpdate !== "usage_update") {
1573
+ return false;
1574
+ }
1575
+ const next = { ...this.currentUsage ?? {} };
1576
+ let changed = false;
1577
+ if (typeof update.used === "number" && next.used !== update.used) {
1578
+ next.used = update.used;
1579
+ changed = true;
1580
+ }
1581
+ if (typeof update.size === "number" && next.size !== update.size) {
1582
+ next.size = update.size;
1583
+ changed = true;
1584
+ }
1585
+ if (update.cost && typeof update.cost === "object") {
1586
+ const cost = update.cost;
1587
+ if (typeof cost.amount === "number" && next.costAmount !== cost.amount) {
1588
+ next.costAmount = cost.amount;
1589
+ changed = true;
1590
+ }
1591
+ if (typeof cost.currency === "string" && next.costCurrency !== cost.currency) {
1592
+ next.costCurrency = cost.currency;
1593
+ changed = true;
1594
+ }
1595
+ }
1596
+ if (!changed) {
1597
+ return true;
1598
+ }
1599
+ this.currentUsage = next;
1600
+ for (const handler of this.usageHandlers) {
1601
+ try {
1602
+ handler(next);
1603
+ } catch {
1604
+ }
1605
+ }
1606
+ return true;
1607
+ }
1274
1608
  // Update the cached agent command list, fire persist handlers, and
1275
1609
  // broadcast the merged list to attached clients. Idempotent on a
1276
1610
  // structurally identical list so we don't churn meta.json on noisy
@@ -1301,12 +1635,21 @@ var Session = class {
1301
1635
  onModeChange(handler) {
1302
1636
  this.modeHandlers.push(handler);
1303
1637
  }
1638
+ onUsageChange(handler) {
1639
+ this.usageHandlers.push(handler);
1640
+ }
1304
1641
  // Returns a freshly merged command list (hydra ∪ agent) for callers
1305
1642
  // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1306
1643
  // assembling the attach response.
1307
1644
  mergedAvailableCommands() {
1308
1645
  return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1309
1646
  }
1647
+ // The agent's own advertised commands (not merged with hydra verbs).
1648
+ // Used by SessionManager to persist into meta.json so cold resurrect
1649
+ // can re-deliver via the attach response _meta.
1650
+ agentOnlyAdvertisedCommands() {
1651
+ return [...this.agentAdvertisedCommands];
1652
+ }
1310
1653
  // Pick up an agent-emitted session_info_update and store its title
1311
1654
  // as our canonical record. The notification is also forwarded to
1312
1655
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1357,8 +1700,8 @@ var Session = class {
1357
1700
  switch (verb) {
1358
1701
  case "title":
1359
1702
  return this.runTitleCommand(arg);
1360
- case "switch":
1361
- return this.runSwitchCommand(arg);
1703
+ case "agent":
1704
+ return this.runAgentCommand(arg);
1362
1705
  default: {
1363
1706
  const err = new Error(
1364
1707
  `no dispatcher for /hydra verb ${verb}`
@@ -1394,7 +1737,7 @@ var Session = class {
1394
1737
  }
1395
1738
  // Send a prompt to the underlying agent and capture its reply chunks
1396
1739
  // privately (no fan-out to clients, no recording into history). Used
1397
- // by /hydra title's regen path and /hydra switch's transcript-injection
1740
+ // by /hydra title's regen path and /hydra agent's transcript-injection
1398
1741
  // path. Returns the joined agent_message_chunk text.
1399
1742
  async runInternalPrompt(text) {
1400
1743
  if (this.internalPromptCapture) {
@@ -1416,10 +1759,10 @@ var Session = class {
1416
1759
  // record. Spawns the new agent first so a failure leaves the old one
1417
1760
  // intact; then injects a synthesized transcript so the new agent has
1418
1761
  // context for the next turn.
1419
- runSwitchCommand(newAgentId) {
1762
+ runAgentCommand(newAgentId) {
1420
1763
  if (!newAgentId) {
1421
1764
  throw withCode(
1422
- new Error("/hydra switch requires an agent id"),
1765
+ new Error("/hydra agent requires an agent id"),
1423
1766
  JsonRpcErrorCodes.InvalidParams
1424
1767
  );
1425
1768
  }
@@ -1438,7 +1781,7 @@ var Session = class {
1438
1781
  const spawnAgent = this.spawnReplacementAgent;
1439
1782
  return this.enqueuePrompt(async () => {
1440
1783
  const oldAgentId = this.agentId;
1441
- const transcript = this.buildSwitchTranscript(oldAgentId);
1784
+ const transcript = await this.buildSwitchTranscript(oldAgentId);
1442
1785
  const fresh = await spawnAgent({
1443
1786
  agentId: newAgentId,
1444
1787
  cwd: this.cwd,
@@ -1470,15 +1813,20 @@ var Session = class {
1470
1813
  return { stopReason: "end_turn" };
1471
1814
  });
1472
1815
  }
1473
- // Walk this.history (rewritten-for-clients notification cache) and
1474
- // produce a labeled transcript suitable for handing to a fresh agent.
1475
- // Includes user prompts, agent replies, and tool-call outcomes; skips
1476
- // hydra-synthesized markers (so multi-hop switches don't accumulate
1477
- // banners) and other update kinds we don't think the next agent
1478
- // benefits from re-seeing (plans, thoughts, mode/model/usage).
1479
- buildSwitchTranscript(prevAgentId) {
1816
+ // Walk the persisted history and produce a labeled transcript suitable
1817
+ // for handing to a fresh agent. Includes user prompts, agent replies,
1818
+ // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
1819
+ // switches don't accumulate banners) and other update kinds we don't
1820
+ // think the next agent benefits from re-seeing (plans, thoughts,
1821
+ // mode/model/usage).
1822
+ //
1823
+ // The header text defaults to the agent-swap framing; callers like
1824
+ // seedFromImport pass a custom header when the new agent is taking
1825
+ // over an imported session rather than swapping mid-conversation.
1826
+ async buildSwitchTranscript(prevAgentId, headerOverride) {
1480
1827
  const lines = [];
1481
- for (const note of this.history) {
1828
+ const history = await this.getHistorySnapshot();
1829
+ for (const note of history) {
1482
1830
  if (note.method !== "session/update") {
1483
1831
  continue;
1484
1832
  }
@@ -1532,29 +1880,53 @@ var Session = class {
1532
1880
  if (current) {
1533
1881
  coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1534
1882
  }
1883
+ const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
1884
+ 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.`;
1535
1885
  return [
1536
- `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1537
- `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`,
1886
+ intro,
1887
+ followup,
1538
1888
  "",
1539
1889
  "--- begin transcript ---",
1540
1890
  ...coalesced,
1541
1891
  "--- end transcript ---"
1542
1892
  ].join("\n");
1543
1893
  }
1894
+ // Replay the persisted history into a freshly-spawned agent so an
1895
+ // imported session has context. Called by SessionManager.doResurrect
1896
+ // on the first wake-up of a session whose meta.json has an empty
1897
+ // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1898
+ // any user prompts arriving mid-seed queue behind it (mirrors the
1899
+ // /hydra agent path so the agent isn't asked to respond to a user
1900
+ // turn before it has absorbed the imported transcript). Best-effort:
1901
+ // if the agent fails to absorb the transcript we still leave the
1902
+ // session usable — the user just continues without context.
1903
+ async seedFromImport() {
1904
+ await this.enqueuePrompt(async () => {
1905
+ const transcript = await this.buildSwitchTranscript(this.agentId, {
1906
+ intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
1907
+ followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
1908
+ });
1909
+ if (!transcript) {
1910
+ return void 0;
1911
+ }
1912
+ await this.runInternalPrompt(transcript).catch(() => void 0);
1913
+ return void 0;
1914
+ });
1915
+ }
1544
1916
  // Tell every attached client (a) the agent identity has changed
1545
- // (session_info_update with an agentId field clients that already
1546
- // listen for title updates pick this up; older clients ignore unknown
1547
- // fields harmlessly) and (b) drop a visible banner into the transcript
1548
- // so users see the switch rather than just suddenly getting answers
1549
- // from a different agent. Both updates carry _meta["hydra-acp"].synthetic
1550
- // so a future /hydra switch's transcript builder filters them out.
1917
+ // (session_info_update carrying agentId inside _meta["hydra-acp"]
1918
+ // the ACP schema for session_info_update is just title/updatedAt/_meta,
1919
+ // so non-hydra clients harmlessly ignore the extension; hydra-aware
1920
+ // ones read it and relabel) and (b) drop a visible banner into the
1921
+ // transcript so users see the switch rather than just suddenly getting
1922
+ // answers from a different agent. Both updates carry synthetic=true
1923
+ // so a future /hydra agent's transcript builder filters them out.
1551
1924
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1552
1925
  this.recordAndBroadcast("session/update", {
1553
1926
  sessionId: this.sessionId,
1554
1927
  update: {
1555
1928
  sessionUpdate: "session_info_update",
1556
- agentId: newAgentId,
1557
- _meta: { "hydra-acp": { synthetic: true } }
1929
+ _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
1558
1930
  }
1559
1931
  });
1560
1932
  this.recordAndBroadcast("session/update", {
@@ -1585,22 +1957,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1585
1957
  handler(opts);
1586
1958
  }
1587
1959
  }
1588
- maybeStartIdleTimer() {
1589
- if (this.closed || this.clients.size > 0 || this.idleTimeoutMs <= 0) {
1960
+ // Last meaningful activity timestamp. Bumped only by recordable
1961
+ // broadcasts in recordAndBroadcast the same signal historyMtimeIso
1962
+ // uses for the picker. Initialized at construction (and seeded from
1963
+ // the newest entry on resurrect) so the inactivity window starts
1964
+ // ticking from a sensible floor when there's no history yet.
1965
+ get lastActivityAt() {
1966
+ return this.lastRecordedAt;
1967
+ }
1968
+ // (Re-)arm the idle timer to fire when the inactivity window
1969
+ // elapses past lastActivityAt. Called once at construction and after
1970
+ // every recorded broadcast. The previous design gated on
1971
+ // clients.size === 0; we drop that gate because extensions
1972
+ // (slack/notifier/approver/browser) hold persistent attaches that
1973
+ // would otherwise keep a quiet session alive forever.
1974
+ scheduleIdleCheck() {
1975
+ if (this.closed || this.idleTimeoutMs <= 0) {
1590
1976
  return;
1591
1977
  }
1978
+ const dueAt = this.lastActivityAt + this.idleTimeoutMs;
1979
+ this.armIdleTimer(Math.max(0, dueAt - Date.now()));
1980
+ }
1981
+ armIdleTimer(delay) {
1592
1982
  if (this.idleTimer) {
1593
- return;
1983
+ clearTimeout(this.idleTimer);
1594
1984
  }
1595
1985
  this.idleTimer = setTimeout(() => {
1596
1986
  this.idleTimer = void 0;
1597
- const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1598
- void this.close(opts).catch(() => void 0);
1599
- }, this.idleTimeoutMs);
1987
+ this.checkIdle();
1988
+ }, delay);
1600
1989
  if (typeof this.idleTimer.unref === "function") {
1601
1990
  this.idleTimer.unref();
1602
1991
  }
1603
1992
  }
1993
+ checkIdle() {
1994
+ if (this.closed || this.idleTimeoutMs <= 0) {
1995
+ return;
1996
+ }
1997
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
1998
+ this.armIdleTimer(this.idleTimeoutMs);
1999
+ return;
2000
+ }
2001
+ const idle = Date.now() - this.lastActivityAt;
2002
+ if (idle < this.idleTimeoutMs) {
2003
+ this.armIdleTimer(this.idleTimeoutMs - idle);
2004
+ return;
2005
+ }
2006
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
2007
+ void this.close(opts).catch(() => void 0);
2008
+ }
1604
2009
  cancelIdleTimer() {
1605
2010
  if (this.idleTimer) {
1606
2011
  clearTimeout(this.idleTimer);
@@ -1625,17 +2030,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1625
2030
  params: rewritten,
1626
2031
  recordedAt: Date.now()
1627
2032
  };
1628
- this.history.push(entry);
1629
- let trimmed = false;
1630
- if (this.history.length > 1e3) {
1631
- this.history = this.history.slice(-500);
1632
- trimmed = true;
1633
- }
2033
+ this.lastRecordedAt = entry.recordedAt;
2034
+ this.appendCount += 1;
1634
2035
  if (this.historyStore) {
1635
- if (trimmed) {
1636
- void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1637
- } else {
1638
- void this.historyStore.append(this.sessionId, entry).catch(
2036
+ const store = this.historyStore;
2037
+ void store.append(this.sessionId, entry).catch(() => void 0);
2038
+ if (this.appendCount >= COMPACT_EVERY) {
2039
+ this.appendCount = 0;
2040
+ void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1639
2041
  () => void 0
1640
2042
  );
1641
2043
  }
@@ -1646,6 +2048,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1646
2048
  } catch {
1647
2049
  }
1648
2050
  }
2051
+ this.scheduleIdleCheck();
1649
2052
  }
1650
2053
  this.updatedAt = Date.now();
1651
2054
  for (const client of this.clients.values()) {
@@ -1664,7 +2067,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1664
2067
  );
1665
2068
  }
1666
2069
  const clientParams = this.rewriteForClient(params);
1667
- return new Promise((resolve2, reject) => {
2070
+ return new Promise((resolve3, reject) => {
1668
2071
  let settled = false;
1669
2072
  const outbound = [];
1670
2073
  const entry = { addClient: sendTo };
@@ -1699,7 +2102,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1699
2102
  result
1700
2103
  }).catch(() => void 0);
1701
2104
  }
1702
- resolve2(result);
2105
+ resolve3(result);
1703
2106
  });
1704
2107
  }).catch((err) => {
1705
2108
  settle(() => reject(err));
@@ -1711,16 +2114,16 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1711
2114
  });
1712
2115
  }
1713
2116
  async enqueuePrompt(task) {
1714
- return new Promise((resolve2, reject) => {
1715
- const run = async () => {
2117
+ return new Promise((resolve3, reject) => {
2118
+ const run2 = async () => {
1716
2119
  try {
1717
2120
  const result = await task();
1718
- resolve2(result);
2121
+ resolve3(result);
1719
2122
  } catch (err) {
1720
2123
  reject(err);
1721
2124
  }
1722
2125
  };
1723
- this.promptQueue.push(run);
2126
+ this.promptQueue.push(run2);
1724
2127
  void this.drainQueue();
1725
2128
  });
1726
2129
  }
@@ -1749,7 +2152,8 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1749
2152
  "session_info_update",
1750
2153
  "current_model_update",
1751
2154
  "current_mode_update",
1752
- "available_commands_update"
2155
+ "available_commands_update",
2156
+ "usage_update"
1753
2157
  ]);
1754
2158
  function isStateUpdate(method, params) {
1755
2159
  if (method !== "session/update") {
@@ -1834,17 +2238,43 @@ function firstLine(text, max) {
1834
2238
  }
1835
2239
 
1836
2240
  // src/core/session-store.ts
1837
- import * as fs3 from "fs/promises";
1838
- import * as path2 from "path";
2241
+ import * as fs4 from "fs/promises";
2242
+ import * as path3 from "path";
2243
+ import { customAlphabet as customAlphabet2 } from "nanoid";
1839
2244
  import { z as z4 } from "zod";
2245
+ var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2246
+ var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
2247
+ var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
2248
+ function generateLineageId() {
2249
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
2250
+ }
1840
2251
  var PersistedAgentCommand = z4.object({
1841
2252
  name: z4.string(),
1842
2253
  description: z4.string().optional()
1843
2254
  });
2255
+ var PersistedUsage = z4.object({
2256
+ used: z4.number().optional(),
2257
+ size: z4.number().optional(),
2258
+ costAmount: z4.number().optional(),
2259
+ costCurrency: z4.string().optional()
2260
+ });
1844
2261
  var SessionRecord = z4.object({
1845
2262
  version: z4.literal(1),
1846
2263
  sessionId: z4.string(),
2264
+ // Optional for back-compat with records written before this field
2265
+ // existed; mergeForPersistence generates one on next write so any
2266
+ // touched session converges to having a lineageId. A record that
2267
+ // never gets written again (truly cold and untouched) just won't
2268
+ // participate in lineage-based dedup, which is correct — it was
2269
+ // never exported, so no incoming bundle can claim its lineage.
2270
+ lineageId: z4.string().optional(),
1847
2271
  upstreamSessionId: z4.string(),
2272
+ // When non-empty, marks a session that was created by import and is
2273
+ // waiting for its first attach to bootstrap a fresh upstream agent
2274
+ // and replay the imported history as a takeover transcript. The
2275
+ // origin's local id at export time, kept for debuggability and as a
2276
+ // breadcrumb in `sessions list` (informational, not used for routing).
2277
+ importedFromSessionId: z4.string().optional(),
1848
2278
  agentId: z4.string(),
1849
2279
  cwd: z4.string(),
1850
2280
  title: z4.string().optional(),
@@ -1855,6 +2285,7 @@ var SessionRecord = z4.object({
1855
2285
  // replay of a snapshot-shaped notification.
1856
2286
  currentModel: z4.string().optional(),
1857
2287
  currentMode: z4.string().optional(),
2288
+ currentUsage: PersistedUsage.optional(),
1858
2289
  agentCommands: z4.array(PersistedAgentCommand).optional(),
1859
2290
  createdAt: z4.string(),
1860
2291
  updatedAt: z4.string()
@@ -1868,9 +2299,9 @@ function assertSafeId(id) {
1868
2299
  var SessionStore = class {
1869
2300
  async write(record) {
1870
2301
  assertSafeId(record.sessionId);
1871
- await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2302
+ await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
1872
2303
  const full = { version: 1, ...record };
1873
- await fs3.writeFile(
2304
+ await fs4.writeFile(
1874
2305
  paths.sessionFile(record.sessionId),
1875
2306
  JSON.stringify(full, null, 2) + "\n",
1876
2307
  { encoding: "utf8", mode: 384 }
@@ -1882,7 +2313,7 @@ var SessionStore = class {
1882
2313
  }
1883
2314
  let raw;
1884
2315
  try {
1885
- raw = await fs3.readFile(paths.sessionFile(sessionId), "utf8");
2316
+ raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
1886
2317
  } catch (err) {
1887
2318
  const e = err;
1888
2319
  if (e.code === "ENOENT") {
@@ -1901,7 +2332,7 @@ var SessionStore = class {
1901
2332
  return;
1902
2333
  }
1903
2334
  try {
1904
- await fs3.unlink(paths.sessionFile(sessionId));
2335
+ await fs4.unlink(paths.sessionFile(sessionId));
1905
2336
  } catch (err) {
1906
2337
  const e = err;
1907
2338
  if (e.code !== "ENOENT") {
@@ -1909,7 +2340,7 @@ var SessionStore = class {
1909
2340
  }
1910
2341
  }
1911
2342
  try {
1912
- await fs3.rmdir(paths.sessionDir(sessionId));
2343
+ await fs4.rmdir(paths.sessionDir(sessionId));
1913
2344
  } catch (err) {
1914
2345
  const e = err;
1915
2346
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -1917,10 +2348,29 @@ var SessionStore = class {
1917
2348
  }
1918
2349
  }
1919
2350
  }
2351
+ // Find a persisted session by lineageId. Used by SessionManager.import
2352
+ // to detect bundles that have already been imported (lineageId match)
2353
+ // so we can either error out or, with replace:true, overwrite.
2354
+ // Returns undefined if no record has that lineageId. Records that
2355
+ // pre-date the lineageId field simply don't match — which is
2356
+ // correct: they were never exported, so no incoming bundle can
2357
+ // legitimately claim their lineage.
2358
+ async findByLineageId(lineageId) {
2359
+ if (lineageId.length === 0) {
2360
+ return void 0;
2361
+ }
2362
+ const all = await this.list().catch(() => []);
2363
+ for (const record of all) {
2364
+ if (record.lineageId === lineageId) {
2365
+ return record;
2366
+ }
2367
+ }
2368
+ return void 0;
2369
+ }
1920
2370
  async list() {
1921
2371
  let entries;
1922
2372
  try {
1923
- entries = await fs3.readdir(paths.sessionsDir());
2373
+ entries = await fs4.readdir(paths.sessionsDir());
1924
2374
  } catch (err) {
1925
2375
  const e = err;
1926
2376
  if (e.code === "ENOENT") {
@@ -1942,13 +2392,16 @@ function recordFromMemorySession(args) {
1942
2392
  const now = (/* @__PURE__ */ new Date()).toISOString();
1943
2393
  return {
1944
2394
  sessionId: args.sessionId,
2395
+ lineageId: args.lineageId,
1945
2396
  upstreamSessionId: args.upstreamSessionId,
2397
+ importedFromSessionId: args.importedFromSessionId,
1946
2398
  agentId: args.agentId,
1947
2399
  cwd: args.cwd,
1948
2400
  title: args.title,
1949
2401
  agentArgs: args.agentArgs,
1950
2402
  currentModel: args.currentModel,
1951
2403
  currentMode: args.currentMode,
2404
+ currentUsage: args.currentUsage,
1952
2405
  agentCommands: args.agentCommands,
1953
2406
  createdAt: args.createdAt ?? now,
1954
2407
  updatedAt: args.updatedAt ?? now
@@ -1956,7 +2409,7 @@ function recordFromMemorySession(args) {
1956
2409
  }
1957
2410
 
1958
2411
  // src/core/history-store.ts
1959
- import * as fs4 from "fs/promises";
2412
+ import * as fs5 from "fs/promises";
1960
2413
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
1961
2414
  var MAX_ENTRIES = 1e3;
1962
2415
  var HistoryStore = class {
@@ -1969,9 +2422,9 @@ var HistoryStore = class {
1969
2422
  return;
1970
2423
  }
1971
2424
  return this.enqueue(sessionId, async () => {
1972
- await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
2425
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
1973
2426
  const line = JSON.stringify(entry) + "\n";
1974
- await fs4.appendFile(paths.historyFile(sessionId), line, {
2427
+ await fs5.appendFile(paths.historyFile(sessionId), line, {
1975
2428
  encoding: "utf8",
1976
2429
  mode: 384
1977
2430
  });
@@ -1982,9 +2435,39 @@ var HistoryStore = class {
1982
2435
  return;
1983
2436
  }
1984
2437
  return this.enqueue(sessionId, async () => {
1985
- await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
2438
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
1986
2439
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1987
- await fs4.writeFile(paths.historyFile(sessionId), body, {
2440
+ await fs5.writeFile(paths.historyFile(sessionId), body, {
2441
+ encoding: "utf8",
2442
+ mode: 384
2443
+ });
2444
+ });
2445
+ }
2446
+ // Trim the on-disk history file to the most recent maxEntries lines.
2447
+ // Runs through the same per-session write queue as append/rewrite so
2448
+ // it's safe to invoke alongside ongoing writes; a no-op if the file is
2449
+ // already at or below the cap.
2450
+ async compact(sessionId, maxEntries) {
2451
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
2452
+ return;
2453
+ }
2454
+ return this.enqueue(sessionId, async () => {
2455
+ let raw;
2456
+ try {
2457
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
2458
+ } catch (err) {
2459
+ const e = err;
2460
+ if (e.code === "ENOENT") {
2461
+ return;
2462
+ }
2463
+ throw err;
2464
+ }
2465
+ const lines = raw.split("\n").filter((l) => l.length > 0);
2466
+ if (lines.length <= maxEntries) {
2467
+ return;
2468
+ }
2469
+ const trimmed = lines.slice(-maxEntries);
2470
+ await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
1988
2471
  encoding: "utf8",
1989
2472
  mode: 384
1990
2473
  });
@@ -2000,7 +2483,7 @@ var HistoryStore = class {
2000
2483
  }
2001
2484
  let raw;
2002
2485
  try {
2003
- raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2486
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
2004
2487
  } catch (err) {
2005
2488
  const e = err;
2006
2489
  if (e.code === "ENOENT") {
@@ -2046,7 +2529,7 @@ var HistoryStore = class {
2046
2529
  }
2047
2530
  return this.enqueue(sessionId, async () => {
2048
2531
  try {
2049
- await fs4.unlink(paths.historyFile(sessionId));
2532
+ await fs5.unlink(paths.historyFile(sessionId));
2050
2533
  } catch (err) {
2051
2534
  const e = err;
2052
2535
  if (e.code !== "ENOENT") {
@@ -2054,7 +2537,7 @@ var HistoryStore = class {
2054
2537
  }
2055
2538
  }
2056
2539
  try {
2057
- await fs4.rmdir(paths.sessionDir(sessionId));
2540
+ await fs5.rmdir(paths.sessionDir(sessionId));
2058
2541
  } catch (err) {
2059
2542
  const e = err;
2060
2543
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -2077,7 +2560,18 @@ var HistoryStore = class {
2077
2560
  }
2078
2561
  };
2079
2562
 
2563
+ // src/tui/history.ts
2564
+ import { promises as fs6 } from "fs";
2565
+ import * as path4 from "path";
2566
+ async function saveHistory(file, history) {
2567
+ await fs6.mkdir(path4.dirname(file), { recursive: true });
2568
+ const lines = history.map((entry) => JSON.stringify(entry));
2569
+ await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2570
+ }
2571
+
2080
2572
  // src/core/session-manager.ts
2573
+ var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2574
+ var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
2081
2575
  var SessionManager = class {
2082
2576
  constructor(registry, spawner, store, options = {}) {
2083
2577
  this.registry = registry;
@@ -2085,6 +2579,7 @@ var SessionManager = class {
2085
2579
  this.store = store ?? new SessionStore();
2086
2580
  this.histories = new HistoryStore();
2087
2581
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
2582
+ this.defaultModels = options.defaultModels ?? {};
2088
2583
  }
2089
2584
  registry;
2090
2585
  sessions = /* @__PURE__ */ new Map();
@@ -2093,6 +2588,7 @@ var SessionManager = class {
2093
2588
  store;
2094
2589
  histories;
2095
2590
  idleTimeoutMs;
2591
+ defaultModels;
2096
2592
  // Serialize meta.json read-modify-write operations per session id so
2097
2593
  // concurrent snapshot updates (e.g. an agent emitting model + mode
2098
2594
  // back-to-back) don't lose writes via interleaved reads.
@@ -2114,7 +2610,8 @@ var SessionManager = class {
2114
2610
  agentArgs: params.agentArgs,
2115
2611
  idleTimeoutMs: this.idleTimeoutMs,
2116
2612
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2117
- historyStore: this.histories
2613
+ historyStore: this.histories,
2614
+ currentModel: fresh.initialModel
2118
2615
  });
2119
2616
  await this.attachManagerHooks(session);
2120
2617
  return session;
@@ -2156,7 +2653,10 @@ var SessionManager = class {
2156
2653
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
2157
2654
  throw err;
2158
2655
  }
2159
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
2656
+ if (params.upstreamSessionId === "") {
2657
+ return this.doResurrectFromImport(params);
2658
+ }
2659
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
2160
2660
  const agent = this.spawner({
2161
2661
  agentId: params.agentId,
2162
2662
  cwd: params.cwd,
@@ -2169,11 +2669,14 @@ var SessionManager = class {
2169
2669
  });
2170
2670
  let loadResult;
2171
2671
  try {
2172
- loadResult = await agent.connection.request("session/load", {
2173
- sessionId: params.upstreamSessionId,
2174
- cwd: params.cwd,
2175
- mcpServers: []
2176
- });
2672
+ loadResult = await agent.connection.request(
2673
+ "session/load",
2674
+ {
2675
+ sessionId: params.upstreamSessionId,
2676
+ cwd: params.cwd,
2677
+ mcpServers: []
2678
+ }
2679
+ );
2177
2680
  } catch (err) {
2178
2681
  await agent.kill().catch(() => void 0);
2179
2682
  throw new Error(
@@ -2192,17 +2695,65 @@ var SessionManager = class {
2192
2695
  idleTimeoutMs: this.idleTimeoutMs,
2193
2696
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2194
2697
  historyStore: this.histories,
2195
- seedHistory: params.seedHistory,
2196
- currentModel: params.currentModel,
2698
+ // Prefer what we previously stored from a current_model_update; if
2699
+ // we never captured one (e.g. old opencode sessions on disk before
2700
+ // this fix), fall back to the model the agent ships in its
2701
+ // session/load response body.
2702
+ currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
2197
2703
  currentMode: params.currentMode,
2704
+ currentUsage: params.currentUsage,
2198
2705
  agentCommands: params.agentCommands,
2199
- firstPromptSeeded: true
2706
+ // Only gate the first-prompt title heuristic when we actually have
2707
+ // a title to preserve. A title-less session (lost to a write race
2708
+ // or never seeded) should re-derive from the next prompt rather
2709
+ // than stay stuck.
2710
+ firstPromptSeeded: !!params.title,
2711
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
2200
2712
  });
2201
2713
  await this.attachManagerHooks(session);
2202
2714
  return session;
2203
2715
  }
2716
+ // First-attach path for a session that was created via import(). The
2717
+ // on-disk meta.json carries upstreamSessionId="" as the import
2718
+ // marker; bootstrap a fresh agent (gets a real upstream id) and kick
2719
+ // off seedFromImport so the agent absorbs the historical transcript.
2720
+ // attachManagerHooks rewrites meta.json with the new upstreamSessionId,
2721
+ // so subsequent resurrects of this session use the normal session/load
2722
+ // path.
2723
+ async doResurrectFromImport(params) {
2724
+ const fresh = await this.bootstrapAgent({
2725
+ agentId: params.agentId,
2726
+ cwd: params.cwd,
2727
+ agentArgs: params.agentArgs,
2728
+ mcpServers: []
2729
+ });
2730
+ const session = new Session({
2731
+ sessionId: params.hydraSessionId,
2732
+ cwd: params.cwd,
2733
+ agentId: params.agentId,
2734
+ agent: fresh.agent,
2735
+ upstreamSessionId: fresh.upstreamSessionId,
2736
+ agentMeta: fresh.agentMeta,
2737
+ title: params.title,
2738
+ agentArgs: params.agentArgs,
2739
+ idleTimeoutMs: this.idleTimeoutMs,
2740
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2741
+ historyStore: this.histories,
2742
+ // Prefer the stored value (set by a previous current_model_update);
2743
+ // fall back to whatever the agent ships in its session/new response.
2744
+ currentModel: params.currentModel ?? fresh.initialModel,
2745
+ currentMode: params.currentMode,
2746
+ currentUsage: params.currentUsage,
2747
+ agentCommands: params.agentCommands,
2748
+ firstPromptSeeded: !!params.title,
2749
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
2750
+ });
2751
+ await this.attachManagerHooks(session);
2752
+ void session.seedFromImport().catch(() => void 0);
2753
+ return session;
2754
+ }
2204
2755
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
2205
- // → session/new. Shared by create() and the /hydra switch path so both
2756
+ // → session/new. Shared by create() and the /hydra agent path so both
2206
2757
  // go through the same env / capabilities / error-handling.
2207
2758
  async bootstrapAgent(params) {
2208
2759
  const agentDef = await this.registry.getAgent(params.agentId);
@@ -2213,7 +2764,7 @@ var SessionManager = class {
2213
2764
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
2214
2765
  throw err;
2215
2766
  }
2216
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
2767
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
2217
2768
  const agent = this.spawner({
2218
2769
  agentId: params.agentId,
2219
2770
  cwd: params.cwd,
@@ -2225,14 +2776,36 @@ var SessionManager = class {
2225
2776
  clientCapabilities: {},
2226
2777
  clientInfo: { name: "hydra", version: "0.1.0" }
2227
2778
  });
2228
- const newResult = await agent.connection.request("session/new", {
2229
- cwd: params.cwd,
2230
- mcpServers: params.mcpServers ?? []
2231
- });
2779
+ const newResult = await agent.connection.request(
2780
+ "session/new",
2781
+ {
2782
+ cwd: params.cwd,
2783
+ mcpServers: params.mcpServers ?? []
2784
+ }
2785
+ );
2786
+ const sessionIdRaw = newResult.sessionId;
2787
+ if (typeof sessionIdRaw !== "string") {
2788
+ throw new Error(
2789
+ `agent ${params.agentId} returned a non-string sessionId from session/new`
2790
+ );
2791
+ }
2792
+ let initialModel = extractInitialModel(newResult);
2793
+ const desired = this.defaultModels[params.agentId];
2794
+ if (desired && desired !== initialModel) {
2795
+ try {
2796
+ await agent.connection.request("session/set_model", {
2797
+ sessionId: sessionIdRaw,
2798
+ modelId: desired
2799
+ });
2800
+ initialModel = desired;
2801
+ } catch {
2802
+ }
2803
+ }
2232
2804
  return {
2233
2805
  agent,
2234
- upstreamSessionId: newResult.sessionId,
2235
- agentMeta: newResult._meta
2806
+ upstreamSessionId: sessionIdRaw,
2807
+ agentMeta: newResult._meta,
2808
+ initialModel
2236
2809
  };
2237
2810
  } catch (err) {
2238
2811
  await agent.kill().catch(() => void 0);
@@ -2243,7 +2816,7 @@ var SessionManager = class {
2243
2816
  // bookkeeping. Called from both create() and resurrect() so the same
2244
2817
  // session record + lifecycle handlers are wired regardless of origin.
2245
2818
  // Returns once the initial disk record is written — callers should
2246
- // await so a subsequent /hydra switch's persistAgentChange (which
2819
+ // await so a subsequent /hydra agent's persistAgentChange (which
2247
2820
  // does read-then-write) finds the file in place.
2248
2821
  async attachManagerHooks(session) {
2249
2822
  session.onClose(({ deleteRecord }) => {
@@ -2271,6 +2844,11 @@ var SessionManager = class {
2271
2844
  () => void 0
2272
2845
  );
2273
2846
  });
2847
+ session.onUsageChange((usage) => {
2848
+ void this.persistSnapshot(session.sessionId, {
2849
+ currentUsage: usageSnapshotToPersisted(usage)
2850
+ }).catch(() => void 0);
2851
+ });
2274
2852
  session.onAgentCommandsChange((commands) => {
2275
2853
  void this.persistSnapshot(session.sessionId, {
2276
2854
  agentCommands: commands.map((c) => ({
@@ -2280,28 +2858,20 @@ var SessionManager = class {
2280
2858
  }).catch(() => void 0);
2281
2859
  });
2282
2860
  this.sessions.set(session.sessionId, session);
2283
- await this.store.write(
2284
- recordFromMemorySession({
2285
- sessionId: session.sessionId,
2286
- upstreamSessionId: session.upstreamSessionId,
2287
- agentId: session.agentId,
2288
- cwd: session.cwd,
2289
- title: session.title,
2290
- agentArgs: session.agentArgs,
2291
- currentModel: session.currentModel,
2292
- currentMode: session.currentMode
2293
- })
2294
- ).catch(() => void 0);
2861
+ await this.enqueueMetaWrite(session.sessionId, async () => {
2862
+ const existing = await this.store.read(session.sessionId);
2863
+ const merged = mergeForPersistence(session, existing);
2864
+ await this.store.write(merged);
2865
+ }).catch(() => void 0);
2295
2866
  }
2296
2867
  // Resolve a session's recorded history without forcing a resurrect.
2297
- // Returns the in-memory snapshot if the session is hot, falls back
2298
- // to the on-disk history file otherwise. Returns undefined if the
2299
- // session id is unknown to both the live map and disk store, so the
2300
- // caller can distinguish "no history yet" (empty array) from "404".
2868
+ // Always loads from disk that's the source of truth whether the
2869
+ // session is hot or cold. Returns undefined if the session id is
2870
+ // unknown to both the live map and disk store, so the caller can
2871
+ // distinguish "no history yet" (empty array) from "404".
2301
2872
  async getHistory(sessionId) {
2302
- const live = this.sessions.get(sessionId);
2303
- if (live) {
2304
- return live.getHistorySnapshot();
2873
+ if (this.sessions.has(sessionId)) {
2874
+ return this.histories.load(sessionId).catch(() => []);
2305
2875
  }
2306
2876
  const record = await this.store.read(sessionId);
2307
2877
  if (!record) {
@@ -2314,20 +2884,42 @@ var SessionManager = class {
2314
2884
  if (!record) {
2315
2885
  return void 0;
2316
2886
  }
2317
- const seedHistory = await this.histories.load(sessionId).catch(() => []);
2887
+ let title = record.title;
2888
+ if (!title) {
2889
+ title = await this.deriveTitleFromHistory(sessionId);
2890
+ }
2318
2891
  return {
2319
2892
  hydraSessionId: record.sessionId,
2320
2893
  upstreamSessionId: record.upstreamSessionId,
2321
2894
  agentId: record.agentId,
2322
2895
  cwd: record.cwd,
2323
- title: record.title,
2896
+ title,
2324
2897
  agentArgs: record.agentArgs,
2325
- seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
2326
2898
  currentModel: record.currentModel,
2327
2899
  currentMode: record.currentMode,
2328
- agentCommands: record.agentCommands
2900
+ currentUsage: persistedUsageToSnapshot(record.currentUsage),
2901
+ agentCommands: record.agentCommands,
2902
+ createdAt: record.createdAt
2329
2903
  };
2330
2904
  }
2905
+ // Best-effort: peek at the persisted history's first prompt and use
2906
+ // its first line (capped to 200 chars) as a session title. Returns
2907
+ // undefined if no usable prompt is found or any I/O fails.
2908
+ async deriveTitleFromHistory(sessionId) {
2909
+ const history = await this.histories.load(sessionId).catch(() => []);
2910
+ for (const entry of history) {
2911
+ const params = entry.params;
2912
+ if (params?.update?.sessionUpdate !== "prompt_received") {
2913
+ continue;
2914
+ }
2915
+ const text = extractPromptText(params.update.prompt);
2916
+ const line = firstLine(text, 200);
2917
+ if (line) {
2918
+ return line;
2919
+ }
2920
+ }
2921
+ return void 0;
2922
+ }
2331
2923
  get(sessionId) {
2332
2924
  return this.sessions.get(sessionId);
2333
2925
  }
@@ -2374,6 +2966,8 @@ var SessionManager = class {
2374
2966
  cwd: session.cwd,
2375
2967
  title: session.title,
2376
2968
  agentId: session.agentId,
2969
+ currentModel: session.currentModel,
2970
+ currentUsage: session.currentUsage,
2377
2971
  updatedAt: used,
2378
2972
  attachedClients: session.attachedCount,
2379
2973
  status: "live"
@@ -2394,6 +2988,8 @@ var SessionManager = class {
2394
2988
  cwd: r.cwd,
2395
2989
  title: r.title,
2396
2990
  agentId: r.agentId,
2991
+ currentModel: r.currentModel,
2992
+ currentUsage: r.currentUsage,
2397
2993
  updatedAt: used,
2398
2994
  attachedClients: 0,
2399
2995
  status: "cold"
@@ -2402,6 +2998,112 @@ var SessionManager = class {
2402
2998
  entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
2403
2999
  return entries;
2404
3000
  }
3001
+ // Build an export bundle for a session, reading meta + history from
3002
+ // disk. Backfills lineageId if the on-disk record pre-dates that
3003
+ // field. Returns undefined if the session doesn't exist. Callers
3004
+ // populate the bundle's exportedFrom metadata themselves.
3005
+ async exportBundle(sessionId) {
3006
+ const record = await this.store.read(sessionId);
3007
+ if (!record) {
3008
+ return void 0;
3009
+ }
3010
+ let withLineage;
3011
+ if (record.lineageId) {
3012
+ withLineage = record;
3013
+ } else {
3014
+ const lineageId = generateLineageId();
3015
+ const backfilled = { ...record, lineageId };
3016
+ await this.enqueueMetaWrite(sessionId, async () => {
3017
+ const latest = await this.store.read(sessionId);
3018
+ if (!latest) {
3019
+ return;
3020
+ }
3021
+ if (latest.lineageId) {
3022
+ return;
3023
+ }
3024
+ await this.store.write({ ...latest, lineageId });
3025
+ }).catch(() => void 0);
3026
+ withLineage = backfilled;
3027
+ }
3028
+ const history = await this.histories.load(sessionId).catch(() => []);
3029
+ const promptHistory = await loadPromptHistorySafely(sessionId);
3030
+ return { record: withLineage, history, promptHistory };
3031
+ }
3032
+ // Create a local session from an imported bundle. Without `replace`,
3033
+ // a bundle with a lineageId we already have on disk throws
3034
+ // BundleAlreadyImported citing the existing local id. With
3035
+ // `replace: true`, the existing record is overwritten in-place (its
3036
+ // local sessionId is preserved so bookmarks/Slack thread links still
3037
+ // resolve), and any live in-memory session is closed so the next
3038
+ // attach triggers the import-reseed path.
3039
+ async importBundle(bundle, opts = {}) {
3040
+ const existing = await this.store.findByLineageId(bundle.session.lineageId);
3041
+ if (existing) {
3042
+ if (!opts.replace) {
3043
+ const err = new Error(
3044
+ `bundle already imported as ${existing.sessionId}`
3045
+ );
3046
+ err.code = JsonRpcErrorCodes.BundleAlreadyImported;
3047
+ err.existingSessionId = existing.sessionId;
3048
+ throw err;
3049
+ }
3050
+ const live = this.sessions.get(existing.sessionId);
3051
+ if (live) {
3052
+ await live.close({ deleteRecord: false }).catch(() => void 0);
3053
+ }
3054
+ await this.writeImportedRecord({
3055
+ sessionId: existing.sessionId,
3056
+ bundle,
3057
+ preservedCreatedAt: existing.createdAt
3058
+ });
3059
+ return {
3060
+ sessionId: existing.sessionId,
3061
+ importedFromSessionId: bundle.session.sessionId,
3062
+ replaced: true
3063
+ };
3064
+ }
3065
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
3066
+ await this.writeImportedRecord({ sessionId: newId, bundle });
3067
+ return {
3068
+ sessionId: newId,
3069
+ importedFromSessionId: bundle.session.sessionId,
3070
+ replaced: false
3071
+ };
3072
+ }
3073
+ // Write the imported bundle's history.jsonl, prompt-history (if
3074
+ // present), and meta.json. upstreamSessionId is left empty as the
3075
+ // marker that the first attach should bootstrap a fresh agent and
3076
+ // run seedFromImport rather than calling session/load.
3077
+ async writeImportedRecord(args) {
3078
+ await this.histories.rewrite(
3079
+ args.sessionId,
3080
+ args.bundle.history
3081
+ );
3082
+ if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
3083
+ await saveHistory(
3084
+ paths.tuiHistoryFile(args.sessionId),
3085
+ args.bundle.promptHistory
3086
+ ).catch(() => void 0);
3087
+ }
3088
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3089
+ await this.enqueueMetaWrite(args.sessionId, async () => {
3090
+ await this.store.write({
3091
+ sessionId: args.sessionId,
3092
+ lineageId: args.bundle.session.lineageId,
3093
+ upstreamSessionId: "",
3094
+ importedFromSessionId: args.bundle.session.sessionId,
3095
+ agentId: args.bundle.session.agentId,
3096
+ cwd: args.bundle.session.cwd,
3097
+ title: args.bundle.session.title,
3098
+ currentModel: args.bundle.session.currentModel,
3099
+ currentMode: args.bundle.session.currentMode,
3100
+ currentUsage: args.bundle.session.currentUsage,
3101
+ agentCommands: args.bundle.session.agentCommands,
3102
+ createdAt: args.preservedCreatedAt ?? now,
3103
+ updatedAt: now
3104
+ });
3105
+ });
3106
+ }
2405
3107
  async deleteRecord(sessionId) {
2406
3108
  const record = await this.store.read(sessionId);
2407
3109
  if (!record) {
@@ -2431,7 +3133,7 @@ var SessionManager = class {
2431
3133
  });
2432
3134
  });
2433
3135
  }
2434
- // Persist an agent swap from /hydra switch. The on-disk record's
3136
+ // Persist an agent swap from /hydra agent. The on-disk record's
2435
3137
  // agentId + upstreamSessionId both rotate so a daemon restart (and
2436
3138
  // later resurrect) brings the session back up on the agent the user
2437
3139
  // most recently switched to, not the one it was originally created on.
@@ -2463,6 +3165,7 @@ var SessionManager = class {
2463
3165
  ...record,
2464
3166
  ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
2465
3167
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
3168
+ ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
2466
3169
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
2467
3170
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2468
3171
  });
@@ -2487,10 +3190,124 @@ var SessionManager = class {
2487
3190
  await Promise.allSettled(sessions.map((s) => s.close()));
2488
3191
  this.sessions.clear();
2489
3192
  }
3193
+ // Wait for every pending meta.json write to settle. Daemon shutdown
3194
+ // hooks call this so a SIGTERM doesn't kill the process mid-write
3195
+ // and lose a freshly-set title (or model/mode/commands).
3196
+ async flushMetaWrites() {
3197
+ const pending = [...this.metaWriteQueues.values()];
3198
+ if (pending.length === 0) {
3199
+ return;
3200
+ }
3201
+ await Promise.allSettled(pending);
3202
+ }
2490
3203
  };
3204
+ function mergeForPersistence(session, existing) {
3205
+ const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
3206
+ if (c.description !== void 0) {
3207
+ return { name: c.name, description: c.description };
3208
+ }
3209
+ return { name: c.name };
3210
+ }) : void 0;
3211
+ const agentCommands = persistedCommands ?? existing?.agentCommands;
3212
+ return recordFromMemorySession({
3213
+ sessionId: session.sessionId,
3214
+ lineageId: existing?.lineageId ?? generateLineageId(),
3215
+ upstreamSessionId: session.upstreamSessionId,
3216
+ importedFromSessionId: existing?.importedFromSessionId,
3217
+ agentId: session.agentId,
3218
+ cwd: session.cwd,
3219
+ title: session.title,
3220
+ agentArgs: session.agentArgs,
3221
+ currentModel: session.currentModel ?? existing?.currentModel,
3222
+ currentMode: session.currentMode ?? existing?.currentMode,
3223
+ currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
3224
+ agentCommands,
3225
+ createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
3226
+ });
3227
+ }
3228
+ function usageSnapshotToPersisted(usage) {
3229
+ if (!usage) {
3230
+ return void 0;
3231
+ }
3232
+ const out = {};
3233
+ if (usage.used !== void 0) {
3234
+ out.used = usage.used;
3235
+ }
3236
+ if (usage.size !== void 0) {
3237
+ out.size = usage.size;
3238
+ }
3239
+ if (usage.costAmount !== void 0) {
3240
+ out.costAmount = usage.costAmount;
3241
+ }
3242
+ if (usage.costCurrency !== void 0) {
3243
+ out.costCurrency = usage.costCurrency;
3244
+ }
3245
+ return Object.keys(out).length > 0 ? out : void 0;
3246
+ }
3247
+ function persistedUsageToSnapshot(usage) {
3248
+ return usage ? { ...usage } : void 0;
3249
+ }
3250
+ function extractInitialModel(result) {
3251
+ const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
3252
+ if (direct) {
3253
+ return direct;
3254
+ }
3255
+ const models = result.models;
3256
+ if (models && typeof models === "object" && !Array.isArray(models)) {
3257
+ const m = asString(models.currentModelId) ?? asString(models.currentModel);
3258
+ if (m) {
3259
+ return m;
3260
+ }
3261
+ }
3262
+ const meta = result._meta;
3263
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
3264
+ for (const [key, value] of Object.entries(
3265
+ meta
3266
+ )) {
3267
+ if (key === "hydra-acp") {
3268
+ continue;
3269
+ }
3270
+ if (value && typeof value === "object" && !Array.isArray(value)) {
3271
+ const m = asString(value.modelId) ?? asString(value.model) ?? asString(value.currentModelId);
3272
+ if (m) {
3273
+ return m;
3274
+ }
3275
+ }
3276
+ }
3277
+ }
3278
+ return void 0;
3279
+ }
3280
+ function asString(value) {
3281
+ if (typeof value !== "string") {
3282
+ return void 0;
3283
+ }
3284
+ const trimmed = value.trim();
3285
+ return trimmed.length > 0 ? trimmed : void 0;
3286
+ }
3287
+ async function loadPromptHistorySafely(sessionId) {
3288
+ try {
3289
+ const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
3290
+ const out = [];
3291
+ for (const line of raw.split("\n")) {
3292
+ if (line.length === 0) {
3293
+ continue;
3294
+ }
3295
+ try {
3296
+ const decoded = JSON.parse(line);
3297
+ if (typeof decoded === "string") {
3298
+ out.push(decoded);
3299
+ }
3300
+ } catch {
3301
+ }
3302
+ }
3303
+ return out;
3304
+ } catch {
3305
+ return [];
3306
+ }
3307
+ }
2491
3308
  async function historyMtimeIso(sessionId) {
2492
3309
  try {
2493
- const st = await fs5.stat(paths.historyFile(sessionId));
3310
+ const st = await fs7.stat(paths.historyFile(sessionId));
2494
3311
  return new Date(st.mtimeMs).toISOString();
2495
3312
  } catch {
2496
3313
  return void 0;
@@ -2498,10 +3315,10 @@ async function historyMtimeIso(sessionId) {
2498
3315
  }
2499
3316
 
2500
3317
  // src/core/extensions.ts
2501
- import { spawn as spawn2 } from "child_process";
2502
- import * as fs6 from "fs";
2503
- import * as fsp from "fs/promises";
2504
- import * as path3 from "path";
3318
+ import { spawn as spawn3 } from "child_process";
3319
+ import * as fs8 from "fs";
3320
+ import * as fsp2 from "fs/promises";
3321
+ import * as path5 from "path";
2505
3322
  var RESTART_BASE_MS = 1e3;
2506
3323
  var RESTART_CAP_MS = 6e4;
2507
3324
  var STOP_GRACE_MS = 3e3;
@@ -2522,7 +3339,7 @@ var ExtensionManager = class {
2522
3339
  if (!this.context) {
2523
3340
  throw new Error("ExtensionManager: setContext must be called before start");
2524
3341
  }
2525
- await fsp.mkdir(paths.extensionsDir(), { recursive: true });
3342
+ await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
2526
3343
  await this.reapOrphans();
2527
3344
  for (const entry of this.entries.values()) {
2528
3345
  if (!entry.config.enabled) {
@@ -2548,9 +3365,9 @@ var ExtensionManager = class {
2548
3365
  } catch {
2549
3366
  }
2550
3367
  tasks.push(
2551
- new Promise((resolve2) => {
3368
+ new Promise((resolve3) => {
2552
3369
  if (child.exitCode !== null || child.signalCode !== null) {
2553
- resolve2();
3370
+ resolve3();
2554
3371
  return;
2555
3372
  }
2556
3373
  const timer = setTimeout(() => {
@@ -2558,11 +3375,11 @@ var ExtensionManager = class {
2558
3375
  child.kill("SIGKILL");
2559
3376
  } catch {
2560
3377
  }
2561
- resolve2();
3378
+ resolve3();
2562
3379
  }, STOP_GRACE_MS);
2563
3380
  child.on("exit", () => {
2564
3381
  clearTimeout(timer);
2565
- resolve2();
3382
+ resolve3();
2566
3383
  });
2567
3384
  })
2568
3385
  );
@@ -2670,8 +3487,8 @@ var ExtensionManager = class {
2670
3487
  if (child.exitCode !== null || child.signalCode !== null) {
2671
3488
  return;
2672
3489
  }
2673
- const exited = new Promise((resolve2) => {
2674
- entry.exitWaiters.push(resolve2);
3490
+ const exited = new Promise((resolve3) => {
3491
+ entry.exitWaiters.push(resolve3);
2675
3492
  });
2676
3493
  try {
2677
3494
  child.kill("SIGTERM");
@@ -2731,7 +3548,7 @@ var ExtensionManager = class {
2731
3548
  async reapOrphans() {
2732
3549
  let entries;
2733
3550
  try {
2734
- entries = await fsp.readdir(paths.extensionsDir());
3551
+ entries = await fsp2.readdir(paths.extensionsDir());
2735
3552
  } catch (err) {
2736
3553
  const e = err;
2737
3554
  if (e.code === "ENOENT") {
@@ -2743,10 +3560,10 @@ var ExtensionManager = class {
2743
3560
  if (!entry.endsWith(".pid")) {
2744
3561
  continue;
2745
3562
  }
2746
- const pidPath = path3.join(paths.extensionsDir(), entry);
3563
+ const pidPath = path5.join(paths.extensionsDir(), entry);
2747
3564
  let pid;
2748
3565
  try {
2749
- const raw = await fsp.readFile(pidPath, "utf8");
3566
+ const raw = await fsp2.readFile(pidPath, "utf8");
2750
3567
  const parsed = Number.parseInt(raw.trim(), 10);
2751
3568
  if (Number.isInteger(parsed) && parsed > 0) {
2752
3569
  pid = parsed;
@@ -2769,7 +3586,7 @@ var ExtensionManager = class {
2769
3586
  }
2770
3587
  }
2771
3588
  }
2772
- await fsp.unlink(pidPath).catch(() => void 0);
3589
+ await fsp2.unlink(pidPath).catch(() => void 0);
2773
3590
  }
2774
3591
  }
2775
3592
  spawn(entry, attempt) {
@@ -2782,7 +3599,7 @@ var ExtensionManager = class {
2782
3599
  }
2783
3600
  const ext = entry.config;
2784
3601
  const command = ext.command.length > 0 ? ext.command : [ext.name];
2785
- const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
3602
+ const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
2786
3603
  flags: "a"
2787
3604
  });
2788
3605
  logStream.write(
@@ -2810,7 +3627,7 @@ var ExtensionManager = class {
2810
3627
  const args = [...baseArgs, ...ext.args];
2811
3628
  let child;
2812
3629
  try {
2813
- child = spawn2(cmd, args, {
3630
+ child = spawn3(cmd, args, {
2814
3631
  env,
2815
3632
  stdio: ["ignore", "pipe", "pipe"],
2816
3633
  detached: false
@@ -2832,7 +3649,7 @@ var ExtensionManager = class {
2832
3649
  }
2833
3650
  if (typeof child.pid === "number") {
2834
3651
  try {
2835
- fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3652
+ fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2836
3653
  `, {
2837
3654
  encoding: "utf8",
2838
3655
  mode: 384
@@ -2857,7 +3674,7 @@ var ExtensionManager = class {
2857
3674
  });
2858
3675
  child.on("exit", (code, signal) => {
2859
3676
  try {
2860
- fs6.unlinkSync(paths.extensionPidFile(ext.name));
3677
+ fs8.unlinkSync(paths.extensionPidFile(ext.name));
2861
3678
  } catch {
2862
3679
  }
2863
3680
  logStream.write(
@@ -2868,8 +3685,8 @@ var ExtensionManager = class {
2868
3685
  entry.pid = void 0;
2869
3686
  entry.lastExitCode = typeof code === "number" ? code : void 0;
2870
3687
  const waiters = entry.exitWaiters.splice(0);
2871
- for (const resolve2 of waiters) {
2872
- resolve2();
3688
+ for (const resolve3 of waiters) {
3689
+ resolve3();
2873
3690
  }
2874
3691
  if (this.stopping || entry.manuallyStopped) {
2875
3692
  try {
@@ -2966,6 +3783,77 @@ function constantTimeEqual(a, b) {
2966
3783
  }
2967
3784
 
2968
3785
  // src/daemon/routes/sessions.ts
3786
+ import * as os2 from "os";
3787
+
3788
+ // src/core/bundle.ts
3789
+ import { z as z5 } from "zod";
3790
+ var HistoryEntrySchema = z5.object({
3791
+ method: z5.string(),
3792
+ params: z5.unknown(),
3793
+ recordedAt: z5.number()
3794
+ });
3795
+ var BundleSession = z5.object({
3796
+ // The exporter's local id. Regenerated fresh on import (sessionId is
3797
+ // the local namespace; lineageId is what survives across hops).
3798
+ sessionId: z5.string(),
3799
+ // Required on bundles — the export path backfills if the source
3800
+ // record was written before lineageId existed.
3801
+ lineageId: z5.string(),
3802
+ agentId: z5.string(),
3803
+ cwd: z5.string(),
3804
+ title: z5.string().optional(),
3805
+ currentModel: z5.string().optional(),
3806
+ currentMode: z5.string().optional(),
3807
+ currentUsage: PersistedUsage.optional(),
3808
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
3809
+ createdAt: z5.string(),
3810
+ updatedAt: z5.string()
3811
+ });
3812
+ var Bundle = z5.object({
3813
+ version: z5.literal(1),
3814
+ exportedAt: z5.string(),
3815
+ exportedFrom: z5.object({
3816
+ hydraVersion: z5.string(),
3817
+ machine: z5.string()
3818
+ }),
3819
+ session: BundleSession,
3820
+ history: z5.array(HistoryEntrySchema),
3821
+ promptHistory: z5.array(z5.string()).optional()
3822
+ });
3823
+ function encodeBundle(params) {
3824
+ const bundle = {
3825
+ version: 1,
3826
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
3827
+ exportedFrom: {
3828
+ hydraVersion: params.hydraVersion,
3829
+ machine: params.machine
3830
+ },
3831
+ session: {
3832
+ sessionId: params.record.sessionId,
3833
+ lineageId: params.record.lineageId,
3834
+ agentId: params.record.agentId,
3835
+ cwd: params.record.cwd,
3836
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
3837
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
3838
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
3839
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
3840
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
3841
+ createdAt: params.record.createdAt,
3842
+ updatedAt: params.record.updatedAt
3843
+ },
3844
+ history: params.history
3845
+ };
3846
+ if (params.promptHistory !== void 0) {
3847
+ bundle.promptHistory = params.promptHistory;
3848
+ }
3849
+ return bundle;
3850
+ }
3851
+ function decodeBundle(raw) {
3852
+ return Bundle.parse(raw);
3853
+ }
3854
+
3855
+ // src/daemon/routes/sessions.ts
3856
+ var HYDRA_VERSION = "0.1.0";
2969
3857
  function registerSessionRoutes(app, manager, defaults) {
2970
3858
  app.get("/v1/sessions", async (request) => {
2971
3859
  const query = request.query;
@@ -3023,6 +3911,61 @@ function registerSessionRoutes(app, manager, defaults) {
3023
3911
  }
3024
3912
  reply.code(204).send();
3025
3913
  });
3914
+ app.get("/v1/sessions/:id/export", async (request, reply) => {
3915
+ const raw = request.params.id;
3916
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
3917
+ const exported = await manager.exportBundle(id);
3918
+ if (!exported) {
3919
+ reply.code(404).send({ error: "session not found" });
3920
+ return;
3921
+ }
3922
+ const bundle = encodeBundle({
3923
+ record: exported.record,
3924
+ history: exported.history,
3925
+ promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
3926
+ hydraVersion: HYDRA_VERSION,
3927
+ machine: os2.hostname()
3928
+ });
3929
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3930
+ reply.header(
3931
+ "Content-Disposition",
3932
+ `attachment; filename="hydra-${id}-${stamp}.hydra"`
3933
+ );
3934
+ reply.code(200).send(bundle);
3935
+ });
3936
+ app.post("/v1/sessions/import", async (request, reply) => {
3937
+ const body = request.body ?? {};
3938
+ if (body.bundle === void 0) {
3939
+ reply.code(400).send({ error: "missing bundle" });
3940
+ return;
3941
+ }
3942
+ let bundle;
3943
+ try {
3944
+ bundle = decodeBundle(body.bundle);
3945
+ } catch (err) {
3946
+ reply.code(400).send({
3947
+ error: "invalid bundle",
3948
+ details: err.message
3949
+ });
3950
+ return;
3951
+ }
3952
+ try {
3953
+ const result = await manager.importBundle(bundle, {
3954
+ replace: body.replace === true
3955
+ });
3956
+ reply.code(201).send(result);
3957
+ } catch (err) {
3958
+ const e = err;
3959
+ if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
3960
+ reply.code(409).send({
3961
+ error: "bundle already imported",
3962
+ existingSessionId: e.existingSessionId
3963
+ });
3964
+ return;
3965
+ }
3966
+ reply.code(500).send({ error: e.message });
3967
+ }
3968
+ });
3026
3969
  app.get("/v1/sessions/:id/history", async (request, reply) => {
3027
3970
  const raw = request.params.id;
3028
3971
  const query = request.query;
@@ -3031,16 +3974,22 @@ function registerSessionRoutes(app, manager, defaults) {
3031
3974
  const live = manager.get(id);
3032
3975
  let snapshot;
3033
3976
  let unsubscribe;
3977
+ let snapshotDone = false;
3978
+ const pending = [];
3034
3979
  if (live) {
3035
- snapshot = live.getHistorySnapshot();
3036
3980
  if (follow) {
3037
3981
  unsubscribe = live.onBroadcast((entry) => {
3038
3982
  if (reply.raw.writableEnded) {
3039
3983
  return;
3040
3984
  }
3041
- reply.raw.write(JSON.stringify(entry) + "\n");
3985
+ if (snapshotDone) {
3986
+ reply.raw.write(JSON.stringify(entry) + "\n");
3987
+ } else {
3988
+ pending.push(entry);
3989
+ }
3042
3990
  });
3043
3991
  }
3992
+ snapshot = await live.getHistorySnapshot();
3044
3993
  } else {
3045
3994
  const cold = await manager.getHistory(id);
3046
3995
  if (cold === void 0) {
@@ -3052,9 +4001,23 @@ function registerSessionRoutes(app, manager, defaults) {
3052
4001
  reply.raw.setHeader("Content-Type", "application/x-ndjson");
3053
4002
  reply.raw.setHeader("Cache-Control", "no-cache");
3054
4003
  reply.raw.statusCode = 200;
4004
+ const snapshotKeys = /* @__PURE__ */ new Set();
3055
4005
  for (const entry of snapshot ?? []) {
3056
4006
  reply.raw.write(JSON.stringify(entry) + "\n");
4007
+ const e = entry;
4008
+ if (typeof e.recordedAt === "number") {
4009
+ snapshotKeys.add(String(e.recordedAt));
4010
+ }
4011
+ }
4012
+ for (const entry of pending) {
4013
+ const e = entry;
4014
+ const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
4015
+ if (key && snapshotKeys.has(key)) {
4016
+ continue;
4017
+ }
4018
+ reply.raw.write(JSON.stringify(entry) + "\n");
3057
4019
  }
4020
+ snapshotDone = true;
3058
4021
  if (!unsubscribe) {
3059
4022
  reply.raw.end();
3060
4023
  return reply;
@@ -3272,13 +4235,13 @@ function wsToMessageStream(ws) {
3272
4235
  throw new Error("ws is closed");
3273
4236
  }
3274
4237
  const text = JSON.stringify(message);
3275
- await new Promise((resolve2, reject) => {
4238
+ await new Promise((resolve3, reject) => {
3276
4239
  ws.send(text, (err) => {
3277
4240
  if (err) {
3278
4241
  reject(err);
3279
4242
  return;
3280
4243
  }
3281
- resolve2();
4244
+ resolve3();
3282
4245
  });
3283
4246
  });
3284
4247
  },
@@ -3299,7 +4262,7 @@ function wsToMessageStream(ws) {
3299
4262
  }
3300
4263
 
3301
4264
  // src/daemon/acp-ws.ts
3302
- var HYDRA_VERSION = "0.1.0";
4265
+ var HYDRA_VERSION2 = "0.1.0";
3303
4266
  var HYDRA_PROTOCOL_VERSION = 1;
3304
4267
  function registerAcpWsEndpoint(app, deps) {
3305
4268
  app.get("/acp", { websocket: true }, (socket, request) => {
@@ -3345,7 +4308,7 @@ function registerAcpWsEndpoint(app, deps) {
3345
4308
  agentArgs: hydraMeta.agentArgs
3346
4309
  });
3347
4310
  const client = bindClientToSession(connection, session, state);
3348
- session.attach(client, "full");
4311
+ await session.attach(client, "full");
3349
4312
  state.attached.set(session.sessionId, {
3350
4313
  sessionId: session.sessionId,
3351
4314
  clientId: client.clientId
@@ -3364,14 +4327,22 @@ function registerAcpWsEndpoint(app, deps) {
3364
4327
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
3365
4328
  let session = deps.manager.get(lookupId);
3366
4329
  if (!session) {
3367
- let resurrectParams = hydraHints ? {
3368
- hydraSessionId: params.sessionId,
3369
- upstreamSessionId: hydraHints.upstreamSessionId,
3370
- agentId: hydraHints.agentId,
3371
- cwd: hydraHints.cwd,
3372
- title: hydraHints.title,
3373
- agentArgs: hydraHints.agentArgs
3374
- } : await deps.manager.loadFromDisk(lookupId);
4330
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
4331
+ let resurrectParams = fromDisk;
4332
+ if (hydraHints) {
4333
+ resurrectParams = {
4334
+ hydraSessionId: params.sessionId,
4335
+ upstreamSessionId: hydraHints.upstreamSessionId,
4336
+ agentId: hydraHints.agentId,
4337
+ cwd: hydraHints.cwd,
4338
+ title: hydraHints.title ?? fromDisk?.title,
4339
+ agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
4340
+ currentModel: fromDisk?.currentModel,
4341
+ currentMode: fromDisk?.currentMode,
4342
+ agentCommands: fromDisk?.agentCommands,
4343
+ createdAt: fromDisk?.createdAt
4344
+ };
4345
+ }
3375
4346
  if (!resurrectParams) {
3376
4347
  const err = new Error(
3377
4348
  `session ${params.sessionId} not found and no resume hints provided`
@@ -3387,13 +4358,13 @@ function registerAcpWsEndpoint(app, deps) {
3387
4358
  state,
3388
4359
  params.clientInfo
3389
4360
  );
3390
- const replay = session.attach(client, params.historyPolicy);
4361
+ const replay = await session.attach(client, params.historyPolicy);
3391
4362
  state.attached.set(session.sessionId, {
3392
4363
  sessionId: session.sessionId,
3393
4364
  clientId: client.clientId
3394
4365
  });
3395
4366
  app.log.info(
3396
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
4367
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
3397
4368
  );
3398
4369
  for (const note of replay) {
3399
4370
  await connection.notify(note.method, note.params);
@@ -3489,7 +4460,7 @@ function registerAcpWsEndpoint(app, deps) {
3489
4460
  session = await deps.manager.resurrect(fromDisk);
3490
4461
  }
3491
4462
  const client = bindClientToSession(connection, session, state);
3492
- const replay = session.attach(client, "pending_only");
4463
+ const replay = await session.attach(client, "pending_only");
3493
4464
  state.attached.set(session.sessionId, {
3494
4465
  sessionId: session.sessionId,
3495
4466
  clientId: client.clientId
@@ -3555,7 +4526,7 @@ function buildResponseMeta(session) {
3555
4526
  function buildInitializeResult() {
3556
4527
  return {
3557
4528
  protocolVersion: HYDRA_PROTOCOL_VERSION,
3558
- agentInfo: { name: "hydra", version: HYDRA_VERSION },
4529
+ agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
3559
4530
  agentCapabilities: {
3560
4531
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
3561
4532
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -3594,14 +4565,14 @@ function bindClientToSession(connection, session, state, clientInfo) {
3594
4565
  }
3595
4566
 
3596
4567
  // src/daemon/server.ts
3597
- var HYDRA_VERSION2 = "0.1.0";
4568
+ var HYDRA_VERSION3 = "0.1.0";
3598
4569
  async function startDaemon(config) {
3599
4570
  ensureLoopbackOrTls(config);
3600
4571
  const httpsOptions = config.daemon.tls ? {
3601
- key: await fsp2.readFile(config.daemon.tls.key),
3602
- cert: await fsp2.readFile(config.daemon.tls.cert)
4572
+ key: await fsp3.readFile(config.daemon.tls.key),
4573
+ cert: await fsp3.readFile(config.daemon.tls.cert)
3603
4574
  } : void 0;
3604
- await fsp2.mkdir(paths.home(), { recursive: true });
4575
+ await fsp3.mkdir(paths.home(), { recursive: true });
3605
4576
  const { stream: logStream, fileStream } = await buildLogStream(
3606
4577
  config.daemon.logLevel
3607
4578
  );
@@ -3613,6 +4584,9 @@ async function startDaemon(config) {
3613
4584
  https: httpsOptions ?? null
3614
4585
  });
3615
4586
  await app.register(websocketPlugin);
4587
+ setBinaryInstallLogger((msg) => {
4588
+ app.log.info(msg);
4589
+ });
3616
4590
  const auth = bearerAuth({ config });
3617
4591
  app.addHook("onRequest", async (request, reply) => {
3618
4592
  if (request.routeOptions.config?.skipAuth) {
@@ -3625,10 +4599,11 @@ async function startDaemon(config) {
3625
4599
  });
3626
4600
  const registry = new Registry(config);
3627
4601
  const manager = new SessionManager(registry, void 0, void 0, {
3628
- idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
4602
+ idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
4603
+ defaultModels: config.defaultModels
3629
4604
  });
3630
4605
  const extensions = new ExtensionManager(extensionList(config));
3631
- registerHealthRoutes(app, HYDRA_VERSION2);
4606
+ registerHealthRoutes(app, HYDRA_VERSION3);
3632
4607
  registerSessionRoutes(app, manager, {
3633
4608
  agentId: config.defaultAgent,
3634
4609
  cwd: config.defaultCwd
@@ -3647,8 +4622,8 @@ async function startDaemon(config) {
3647
4622
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
3648
4623
  const address = app.server.address();
3649
4624
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
3650
- await fsp2.mkdir(paths.home(), { recursive: true });
3651
- await fsp2.writeFile(
4625
+ await fsp3.mkdir(paths.home(), { recursive: true });
4626
+ await fsp3.writeFile(
3652
4627
  paths.pidFile(),
3653
4628
  JSON.stringify({
3654
4629
  pid: process.pid,
@@ -3672,9 +4647,11 @@ async function startDaemon(config) {
3672
4647
  const shutdown = async () => {
3673
4648
  await extensions.stop();
3674
4649
  await manager.closeAll();
4650
+ await manager.flushMetaWrites();
4651
+ setBinaryInstallLogger(null);
3675
4652
  await app.close();
3676
4653
  try {
3677
- fs7.unlinkSync(paths.pidFile());
4654
+ fs9.unlinkSync(paths.pidFile());
3678
4655
  } catch {
3679
4656
  }
3680
4657
  try {