@hydra-acp/cli 0.1.4 → 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 fs8 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
@@ -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.
@@ -193,8 +205,213 @@ function expandHome(p) {
193
205
  }
194
206
 
195
207
  // src/core/registry.ts
196
- import * as fs2 from "fs/promises";
208
+ import * as fs3 from "fs/promises";
197
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
198
415
  var NpxDistribution = z2.object({
199
416
  package: z2.string(),
200
417
  args: z2.array(z2.string()).optional(),
@@ -202,7 +419,9 @@ var NpxDistribution = z2.object({
202
419
  });
203
420
  var BinaryTarget = z2.object({
204
421
  archive: z2.string().url().optional(),
205
- cmd: z2.string().optional()
422
+ cmd: z2.string().optional(),
423
+ args: z2.array(z2.string()).optional(),
424
+ env: z2.record(z2.string()).optional()
206
425
  });
207
426
  var BinaryDistribution = z2.object({
208
427
  "darwin-aarch64": BinaryTarget.optional(),
@@ -291,34 +510,59 @@ var Registry = class {
291
510
  if (!response.ok) {
292
511
  throw new Error(`Registry fetch failed: HTTP ${response.status}`);
293
512
  }
294
- const json = await response.json();
295
- const data = RegistryDocument.parse(json);
296
- return { fetchedAt: Date.now(), data };
513
+ const raw = await response.json();
514
+ const data = RegistryDocument.parse(raw);
515
+ return { fetchedAt: Date.now(), raw, data };
297
516
  }
298
517
  async readDiskCache() {
518
+ let text;
299
519
  try {
300
- const raw = await fs2.readFile(paths.registryCache(), "utf8");
301
- const parsed = JSON.parse(raw);
302
- if (typeof parsed.fetchedAt === "number" && parsed.data && Array.isArray(parsed.data.agents)) {
303
- return parsed;
304
- }
520
+ text = await fs3.readFile(paths.registryCache(), "utf8");
305
521
  } catch (err) {
306
522
  const e = err;
307
- if (e.code !== "ENOENT") {
308
- throw err;
523
+ if (e.code === "ENOENT") {
524
+ return void 0;
309
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;
310
537
  }
311
- return void 0;
312
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.
313
545
  async writeDiskCache(cache) {
314
- await fs2.mkdir(paths.home(), { recursive: true });
315
- await fs2.writeFile(
316
- paths.registryCache(),
317
- JSON.stringify(cache, null, 2) + "\n",
318
- "utf8"
319
- );
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
+ }
320
561
  }
321
562
  };
563
+ function randSuffix() {
564
+ return Math.random().toString(36).slice(2, 10);
565
+ }
322
566
  function npxPackageBasename(agent) {
323
567
  const pkg = agent.distribution.npx?.package;
324
568
  if (!pkg) {
@@ -329,7 +573,7 @@ function npxPackageBasename(agent) {
329
573
  const atIdx = afterSlash.lastIndexOf("@");
330
574
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
331
575
  }
332
- function planSpawn(agent, extraArgs = []) {
576
+ async function planSpawn(agent, extraArgs = []) {
333
577
  if (agent.distribution.npx) {
334
578
  const npx = agent.distribution.npx;
335
579
  const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
@@ -340,9 +584,22 @@ function planSpawn(agent, extraArgs = []) {
340
584
  };
341
585
  }
342
586
  if (agent.distribution.binary) {
343
- throw new Error(
344
- `Agent ${agent.id} uses binary distribution; not yet supported in hydra-acp. PRs welcome.`
345
- );
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
+ };
346
603
  }
347
604
  if (agent.distribution.uvx) {
348
605
  const uvx = agent.distribution.uvx;
@@ -357,11 +614,11 @@ function planSpawn(agent, extraArgs = []) {
357
614
  }
358
615
 
359
616
  // src/core/session-manager.ts
360
- import * as fs6 from "fs/promises";
617
+ import * as fs7 from "fs/promises";
361
618
  import { customAlphabet as customAlphabet3 } from "nanoid";
362
619
 
363
620
  // src/core/agent-instance.ts
364
- import { spawn } from "child_process";
621
+ import { spawn as spawn2 } from "child_process";
365
622
 
366
623
  // src/acp/types.ts
367
624
  import { z as z3 } from "zod";
@@ -481,12 +738,24 @@ var SessionListParams = z3.object({
481
738
  cursor: z3.string().optional(),
482
739
  limit: z3.number().int().positive().max(200).optional()
483
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
+ });
484
747
  var SessionListEntry = z3.object({
485
748
  sessionId: z3.string(),
486
749
  upstreamSessionId: z3.string().optional(),
487
750
  cwd: z3.string(),
488
751
  title: z3.string().optional(),
489
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(),
490
759
  updatedAt: z3.string(),
491
760
  attachedClients: z3.number().int().nonnegative(),
492
761
  status: z3.enum(["live", "cold"]).default("live"),
@@ -568,13 +837,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
568
837
  throw new Error("stream is closed");
569
838
  }
570
839
  const line = JSON.stringify(message) + "\n";
571
- await new Promise((resolve2, reject) => {
840
+ await new Promise((resolve3, reject) => {
572
841
  stdin.write(line, (err) => {
573
842
  if (err) {
574
843
  reject(err);
575
844
  return;
576
845
  }
577
- resolve2();
846
+ resolve3();
578
847
  });
579
848
  });
580
849
  },
@@ -633,9 +902,9 @@ var JsonRpcConnection = class {
633
902
  }
634
903
  const id = nanoid();
635
904
  const message = { jsonrpc: "2.0", id, method, params };
636
- const response = new Promise((resolve2, reject) => {
905
+ const response = new Promise((resolve3, reject) => {
637
906
  this.pending.set(id, {
638
- resolve: (result) => resolve2(result),
907
+ resolve: (result) => resolve3(result),
639
908
  reject
640
909
  });
641
910
  this.stream.send(message).catch((err) => {
@@ -773,7 +1042,7 @@ var AgentInstance = class _AgentInstance {
773
1042
  ...opts.plan.env,
774
1043
  ...opts.extraEnv ?? {}
775
1044
  };
776
- const child = spawn(opts.plan.command, opts.plan.args, {
1045
+ const child = spawn2(opts.plan.command, opts.plan.args, {
777
1046
  cwd: opts.cwd,
778
1047
  env,
779
1048
  stdio: ["pipe", "pipe", "pipe"]
@@ -806,8 +1075,8 @@ var HYDRA_COMMANDS = [
806
1075
  description: "Regenerate the session title via the agent (or set manually with an arg)"
807
1076
  },
808
1077
  {
809
- verb: "switch",
810
- name: "/hydra switch",
1078
+ verb: "agent",
1079
+ name: "/hydra agent",
811
1080
  argsHint: "<agent>",
812
1081
  description: "Swap the agent backing this session, preserving context"
813
1082
  }
@@ -829,7 +1098,7 @@ var COMPACT_EVERY = 200;
829
1098
  var Session = class {
830
1099
  sessionId;
831
1100
  cwd;
832
- // agent / agentId / upstreamSessionId are mutable so /hydra switch can
1101
+ // agent / agentId / upstreamSessionId are mutable so /hydra agent can
833
1102
  // replace the underlying agent process while keeping the same Session
834
1103
  // record. agentMeta is the metadata returned by the agent at session/new
835
1104
  // time; it gets refreshed on switch too.
@@ -844,6 +1113,7 @@ var Session = class {
844
1113
  // stale-prone for snapshot-shaped events).
845
1114
  currentModel;
846
1115
  currentMode;
1116
+ currentUsage;
847
1117
  updatedAt;
848
1118
  createdAt;
849
1119
  clients = /* @__PURE__ */ new Map();
@@ -905,6 +1175,7 @@ var Session = class {
905
1175
  agentCommandsHandlers = [];
906
1176
  modelHandlers = [];
907
1177
  modeHandlers = [];
1178
+ usageHandlers = [];
908
1179
  constructor(init) {
909
1180
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
910
1181
  this.cwd = init.cwd;
@@ -916,6 +1187,7 @@ var Session = class {
916
1187
  this.title = init.title;
917
1188
  this.currentModel = init.currentModel;
918
1189
  this.currentMode = init.currentMode;
1190
+ this.currentUsage = init.currentUsage;
919
1191
  if (init.agentCommands && init.agentCommands.length > 0) {
920
1192
  this.agentAdvertisedCommands = [...init.agentCommands];
921
1193
  }
@@ -945,7 +1217,7 @@ var Session = class {
945
1217
  });
946
1218
  }
947
1219
  // Register session/update, session/request_permission, and onExit
948
- // 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
949
1221
  // the new agent is plumbed identically. The exit handler's identity
950
1222
  // check is what makes switching safe: when the *old* agent exits as
951
1223
  // part of a swap, this.agent has already been replaced, so we no-op
@@ -969,6 +1241,10 @@ var Session = class {
969
1241
  this.recordAndBroadcast("session/update", params);
970
1242
  return;
971
1243
  }
1244
+ if (this.maybeApplyAgentUsage(params)) {
1245
+ this.recordAndBroadcast("session/update", params);
1246
+ return;
1247
+ }
972
1248
  this.maybeApplyAgentSessionInfo(params);
973
1249
  this.recordAndBroadcast("session/update", params);
974
1250
  });
@@ -1286,6 +1562,49 @@ var Session = class {
1286
1562
  }
1287
1563
  return true;
1288
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
+ }
1289
1608
  // Update the cached agent command list, fire persist handlers, and
1290
1609
  // broadcast the merged list to attached clients. Idempotent on a
1291
1610
  // structurally identical list so we don't churn meta.json on noisy
@@ -1316,6 +1635,9 @@ var Session = class {
1316
1635
  onModeChange(handler) {
1317
1636
  this.modeHandlers.push(handler);
1318
1637
  }
1638
+ onUsageChange(handler) {
1639
+ this.usageHandlers.push(handler);
1640
+ }
1319
1641
  // Returns a freshly merged command list (hydra ∪ agent) for callers
1320
1642
  // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1321
1643
  // assembling the attach response.
@@ -1378,8 +1700,8 @@ var Session = class {
1378
1700
  switch (verb) {
1379
1701
  case "title":
1380
1702
  return this.runTitleCommand(arg);
1381
- case "switch":
1382
- return this.runSwitchCommand(arg);
1703
+ case "agent":
1704
+ return this.runAgentCommand(arg);
1383
1705
  default: {
1384
1706
  const err = new Error(
1385
1707
  `no dispatcher for /hydra verb ${verb}`
@@ -1415,7 +1737,7 @@ var Session = class {
1415
1737
  }
1416
1738
  // Send a prompt to the underlying agent and capture its reply chunks
1417
1739
  // privately (no fan-out to clients, no recording into history). Used
1418
- // 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
1419
1741
  // path. Returns the joined agent_message_chunk text.
1420
1742
  async runInternalPrompt(text) {
1421
1743
  if (this.internalPromptCapture) {
@@ -1437,10 +1759,10 @@ var Session = class {
1437
1759
  // record. Spawns the new agent first so a failure leaves the old one
1438
1760
  // intact; then injects a synthesized transcript so the new agent has
1439
1761
  // context for the next turn.
1440
- runSwitchCommand(newAgentId) {
1762
+ runAgentCommand(newAgentId) {
1441
1763
  if (!newAgentId) {
1442
1764
  throw withCode(
1443
- new Error("/hydra switch requires an agent id"),
1765
+ new Error("/hydra agent requires an agent id"),
1444
1766
  JsonRpcErrorCodes.InvalidParams
1445
1767
  );
1446
1768
  }
@@ -1574,7 +1896,7 @@ var Session = class {
1574
1896
  // on the first wake-up of a session whose meta.json has an empty
1575
1897
  // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1576
1898
  // any user prompts arriving mid-seed queue behind it (mirrors the
1577
- // /hydra switch path so the agent isn't asked to respond to a user
1899
+ // /hydra agent path so the agent isn't asked to respond to a user
1578
1900
  // turn before it has absorbed the imported transcript). Best-effort:
1579
1901
  // if the agent fails to absorb the transcript we still leave the
1580
1902
  // session usable — the user just continues without context.
@@ -1598,7 +1920,7 @@ var Session = class {
1598
1920
  // ones read it and relabel) and (b) drop a visible banner into the
1599
1921
  // transcript so users see the switch rather than just suddenly getting
1600
1922
  // answers from a different agent. Both updates carry synthetic=true
1601
- // so a future /hydra switch's transcript builder filters them out.
1923
+ // so a future /hydra agent's transcript builder filters them out.
1602
1924
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1603
1925
  this.recordAndBroadcast("session/update", {
1604
1926
  sessionId: this.sessionId,
@@ -1745,7 +2067,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1745
2067
  );
1746
2068
  }
1747
2069
  const clientParams = this.rewriteForClient(params);
1748
- return new Promise((resolve2, reject) => {
2070
+ return new Promise((resolve3, reject) => {
1749
2071
  let settled = false;
1750
2072
  const outbound = [];
1751
2073
  const entry = { addClient: sendTo };
@@ -1780,7 +2102,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1780
2102
  result
1781
2103
  }).catch(() => void 0);
1782
2104
  }
1783
- resolve2(result);
2105
+ resolve3(result);
1784
2106
  });
1785
2107
  }).catch((err) => {
1786
2108
  settle(() => reject(err));
@@ -1792,16 +2114,16 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1792
2114
  });
1793
2115
  }
1794
2116
  async enqueuePrompt(task) {
1795
- return new Promise((resolve2, reject) => {
1796
- const run = async () => {
2117
+ return new Promise((resolve3, reject) => {
2118
+ const run2 = async () => {
1797
2119
  try {
1798
2120
  const result = await task();
1799
- resolve2(result);
2121
+ resolve3(result);
1800
2122
  } catch (err) {
1801
2123
  reject(err);
1802
2124
  }
1803
2125
  };
1804
- this.promptQueue.push(run);
2126
+ this.promptQueue.push(run2);
1805
2127
  void this.drainQueue();
1806
2128
  });
1807
2129
  }
@@ -1830,7 +2152,8 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1830
2152
  "session_info_update",
1831
2153
  "current_model_update",
1832
2154
  "current_mode_update",
1833
- "available_commands_update"
2155
+ "available_commands_update",
2156
+ "usage_update"
1834
2157
  ]);
1835
2158
  function isStateUpdate(method, params) {
1836
2159
  if (method !== "session/update") {
@@ -1915,8 +2238,8 @@ function firstLine(text, max) {
1915
2238
  }
1916
2239
 
1917
2240
  // src/core/session-store.ts
1918
- import * as fs3 from "fs/promises";
1919
- import * as path2 from "path";
2241
+ import * as fs4 from "fs/promises";
2242
+ import * as path3 from "path";
1920
2243
  import { customAlphabet as customAlphabet2 } from "nanoid";
1921
2244
  import { z as z4 } from "zod";
1922
2245
  var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -1929,6 +2252,12 @@ var PersistedAgentCommand = z4.object({
1929
2252
  name: z4.string(),
1930
2253
  description: z4.string().optional()
1931
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
+ });
1932
2261
  var SessionRecord = z4.object({
1933
2262
  version: z4.literal(1),
1934
2263
  sessionId: z4.string(),
@@ -1956,6 +2285,7 @@ var SessionRecord = z4.object({
1956
2285
  // replay of a snapshot-shaped notification.
1957
2286
  currentModel: z4.string().optional(),
1958
2287
  currentMode: z4.string().optional(),
2288
+ currentUsage: PersistedUsage.optional(),
1959
2289
  agentCommands: z4.array(PersistedAgentCommand).optional(),
1960
2290
  createdAt: z4.string(),
1961
2291
  updatedAt: z4.string()
@@ -1969,9 +2299,9 @@ function assertSafeId(id) {
1969
2299
  var SessionStore = class {
1970
2300
  async write(record) {
1971
2301
  assertSafeId(record.sessionId);
1972
- await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2302
+ await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
1973
2303
  const full = { version: 1, ...record };
1974
- await fs3.writeFile(
2304
+ await fs4.writeFile(
1975
2305
  paths.sessionFile(record.sessionId),
1976
2306
  JSON.stringify(full, null, 2) + "\n",
1977
2307
  { encoding: "utf8", mode: 384 }
@@ -1983,7 +2313,7 @@ var SessionStore = class {
1983
2313
  }
1984
2314
  let raw;
1985
2315
  try {
1986
- raw = await fs3.readFile(paths.sessionFile(sessionId), "utf8");
2316
+ raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
1987
2317
  } catch (err) {
1988
2318
  const e = err;
1989
2319
  if (e.code === "ENOENT") {
@@ -2002,7 +2332,7 @@ var SessionStore = class {
2002
2332
  return;
2003
2333
  }
2004
2334
  try {
2005
- await fs3.unlink(paths.sessionFile(sessionId));
2335
+ await fs4.unlink(paths.sessionFile(sessionId));
2006
2336
  } catch (err) {
2007
2337
  const e = err;
2008
2338
  if (e.code !== "ENOENT") {
@@ -2010,7 +2340,7 @@ var SessionStore = class {
2010
2340
  }
2011
2341
  }
2012
2342
  try {
2013
- await fs3.rmdir(paths.sessionDir(sessionId));
2343
+ await fs4.rmdir(paths.sessionDir(sessionId));
2014
2344
  } catch (err) {
2015
2345
  const e = err;
2016
2346
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -2040,7 +2370,7 @@ var SessionStore = class {
2040
2370
  async list() {
2041
2371
  let entries;
2042
2372
  try {
2043
- entries = await fs3.readdir(paths.sessionsDir());
2373
+ entries = await fs4.readdir(paths.sessionsDir());
2044
2374
  } catch (err) {
2045
2375
  const e = err;
2046
2376
  if (e.code === "ENOENT") {
@@ -2071,6 +2401,7 @@ function recordFromMemorySession(args) {
2071
2401
  agentArgs: args.agentArgs,
2072
2402
  currentModel: args.currentModel,
2073
2403
  currentMode: args.currentMode,
2404
+ currentUsage: args.currentUsage,
2074
2405
  agentCommands: args.agentCommands,
2075
2406
  createdAt: args.createdAt ?? now,
2076
2407
  updatedAt: args.updatedAt ?? now
@@ -2078,7 +2409,7 @@ function recordFromMemorySession(args) {
2078
2409
  }
2079
2410
 
2080
2411
  // src/core/history-store.ts
2081
- import * as fs4 from "fs/promises";
2412
+ import * as fs5 from "fs/promises";
2082
2413
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
2083
2414
  var MAX_ENTRIES = 1e3;
2084
2415
  var HistoryStore = class {
@@ -2091,9 +2422,9 @@ var HistoryStore = class {
2091
2422
  return;
2092
2423
  }
2093
2424
  return this.enqueue(sessionId, async () => {
2094
- await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
2425
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
2095
2426
  const line = JSON.stringify(entry) + "\n";
2096
- await fs4.appendFile(paths.historyFile(sessionId), line, {
2427
+ await fs5.appendFile(paths.historyFile(sessionId), line, {
2097
2428
  encoding: "utf8",
2098
2429
  mode: 384
2099
2430
  });
@@ -2104,9 +2435,9 @@ var HistoryStore = class {
2104
2435
  return;
2105
2436
  }
2106
2437
  return this.enqueue(sessionId, async () => {
2107
- await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
2438
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
2108
2439
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
2109
- await fs4.writeFile(paths.historyFile(sessionId), body, {
2440
+ await fs5.writeFile(paths.historyFile(sessionId), body, {
2110
2441
  encoding: "utf8",
2111
2442
  mode: 384
2112
2443
  });
@@ -2123,7 +2454,7 @@ var HistoryStore = class {
2123
2454
  return this.enqueue(sessionId, async () => {
2124
2455
  let raw;
2125
2456
  try {
2126
- raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2457
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
2127
2458
  } catch (err) {
2128
2459
  const e = err;
2129
2460
  if (e.code === "ENOENT") {
@@ -2136,7 +2467,7 @@ var HistoryStore = class {
2136
2467
  return;
2137
2468
  }
2138
2469
  const trimmed = lines.slice(-maxEntries);
2139
- await fs4.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
2470
+ await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
2140
2471
  encoding: "utf8",
2141
2472
  mode: 384
2142
2473
  });
@@ -2152,7 +2483,7 @@ var HistoryStore = class {
2152
2483
  }
2153
2484
  let raw;
2154
2485
  try {
2155
- raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2486
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
2156
2487
  } catch (err) {
2157
2488
  const e = err;
2158
2489
  if (e.code === "ENOENT") {
@@ -2198,7 +2529,7 @@ var HistoryStore = class {
2198
2529
  }
2199
2530
  return this.enqueue(sessionId, async () => {
2200
2531
  try {
2201
- await fs4.unlink(paths.historyFile(sessionId));
2532
+ await fs5.unlink(paths.historyFile(sessionId));
2202
2533
  } catch (err) {
2203
2534
  const e = err;
2204
2535
  if (e.code !== "ENOENT") {
@@ -2206,7 +2537,7 @@ var HistoryStore = class {
2206
2537
  }
2207
2538
  }
2208
2539
  try {
2209
- await fs4.rmdir(paths.sessionDir(sessionId));
2540
+ await fs5.rmdir(paths.sessionDir(sessionId));
2210
2541
  } catch (err) {
2211
2542
  const e = err;
2212
2543
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -2230,12 +2561,12 @@ var HistoryStore = class {
2230
2561
  };
2231
2562
 
2232
2563
  // src/tui/history.ts
2233
- import { promises as fs5 } from "fs";
2234
- import * as path3 from "path";
2564
+ import { promises as fs6 } from "fs";
2565
+ import * as path4 from "path";
2235
2566
  async function saveHistory(file, history) {
2236
- await fs5.mkdir(path3.dirname(file), { recursive: true });
2567
+ await fs6.mkdir(path4.dirname(file), { recursive: true });
2237
2568
  const lines = history.map((entry) => JSON.stringify(entry));
2238
- await fs5.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2569
+ await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2239
2570
  }
2240
2571
 
2241
2572
  // src/core/session-manager.ts
@@ -2248,6 +2579,7 @@ var SessionManager = class {
2248
2579
  this.store = store ?? new SessionStore();
2249
2580
  this.histories = new HistoryStore();
2250
2581
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
2582
+ this.defaultModels = options.defaultModels ?? {};
2251
2583
  }
2252
2584
  registry;
2253
2585
  sessions = /* @__PURE__ */ new Map();
@@ -2256,6 +2588,7 @@ var SessionManager = class {
2256
2588
  store;
2257
2589
  histories;
2258
2590
  idleTimeoutMs;
2591
+ defaultModels;
2259
2592
  // Serialize meta.json read-modify-write operations per session id so
2260
2593
  // concurrent snapshot updates (e.g. an agent emitting model + mode
2261
2594
  // back-to-back) don't lose writes via interleaved reads.
@@ -2277,7 +2610,8 @@ var SessionManager = class {
2277
2610
  agentArgs: params.agentArgs,
2278
2611
  idleTimeoutMs: this.idleTimeoutMs,
2279
2612
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2280
- historyStore: this.histories
2613
+ historyStore: this.histories,
2614
+ currentModel: fresh.initialModel
2281
2615
  });
2282
2616
  await this.attachManagerHooks(session);
2283
2617
  return session;
@@ -2322,7 +2656,7 @@ var SessionManager = class {
2322
2656
  if (params.upstreamSessionId === "") {
2323
2657
  return this.doResurrectFromImport(params);
2324
2658
  }
2325
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
2659
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
2326
2660
  const agent = this.spawner({
2327
2661
  agentId: params.agentId,
2328
2662
  cwd: params.cwd,
@@ -2335,11 +2669,14 @@ var SessionManager = class {
2335
2669
  });
2336
2670
  let loadResult;
2337
2671
  try {
2338
- loadResult = await agent.connection.request("session/load", {
2339
- sessionId: params.upstreamSessionId,
2340
- cwd: params.cwd,
2341
- mcpServers: []
2342
- });
2672
+ loadResult = await agent.connection.request(
2673
+ "session/load",
2674
+ {
2675
+ sessionId: params.upstreamSessionId,
2676
+ cwd: params.cwd,
2677
+ mcpServers: []
2678
+ }
2679
+ );
2343
2680
  } catch (err) {
2344
2681
  await agent.kill().catch(() => void 0);
2345
2682
  throw new Error(
@@ -2358,8 +2695,13 @@ var SessionManager = class {
2358
2695
  idleTimeoutMs: this.idleTimeoutMs,
2359
2696
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2360
2697
  historyStore: this.histories,
2361
- 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 ?? {}),
2362
2703
  currentMode: params.currentMode,
2704
+ currentUsage: params.currentUsage,
2363
2705
  agentCommands: params.agentCommands,
2364
2706
  // Only gate the first-prompt title heuristic when we actually have
2365
2707
  // a title to preserve. A title-less session (lost to a write race
@@ -2397,8 +2739,11 @@ var SessionManager = class {
2397
2739
  idleTimeoutMs: this.idleTimeoutMs,
2398
2740
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2399
2741
  historyStore: this.histories,
2400
- currentModel: params.currentModel,
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,
2401
2745
  currentMode: params.currentMode,
2746
+ currentUsage: params.currentUsage,
2402
2747
  agentCommands: params.agentCommands,
2403
2748
  firstPromptSeeded: !!params.title,
2404
2749
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
@@ -2408,7 +2753,7 @@ var SessionManager = class {
2408
2753
  return session;
2409
2754
  }
2410
2755
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
2411
- // → 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
2412
2757
  // go through the same env / capabilities / error-handling.
2413
2758
  async bootstrapAgent(params) {
2414
2759
  const agentDef = await this.registry.getAgent(params.agentId);
@@ -2419,7 +2764,7 @@ var SessionManager = class {
2419
2764
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
2420
2765
  throw err;
2421
2766
  }
2422
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
2767
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
2423
2768
  const agent = this.spawner({
2424
2769
  agentId: params.agentId,
2425
2770
  cwd: params.cwd,
@@ -2431,14 +2776,36 @@ var SessionManager = class {
2431
2776
  clientCapabilities: {},
2432
2777
  clientInfo: { name: "hydra", version: "0.1.0" }
2433
2778
  });
2434
- const newResult = await agent.connection.request("session/new", {
2435
- cwd: params.cwd,
2436
- mcpServers: params.mcpServers ?? []
2437
- });
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
+ }
2438
2804
  return {
2439
2805
  agent,
2440
- upstreamSessionId: newResult.sessionId,
2441
- agentMeta: newResult._meta
2806
+ upstreamSessionId: sessionIdRaw,
2807
+ agentMeta: newResult._meta,
2808
+ initialModel
2442
2809
  };
2443
2810
  } catch (err) {
2444
2811
  await agent.kill().catch(() => void 0);
@@ -2449,7 +2816,7 @@ var SessionManager = class {
2449
2816
  // bookkeeping. Called from both create() and resurrect() so the same
2450
2817
  // session record + lifecycle handlers are wired regardless of origin.
2451
2818
  // Returns once the initial disk record is written — callers should
2452
- // await so a subsequent /hydra switch's persistAgentChange (which
2819
+ // await so a subsequent /hydra agent's persistAgentChange (which
2453
2820
  // does read-then-write) finds the file in place.
2454
2821
  async attachManagerHooks(session) {
2455
2822
  session.onClose(({ deleteRecord }) => {
@@ -2477,6 +2844,11 @@ var SessionManager = class {
2477
2844
  () => void 0
2478
2845
  );
2479
2846
  });
2847
+ session.onUsageChange((usage) => {
2848
+ void this.persistSnapshot(session.sessionId, {
2849
+ currentUsage: usageSnapshotToPersisted(usage)
2850
+ }).catch(() => void 0);
2851
+ });
2480
2852
  session.onAgentCommandsChange((commands) => {
2481
2853
  void this.persistSnapshot(session.sessionId, {
2482
2854
  agentCommands: commands.map((c) => ({
@@ -2525,6 +2897,7 @@ var SessionManager = class {
2525
2897
  agentArgs: record.agentArgs,
2526
2898
  currentModel: record.currentModel,
2527
2899
  currentMode: record.currentMode,
2900
+ currentUsage: persistedUsageToSnapshot(record.currentUsage),
2528
2901
  agentCommands: record.agentCommands,
2529
2902
  createdAt: record.createdAt
2530
2903
  };
@@ -2593,6 +2966,8 @@ var SessionManager = class {
2593
2966
  cwd: session.cwd,
2594
2967
  title: session.title,
2595
2968
  agentId: session.agentId,
2969
+ currentModel: session.currentModel,
2970
+ currentUsage: session.currentUsage,
2596
2971
  updatedAt: used,
2597
2972
  attachedClients: session.attachedCount,
2598
2973
  status: "live"
@@ -2613,6 +2988,8 @@ var SessionManager = class {
2613
2988
  cwd: r.cwd,
2614
2989
  title: r.title,
2615
2990
  agentId: r.agentId,
2991
+ currentModel: r.currentModel,
2992
+ currentUsage: r.currentUsage,
2616
2993
  updatedAt: used,
2617
2994
  attachedClients: 0,
2618
2995
  status: "cold"
@@ -2720,6 +3097,7 @@ var SessionManager = class {
2720
3097
  title: args.bundle.session.title,
2721
3098
  currentModel: args.bundle.session.currentModel,
2722
3099
  currentMode: args.bundle.session.currentMode,
3100
+ currentUsage: args.bundle.session.currentUsage,
2723
3101
  agentCommands: args.bundle.session.agentCommands,
2724
3102
  createdAt: args.preservedCreatedAt ?? now,
2725
3103
  updatedAt: now
@@ -2755,7 +3133,7 @@ var SessionManager = class {
2755
3133
  });
2756
3134
  });
2757
3135
  }
2758
- // 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
2759
3137
  // agentId + upstreamSessionId both rotate so a daemon restart (and
2760
3138
  // later resurrect) brings the session back up on the agent the user
2761
3139
  // most recently switched to, not the one it was originally created on.
@@ -2787,6 +3165,7 @@ var SessionManager = class {
2787
3165
  ...record,
2788
3166
  ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
2789
3167
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
3168
+ ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
2790
3169
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
2791
3170
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2792
3171
  });
@@ -2841,13 +3220,73 @@ function mergeForPersistence(session, existing) {
2841
3220
  agentArgs: session.agentArgs,
2842
3221
  currentModel: session.currentModel ?? existing?.currentModel,
2843
3222
  currentMode: session.currentMode ?? existing?.currentMode,
3223
+ currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
2844
3224
  agentCommands,
2845
3225
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
2846
3226
  });
2847
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
+ }
2848
3287
  async function loadPromptHistorySafely(sessionId) {
2849
3288
  try {
2850
- const raw = await fs6.readFile(paths.tuiHistoryFile(sessionId), "utf8");
3289
+ const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
2851
3290
  const out = [];
2852
3291
  for (const line of raw.split("\n")) {
2853
3292
  if (line.length === 0) {
@@ -2868,7 +3307,7 @@ async function loadPromptHistorySafely(sessionId) {
2868
3307
  }
2869
3308
  async function historyMtimeIso(sessionId) {
2870
3309
  try {
2871
- const st = await fs6.stat(paths.historyFile(sessionId));
3310
+ const st = await fs7.stat(paths.historyFile(sessionId));
2872
3311
  return new Date(st.mtimeMs).toISOString();
2873
3312
  } catch {
2874
3313
  return void 0;
@@ -2876,10 +3315,10 @@ async function historyMtimeIso(sessionId) {
2876
3315
  }
2877
3316
 
2878
3317
  // src/core/extensions.ts
2879
- import { spawn as spawn2 } from "child_process";
2880
- import * as fs7 from "fs";
2881
- import * as fsp from "fs/promises";
2882
- import * as path4 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";
2883
3322
  var RESTART_BASE_MS = 1e3;
2884
3323
  var RESTART_CAP_MS = 6e4;
2885
3324
  var STOP_GRACE_MS = 3e3;
@@ -2900,7 +3339,7 @@ var ExtensionManager = class {
2900
3339
  if (!this.context) {
2901
3340
  throw new Error("ExtensionManager: setContext must be called before start");
2902
3341
  }
2903
- await fsp.mkdir(paths.extensionsDir(), { recursive: true });
3342
+ await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
2904
3343
  await this.reapOrphans();
2905
3344
  for (const entry of this.entries.values()) {
2906
3345
  if (!entry.config.enabled) {
@@ -2926,9 +3365,9 @@ var ExtensionManager = class {
2926
3365
  } catch {
2927
3366
  }
2928
3367
  tasks.push(
2929
- new Promise((resolve2) => {
3368
+ new Promise((resolve3) => {
2930
3369
  if (child.exitCode !== null || child.signalCode !== null) {
2931
- resolve2();
3370
+ resolve3();
2932
3371
  return;
2933
3372
  }
2934
3373
  const timer = setTimeout(() => {
@@ -2936,11 +3375,11 @@ var ExtensionManager = class {
2936
3375
  child.kill("SIGKILL");
2937
3376
  } catch {
2938
3377
  }
2939
- resolve2();
3378
+ resolve3();
2940
3379
  }, STOP_GRACE_MS);
2941
3380
  child.on("exit", () => {
2942
3381
  clearTimeout(timer);
2943
- resolve2();
3382
+ resolve3();
2944
3383
  });
2945
3384
  })
2946
3385
  );
@@ -3048,8 +3487,8 @@ var ExtensionManager = class {
3048
3487
  if (child.exitCode !== null || child.signalCode !== null) {
3049
3488
  return;
3050
3489
  }
3051
- const exited = new Promise((resolve2) => {
3052
- entry.exitWaiters.push(resolve2);
3490
+ const exited = new Promise((resolve3) => {
3491
+ entry.exitWaiters.push(resolve3);
3053
3492
  });
3054
3493
  try {
3055
3494
  child.kill("SIGTERM");
@@ -3109,7 +3548,7 @@ var ExtensionManager = class {
3109
3548
  async reapOrphans() {
3110
3549
  let entries;
3111
3550
  try {
3112
- entries = await fsp.readdir(paths.extensionsDir());
3551
+ entries = await fsp2.readdir(paths.extensionsDir());
3113
3552
  } catch (err) {
3114
3553
  const e = err;
3115
3554
  if (e.code === "ENOENT") {
@@ -3121,10 +3560,10 @@ var ExtensionManager = class {
3121
3560
  if (!entry.endsWith(".pid")) {
3122
3561
  continue;
3123
3562
  }
3124
- const pidPath = path4.join(paths.extensionsDir(), entry);
3563
+ const pidPath = path5.join(paths.extensionsDir(), entry);
3125
3564
  let pid;
3126
3565
  try {
3127
- const raw = await fsp.readFile(pidPath, "utf8");
3566
+ const raw = await fsp2.readFile(pidPath, "utf8");
3128
3567
  const parsed = Number.parseInt(raw.trim(), 10);
3129
3568
  if (Number.isInteger(parsed) && parsed > 0) {
3130
3569
  pid = parsed;
@@ -3147,7 +3586,7 @@ var ExtensionManager = class {
3147
3586
  }
3148
3587
  }
3149
3588
  }
3150
- await fsp.unlink(pidPath).catch(() => void 0);
3589
+ await fsp2.unlink(pidPath).catch(() => void 0);
3151
3590
  }
3152
3591
  }
3153
3592
  spawn(entry, attempt) {
@@ -3160,7 +3599,7 @@ var ExtensionManager = class {
3160
3599
  }
3161
3600
  const ext = entry.config;
3162
3601
  const command = ext.command.length > 0 ? ext.command : [ext.name];
3163
- const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
3602
+ const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
3164
3603
  flags: "a"
3165
3604
  });
3166
3605
  logStream.write(
@@ -3188,7 +3627,7 @@ var ExtensionManager = class {
3188
3627
  const args = [...baseArgs, ...ext.args];
3189
3628
  let child;
3190
3629
  try {
3191
- child = spawn2(cmd, args, {
3630
+ child = spawn3(cmd, args, {
3192
3631
  env,
3193
3632
  stdio: ["ignore", "pipe", "pipe"],
3194
3633
  detached: false
@@ -3210,7 +3649,7 @@ var ExtensionManager = class {
3210
3649
  }
3211
3650
  if (typeof child.pid === "number") {
3212
3651
  try {
3213
- fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3652
+ fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3214
3653
  `, {
3215
3654
  encoding: "utf8",
3216
3655
  mode: 384
@@ -3235,7 +3674,7 @@ var ExtensionManager = class {
3235
3674
  });
3236
3675
  child.on("exit", (code, signal) => {
3237
3676
  try {
3238
- fs7.unlinkSync(paths.extensionPidFile(ext.name));
3677
+ fs8.unlinkSync(paths.extensionPidFile(ext.name));
3239
3678
  } catch {
3240
3679
  }
3241
3680
  logStream.write(
@@ -3246,8 +3685,8 @@ var ExtensionManager = class {
3246
3685
  entry.pid = void 0;
3247
3686
  entry.lastExitCode = typeof code === "number" ? code : void 0;
3248
3687
  const waiters = entry.exitWaiters.splice(0);
3249
- for (const resolve2 of waiters) {
3250
- resolve2();
3688
+ for (const resolve3 of waiters) {
3689
+ resolve3();
3251
3690
  }
3252
3691
  if (this.stopping || entry.manuallyStopped) {
3253
3692
  try {
@@ -3365,6 +3804,7 @@ var BundleSession = z5.object({
3365
3804
  title: z5.string().optional(),
3366
3805
  currentModel: z5.string().optional(),
3367
3806
  currentMode: z5.string().optional(),
3807
+ currentUsage: PersistedUsage.optional(),
3368
3808
  agentCommands: z5.array(PersistedAgentCommand).optional(),
3369
3809
  createdAt: z5.string(),
3370
3810
  updatedAt: z5.string()
@@ -3396,6 +3836,7 @@ function encodeBundle(params) {
3396
3836
  ...params.record.title !== void 0 ? { title: params.record.title } : {},
3397
3837
  ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
3398
3838
  ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
3839
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
3399
3840
  ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
3400
3841
  createdAt: params.record.createdAt,
3401
3842
  updatedAt: params.record.updatedAt
@@ -3794,13 +4235,13 @@ function wsToMessageStream(ws) {
3794
4235
  throw new Error("ws is closed");
3795
4236
  }
3796
4237
  const text = JSON.stringify(message);
3797
- await new Promise((resolve2, reject) => {
4238
+ await new Promise((resolve3, reject) => {
3798
4239
  ws.send(text, (err) => {
3799
4240
  if (err) {
3800
4241
  reject(err);
3801
4242
  return;
3802
4243
  }
3803
- resolve2();
4244
+ resolve3();
3804
4245
  });
3805
4246
  });
3806
4247
  },
@@ -4128,10 +4569,10 @@ var HYDRA_VERSION3 = "0.1.0";
4128
4569
  async function startDaemon(config) {
4129
4570
  ensureLoopbackOrTls(config);
4130
4571
  const httpsOptions = config.daemon.tls ? {
4131
- key: await fsp2.readFile(config.daemon.tls.key),
4132
- 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)
4133
4574
  } : void 0;
4134
- await fsp2.mkdir(paths.home(), { recursive: true });
4575
+ await fsp3.mkdir(paths.home(), { recursive: true });
4135
4576
  const { stream: logStream, fileStream } = await buildLogStream(
4136
4577
  config.daemon.logLevel
4137
4578
  );
@@ -4143,6 +4584,9 @@ async function startDaemon(config) {
4143
4584
  https: httpsOptions ?? null
4144
4585
  });
4145
4586
  await app.register(websocketPlugin);
4587
+ setBinaryInstallLogger((msg) => {
4588
+ app.log.info(msg);
4589
+ });
4146
4590
  const auth = bearerAuth({ config });
4147
4591
  app.addHook("onRequest", async (request, reply) => {
4148
4592
  if (request.routeOptions.config?.skipAuth) {
@@ -4155,7 +4599,8 @@ async function startDaemon(config) {
4155
4599
  });
4156
4600
  const registry = new Registry(config);
4157
4601
  const manager = new SessionManager(registry, void 0, void 0, {
4158
- idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
4602
+ idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
4603
+ defaultModels: config.defaultModels
4159
4604
  });
4160
4605
  const extensions = new ExtensionManager(extensionList(config));
4161
4606
  registerHealthRoutes(app, HYDRA_VERSION3);
@@ -4177,8 +4622,8 @@ async function startDaemon(config) {
4177
4622
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
4178
4623
  const address = app.server.address();
4179
4624
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
4180
- await fsp2.mkdir(paths.home(), { recursive: true });
4181
- await fsp2.writeFile(
4625
+ await fsp3.mkdir(paths.home(), { recursive: true });
4626
+ await fsp3.writeFile(
4182
4627
  paths.pidFile(),
4183
4628
  JSON.stringify({
4184
4629
  pid: process.pid,
@@ -4203,9 +4648,10 @@ async function startDaemon(config) {
4203
4648
  await extensions.stop();
4204
4649
  await manager.closeAll();
4205
4650
  await manager.flushMetaWrites();
4651
+ setBinaryInstallLogger(null);
4206
4652
  await app.close();
4207
4653
  try {
4208
- fs8.unlinkSync(paths.pidFile());
4654
+ fs9.unlinkSync(paths.pidFile());
4209
4655
  } catch {
4210
4656
  }
4211
4657
  try {