@hydra-acp/cli 0.1.4 → 0.1.6

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";
@@ -439,6 +696,9 @@ function extractHydraMeta(meta) {
439
696
  out.resume = parsed.data;
440
697
  }
441
698
  }
699
+ if (typeof obj.model === "string") {
700
+ out.model = obj.model;
701
+ }
442
702
  if (typeof obj.currentModel === "string") {
443
703
  out.currentModel = obj.currentModel;
444
704
  }
@@ -481,12 +741,24 @@ var SessionListParams = z3.object({
481
741
  cursor: z3.string().optional(),
482
742
  limit: z3.number().int().positive().max(200).optional()
483
743
  });
744
+ var SessionListUsage = z3.object({
745
+ used: z3.number().optional(),
746
+ size: z3.number().optional(),
747
+ costAmount: z3.number().optional(),
748
+ costCurrency: z3.string().optional()
749
+ });
484
750
  var SessionListEntry = z3.object({
485
751
  sessionId: z3.string(),
486
752
  upstreamSessionId: z3.string().optional(),
487
753
  cwd: z3.string(),
488
754
  title: z3.string().optional(),
489
755
  agentId: z3.string().optional(),
756
+ // Last-known model id, so list views can render `<agent>(<model>)`
757
+ // without resurrecting cold sessions to look it up.
758
+ currentModel: z3.string().optional(),
759
+ // Last-known usage snapshot so list views can show per-session cost
760
+ // (and tokens, in callers that care) without resurrecting cold sessions.
761
+ currentUsage: SessionListUsage.optional(),
490
762
  updatedAt: z3.string(),
491
763
  attachedClients: z3.number().int().nonnegative(),
492
764
  status: z3.enum(["live", "cold"]).default("live"),
@@ -568,13 +840,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
568
840
  throw new Error("stream is closed");
569
841
  }
570
842
  const line = JSON.stringify(message) + "\n";
571
- await new Promise((resolve2, reject) => {
843
+ await new Promise((resolve3, reject) => {
572
844
  stdin.write(line, (err) => {
573
845
  if (err) {
574
846
  reject(err);
575
847
  return;
576
848
  }
577
- resolve2();
849
+ resolve3();
578
850
  });
579
851
  });
580
852
  },
@@ -633,9 +905,9 @@ var JsonRpcConnection = class {
633
905
  }
634
906
  const id = nanoid();
635
907
  const message = { jsonrpc: "2.0", id, method, params };
636
- const response = new Promise((resolve2, reject) => {
908
+ const response = new Promise((resolve3, reject) => {
637
909
  this.pending.set(id, {
638
- resolve: (result) => resolve2(result),
910
+ resolve: (result) => resolve3(result),
639
911
  reject
640
912
  });
641
913
  this.stream.send(message).catch((err) => {
@@ -773,7 +1045,7 @@ var AgentInstance = class _AgentInstance {
773
1045
  ...opts.plan.env,
774
1046
  ...opts.extraEnv ?? {}
775
1047
  };
776
- const child = spawn(opts.plan.command, opts.plan.args, {
1048
+ const child = spawn2(opts.plan.command, opts.plan.args, {
777
1049
  cwd: opts.cwd,
778
1050
  env,
779
1051
  stdio: ["pipe", "pipe", "pipe"]
@@ -806,8 +1078,8 @@ var HYDRA_COMMANDS = [
806
1078
  description: "Regenerate the session title via the agent (or set manually with an arg)"
807
1079
  },
808
1080
  {
809
- verb: "switch",
810
- name: "/hydra switch",
1081
+ verb: "agent",
1082
+ name: "/hydra agent",
811
1083
  argsHint: "<agent>",
812
1084
  description: "Swap the agent backing this session, preserving context"
813
1085
  }
@@ -829,7 +1101,7 @@ var COMPACT_EVERY = 200;
829
1101
  var Session = class {
830
1102
  sessionId;
831
1103
  cwd;
832
- // agent / agentId / upstreamSessionId are mutable so /hydra switch can
1104
+ // agent / agentId / upstreamSessionId are mutable so /hydra agent can
833
1105
  // replace the underlying agent process while keeping the same Session
834
1106
  // record. agentMeta is the metadata returned by the agent at session/new
835
1107
  // time; it gets refreshed on switch too.
@@ -844,6 +1116,7 @@ var Session = class {
844
1116
  // stale-prone for snapshot-shaped events).
845
1117
  currentModel;
846
1118
  currentMode;
1119
+ currentUsage;
847
1120
  updatedAt;
848
1121
  createdAt;
849
1122
  clients = /* @__PURE__ */ new Map();
@@ -905,6 +1178,7 @@ var Session = class {
905
1178
  agentCommandsHandlers = [];
906
1179
  modelHandlers = [];
907
1180
  modeHandlers = [];
1181
+ usageHandlers = [];
908
1182
  constructor(init) {
909
1183
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
910
1184
  this.cwd = init.cwd;
@@ -916,6 +1190,7 @@ var Session = class {
916
1190
  this.title = init.title;
917
1191
  this.currentModel = init.currentModel;
918
1192
  this.currentMode = init.currentMode;
1193
+ this.currentUsage = init.currentUsage;
919
1194
  if (init.agentCommands && init.agentCommands.length > 0) {
920
1195
  this.agentAdvertisedCommands = [...init.agentCommands];
921
1196
  }
@@ -945,7 +1220,7 @@ var Session = class {
945
1220
  });
946
1221
  }
947
1222
  // Register session/update, session/request_permission, and onExit
948
- // handlers on an agent connection. Re-run on every /hydra switch so
1223
+ // handlers on an agent connection. Re-run on every /hydra agent so
949
1224
  // the new agent is plumbed identically. The exit handler's identity
950
1225
  // check is what makes switching safe: when the *old* agent exits as
951
1226
  // part of a swap, this.agent has already been replaced, so we no-op
@@ -969,6 +1244,10 @@ var Session = class {
969
1244
  this.recordAndBroadcast("session/update", params);
970
1245
  return;
971
1246
  }
1247
+ if (this.maybeApplyAgentUsage(params)) {
1248
+ this.recordAndBroadcast("session/update", params);
1249
+ return;
1250
+ }
972
1251
  this.maybeApplyAgentSessionInfo(params);
973
1252
  this.recordAndBroadcast("session/update", params);
974
1253
  });
@@ -1286,6 +1565,49 @@ var Session = class {
1286
1565
  }
1287
1566
  return true;
1288
1567
  }
1568
+ // usage_update carries any subset of {used, size, cost.amount,
1569
+ // cost.currency}. Merge non-undefined fields onto currentUsage so a
1570
+ // sparse update preserves prior values, and fire usage handlers only
1571
+ // if something actually changed.
1572
+ maybeApplyAgentUsage(params) {
1573
+ const obj = params ?? {};
1574
+ const update = obj.update ?? {};
1575
+ if (update.sessionUpdate !== "usage_update") {
1576
+ return false;
1577
+ }
1578
+ const next = { ...this.currentUsage ?? {} };
1579
+ let changed = false;
1580
+ if (typeof update.used === "number" && next.used !== update.used) {
1581
+ next.used = update.used;
1582
+ changed = true;
1583
+ }
1584
+ if (typeof update.size === "number" && next.size !== update.size) {
1585
+ next.size = update.size;
1586
+ changed = true;
1587
+ }
1588
+ if (update.cost && typeof update.cost === "object") {
1589
+ const cost = update.cost;
1590
+ if (typeof cost.amount === "number" && next.costAmount !== cost.amount) {
1591
+ next.costAmount = cost.amount;
1592
+ changed = true;
1593
+ }
1594
+ if (typeof cost.currency === "string" && next.costCurrency !== cost.currency) {
1595
+ next.costCurrency = cost.currency;
1596
+ changed = true;
1597
+ }
1598
+ }
1599
+ if (!changed) {
1600
+ return true;
1601
+ }
1602
+ this.currentUsage = next;
1603
+ for (const handler of this.usageHandlers) {
1604
+ try {
1605
+ handler(next);
1606
+ } catch {
1607
+ }
1608
+ }
1609
+ return true;
1610
+ }
1289
1611
  // Update the cached agent command list, fire persist handlers, and
1290
1612
  // broadcast the merged list to attached clients. Idempotent on a
1291
1613
  // structurally identical list so we don't churn meta.json on noisy
@@ -1316,6 +1638,9 @@ var Session = class {
1316
1638
  onModeChange(handler) {
1317
1639
  this.modeHandlers.push(handler);
1318
1640
  }
1641
+ onUsageChange(handler) {
1642
+ this.usageHandlers.push(handler);
1643
+ }
1319
1644
  // Returns a freshly merged command list (hydra ∪ agent) for callers
1320
1645
  // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1321
1646
  // assembling the attach response.
@@ -1378,8 +1703,8 @@ var Session = class {
1378
1703
  switch (verb) {
1379
1704
  case "title":
1380
1705
  return this.runTitleCommand(arg);
1381
- case "switch":
1382
- return this.runSwitchCommand(arg);
1706
+ case "agent":
1707
+ return this.runAgentCommand(arg);
1383
1708
  default: {
1384
1709
  const err = new Error(
1385
1710
  `no dispatcher for /hydra verb ${verb}`
@@ -1415,7 +1740,7 @@ var Session = class {
1415
1740
  }
1416
1741
  // Send a prompt to the underlying agent and capture its reply chunks
1417
1742
  // privately (no fan-out to clients, no recording into history). Used
1418
- // by /hydra title's regen path and /hydra switch's transcript-injection
1743
+ // by /hydra title's regen path and /hydra agent's transcript-injection
1419
1744
  // path. Returns the joined agent_message_chunk text.
1420
1745
  async runInternalPrompt(text) {
1421
1746
  if (this.internalPromptCapture) {
@@ -1437,10 +1762,10 @@ var Session = class {
1437
1762
  // record. Spawns the new agent first so a failure leaves the old one
1438
1763
  // intact; then injects a synthesized transcript so the new agent has
1439
1764
  // context for the next turn.
1440
- runSwitchCommand(newAgentId) {
1765
+ runAgentCommand(newAgentId) {
1441
1766
  if (!newAgentId) {
1442
1767
  throw withCode(
1443
- new Error("/hydra switch requires an agent id"),
1768
+ new Error("/hydra agent requires an agent id"),
1444
1769
  JsonRpcErrorCodes.InvalidParams
1445
1770
  );
1446
1771
  }
@@ -1574,7 +1899,7 @@ var Session = class {
1574
1899
  // on the first wake-up of a session whose meta.json has an empty
1575
1900
  // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1576
1901
  // 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
1902
+ // /hydra agent path so the agent isn't asked to respond to a user
1578
1903
  // turn before it has absorbed the imported transcript). Best-effort:
1579
1904
  // if the agent fails to absorb the transcript we still leave the
1580
1905
  // session usable — the user just continues without context.
@@ -1598,7 +1923,7 @@ var Session = class {
1598
1923
  // ones read it and relabel) and (b) drop a visible banner into the
1599
1924
  // transcript so users see the switch rather than just suddenly getting
1600
1925
  // answers from a different agent. Both updates carry synthetic=true
1601
- // so a future /hydra switch's transcript builder filters them out.
1926
+ // so a future /hydra agent's transcript builder filters them out.
1602
1927
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1603
1928
  this.recordAndBroadcast("session/update", {
1604
1929
  sessionId: this.sessionId,
@@ -1745,7 +2070,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1745
2070
  );
1746
2071
  }
1747
2072
  const clientParams = this.rewriteForClient(params);
1748
- return new Promise((resolve2, reject) => {
2073
+ return new Promise((resolve3, reject) => {
1749
2074
  let settled = false;
1750
2075
  const outbound = [];
1751
2076
  const entry = { addClient: sendTo };
@@ -1780,7 +2105,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1780
2105
  result
1781
2106
  }).catch(() => void 0);
1782
2107
  }
1783
- resolve2(result);
2108
+ resolve3(result);
1784
2109
  });
1785
2110
  }).catch((err) => {
1786
2111
  settle(() => reject(err));
@@ -1792,16 +2117,16 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1792
2117
  });
1793
2118
  }
1794
2119
  async enqueuePrompt(task) {
1795
- return new Promise((resolve2, reject) => {
1796
- const run = async () => {
2120
+ return new Promise((resolve3, reject) => {
2121
+ const run2 = async () => {
1797
2122
  try {
1798
2123
  const result = await task();
1799
- resolve2(result);
2124
+ resolve3(result);
1800
2125
  } catch (err) {
1801
2126
  reject(err);
1802
2127
  }
1803
2128
  };
1804
- this.promptQueue.push(run);
2129
+ this.promptQueue.push(run2);
1805
2130
  void this.drainQueue();
1806
2131
  });
1807
2132
  }
@@ -1830,7 +2155,8 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1830
2155
  "session_info_update",
1831
2156
  "current_model_update",
1832
2157
  "current_mode_update",
1833
- "available_commands_update"
2158
+ "available_commands_update",
2159
+ "usage_update"
1834
2160
  ]);
1835
2161
  function isStateUpdate(method, params) {
1836
2162
  if (method !== "session/update") {
@@ -1915,8 +2241,8 @@ function firstLine(text, max) {
1915
2241
  }
1916
2242
 
1917
2243
  // src/core/session-store.ts
1918
- import * as fs3 from "fs/promises";
1919
- import * as path2 from "path";
2244
+ import * as fs4 from "fs/promises";
2245
+ import * as path3 from "path";
1920
2246
  import { customAlphabet as customAlphabet2 } from "nanoid";
1921
2247
  import { z as z4 } from "zod";
1922
2248
  var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -1929,6 +2255,12 @@ var PersistedAgentCommand = z4.object({
1929
2255
  name: z4.string(),
1930
2256
  description: z4.string().optional()
1931
2257
  });
2258
+ var PersistedUsage = z4.object({
2259
+ used: z4.number().optional(),
2260
+ size: z4.number().optional(),
2261
+ costAmount: z4.number().optional(),
2262
+ costCurrency: z4.string().optional()
2263
+ });
1932
2264
  var SessionRecord = z4.object({
1933
2265
  version: z4.literal(1),
1934
2266
  sessionId: z4.string(),
@@ -1956,6 +2288,7 @@ var SessionRecord = z4.object({
1956
2288
  // replay of a snapshot-shaped notification.
1957
2289
  currentModel: z4.string().optional(),
1958
2290
  currentMode: z4.string().optional(),
2291
+ currentUsage: PersistedUsage.optional(),
1959
2292
  agentCommands: z4.array(PersistedAgentCommand).optional(),
1960
2293
  createdAt: z4.string(),
1961
2294
  updatedAt: z4.string()
@@ -1969,9 +2302,9 @@ function assertSafeId(id) {
1969
2302
  var SessionStore = class {
1970
2303
  async write(record) {
1971
2304
  assertSafeId(record.sessionId);
1972
- await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2305
+ await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
1973
2306
  const full = { version: 1, ...record };
1974
- await fs3.writeFile(
2307
+ await fs4.writeFile(
1975
2308
  paths.sessionFile(record.sessionId),
1976
2309
  JSON.stringify(full, null, 2) + "\n",
1977
2310
  { encoding: "utf8", mode: 384 }
@@ -1983,7 +2316,7 @@ var SessionStore = class {
1983
2316
  }
1984
2317
  let raw;
1985
2318
  try {
1986
- raw = await fs3.readFile(paths.sessionFile(sessionId), "utf8");
2319
+ raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
1987
2320
  } catch (err) {
1988
2321
  const e = err;
1989
2322
  if (e.code === "ENOENT") {
@@ -2002,7 +2335,7 @@ var SessionStore = class {
2002
2335
  return;
2003
2336
  }
2004
2337
  try {
2005
- await fs3.unlink(paths.sessionFile(sessionId));
2338
+ await fs4.unlink(paths.sessionFile(sessionId));
2006
2339
  } catch (err) {
2007
2340
  const e = err;
2008
2341
  if (e.code !== "ENOENT") {
@@ -2010,7 +2343,7 @@ var SessionStore = class {
2010
2343
  }
2011
2344
  }
2012
2345
  try {
2013
- await fs3.rmdir(paths.sessionDir(sessionId));
2346
+ await fs4.rmdir(paths.sessionDir(sessionId));
2014
2347
  } catch (err) {
2015
2348
  const e = err;
2016
2349
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -2040,7 +2373,7 @@ var SessionStore = class {
2040
2373
  async list() {
2041
2374
  let entries;
2042
2375
  try {
2043
- entries = await fs3.readdir(paths.sessionsDir());
2376
+ entries = await fs4.readdir(paths.sessionsDir());
2044
2377
  } catch (err) {
2045
2378
  const e = err;
2046
2379
  if (e.code === "ENOENT") {
@@ -2071,6 +2404,7 @@ function recordFromMemorySession(args) {
2071
2404
  agentArgs: args.agentArgs,
2072
2405
  currentModel: args.currentModel,
2073
2406
  currentMode: args.currentMode,
2407
+ currentUsage: args.currentUsage,
2074
2408
  agentCommands: args.agentCommands,
2075
2409
  createdAt: args.createdAt ?? now,
2076
2410
  updatedAt: args.updatedAt ?? now
@@ -2078,7 +2412,7 @@ function recordFromMemorySession(args) {
2078
2412
  }
2079
2413
 
2080
2414
  // src/core/history-store.ts
2081
- import * as fs4 from "fs/promises";
2415
+ import * as fs5 from "fs/promises";
2082
2416
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
2083
2417
  var MAX_ENTRIES = 1e3;
2084
2418
  var HistoryStore = class {
@@ -2091,9 +2425,9 @@ var HistoryStore = class {
2091
2425
  return;
2092
2426
  }
2093
2427
  return this.enqueue(sessionId, async () => {
2094
- await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
2428
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
2095
2429
  const line = JSON.stringify(entry) + "\n";
2096
- await fs4.appendFile(paths.historyFile(sessionId), line, {
2430
+ await fs5.appendFile(paths.historyFile(sessionId), line, {
2097
2431
  encoding: "utf8",
2098
2432
  mode: 384
2099
2433
  });
@@ -2104,9 +2438,9 @@ var HistoryStore = class {
2104
2438
  return;
2105
2439
  }
2106
2440
  return this.enqueue(sessionId, async () => {
2107
- await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
2441
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
2108
2442
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
2109
- await fs4.writeFile(paths.historyFile(sessionId), body, {
2443
+ await fs5.writeFile(paths.historyFile(sessionId), body, {
2110
2444
  encoding: "utf8",
2111
2445
  mode: 384
2112
2446
  });
@@ -2123,7 +2457,7 @@ var HistoryStore = class {
2123
2457
  return this.enqueue(sessionId, async () => {
2124
2458
  let raw;
2125
2459
  try {
2126
- raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2460
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
2127
2461
  } catch (err) {
2128
2462
  const e = err;
2129
2463
  if (e.code === "ENOENT") {
@@ -2136,7 +2470,7 @@ var HistoryStore = class {
2136
2470
  return;
2137
2471
  }
2138
2472
  const trimmed = lines.slice(-maxEntries);
2139
- await fs4.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
2473
+ await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
2140
2474
  encoding: "utf8",
2141
2475
  mode: 384
2142
2476
  });
@@ -2152,7 +2486,7 @@ var HistoryStore = class {
2152
2486
  }
2153
2487
  let raw;
2154
2488
  try {
2155
- raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2489
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
2156
2490
  } catch (err) {
2157
2491
  const e = err;
2158
2492
  if (e.code === "ENOENT") {
@@ -2198,7 +2532,7 @@ var HistoryStore = class {
2198
2532
  }
2199
2533
  return this.enqueue(sessionId, async () => {
2200
2534
  try {
2201
- await fs4.unlink(paths.historyFile(sessionId));
2535
+ await fs5.unlink(paths.historyFile(sessionId));
2202
2536
  } catch (err) {
2203
2537
  const e = err;
2204
2538
  if (e.code !== "ENOENT") {
@@ -2206,7 +2540,7 @@ var HistoryStore = class {
2206
2540
  }
2207
2541
  }
2208
2542
  try {
2209
- await fs4.rmdir(paths.sessionDir(sessionId));
2543
+ await fs5.rmdir(paths.sessionDir(sessionId));
2210
2544
  } catch (err) {
2211
2545
  const e = err;
2212
2546
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -2230,12 +2564,12 @@ var HistoryStore = class {
2230
2564
  };
2231
2565
 
2232
2566
  // src/tui/history.ts
2233
- import { promises as fs5 } from "fs";
2234
- import * as path3 from "path";
2567
+ import { promises as fs6 } from "fs";
2568
+ import * as path4 from "path";
2235
2569
  async function saveHistory(file, history) {
2236
- await fs5.mkdir(path3.dirname(file), { recursive: true });
2570
+ await fs6.mkdir(path4.dirname(file), { recursive: true });
2237
2571
  const lines = history.map((entry) => JSON.stringify(entry));
2238
- await fs5.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2572
+ await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2239
2573
  }
2240
2574
 
2241
2575
  // src/core/session-manager.ts
@@ -2248,6 +2582,7 @@ var SessionManager = class {
2248
2582
  this.store = store ?? new SessionStore();
2249
2583
  this.histories = new HistoryStore();
2250
2584
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
2585
+ this.defaultModels = options.defaultModels ?? {};
2251
2586
  }
2252
2587
  registry;
2253
2588
  sessions = /* @__PURE__ */ new Map();
@@ -2256,6 +2591,7 @@ var SessionManager = class {
2256
2591
  store;
2257
2592
  histories;
2258
2593
  idleTimeoutMs;
2594
+ defaultModels;
2259
2595
  // Serialize meta.json read-modify-write operations per session id so
2260
2596
  // concurrent snapshot updates (e.g. an agent emitting model + mode
2261
2597
  // back-to-back) don't lose writes via interleaved reads.
@@ -2265,7 +2601,8 @@ var SessionManager = class {
2265
2601
  agentId: params.agentId,
2266
2602
  cwd: params.cwd,
2267
2603
  agentArgs: params.agentArgs,
2268
- mcpServers: params.mcpServers
2604
+ mcpServers: params.mcpServers,
2605
+ model: params.model
2269
2606
  });
2270
2607
  const session = new Session({
2271
2608
  cwd: params.cwd,
@@ -2277,7 +2614,8 @@ var SessionManager = class {
2277
2614
  agentArgs: params.agentArgs,
2278
2615
  idleTimeoutMs: this.idleTimeoutMs,
2279
2616
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2280
- historyStore: this.histories
2617
+ historyStore: this.histories,
2618
+ currentModel: fresh.initialModel
2281
2619
  });
2282
2620
  await this.attachManagerHooks(session);
2283
2621
  return session;
@@ -2322,7 +2660,7 @@ var SessionManager = class {
2322
2660
  if (params.upstreamSessionId === "") {
2323
2661
  return this.doResurrectFromImport(params);
2324
2662
  }
2325
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
2663
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
2326
2664
  const agent = this.spawner({
2327
2665
  agentId: params.agentId,
2328
2666
  cwd: params.cwd,
@@ -2335,11 +2673,14 @@ var SessionManager = class {
2335
2673
  });
2336
2674
  let loadResult;
2337
2675
  try {
2338
- loadResult = await agent.connection.request("session/load", {
2339
- sessionId: params.upstreamSessionId,
2340
- cwd: params.cwd,
2341
- mcpServers: []
2342
- });
2676
+ loadResult = await agent.connection.request(
2677
+ "session/load",
2678
+ {
2679
+ sessionId: params.upstreamSessionId,
2680
+ cwd: params.cwd,
2681
+ mcpServers: []
2682
+ }
2683
+ );
2343
2684
  } catch (err) {
2344
2685
  await agent.kill().catch(() => void 0);
2345
2686
  throw new Error(
@@ -2358,8 +2699,13 @@ var SessionManager = class {
2358
2699
  idleTimeoutMs: this.idleTimeoutMs,
2359
2700
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2360
2701
  historyStore: this.histories,
2361
- currentModel: params.currentModel,
2702
+ // Prefer what we previously stored from a current_model_update; if
2703
+ // we never captured one (e.g. old opencode sessions on disk before
2704
+ // this fix), fall back to the model the agent ships in its
2705
+ // session/load response body.
2706
+ currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
2362
2707
  currentMode: params.currentMode,
2708
+ currentUsage: params.currentUsage,
2363
2709
  agentCommands: params.agentCommands,
2364
2710
  // Only gate the first-prompt title heuristic when we actually have
2365
2711
  // a title to preserve. A title-less session (lost to a write race
@@ -2397,8 +2743,11 @@ var SessionManager = class {
2397
2743
  idleTimeoutMs: this.idleTimeoutMs,
2398
2744
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2399
2745
  historyStore: this.histories,
2400
- currentModel: params.currentModel,
2746
+ // Prefer the stored value (set by a previous current_model_update);
2747
+ // fall back to whatever the agent ships in its session/new response.
2748
+ currentModel: params.currentModel ?? fresh.initialModel,
2401
2749
  currentMode: params.currentMode,
2750
+ currentUsage: params.currentUsage,
2402
2751
  agentCommands: params.agentCommands,
2403
2752
  firstPromptSeeded: !!params.title,
2404
2753
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
@@ -2408,7 +2757,7 @@ var SessionManager = class {
2408
2757
  return session;
2409
2758
  }
2410
2759
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
2411
- // → session/new. Shared by create() and the /hydra switch path so both
2760
+ // → session/new. Shared by create() and the /hydra agent path so both
2412
2761
  // go through the same env / capabilities / error-handling.
2413
2762
  async bootstrapAgent(params) {
2414
2763
  const agentDef = await this.registry.getAgent(params.agentId);
@@ -2419,7 +2768,7 @@ var SessionManager = class {
2419
2768
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
2420
2769
  throw err;
2421
2770
  }
2422
- const plan = planSpawn(agentDef, params.agentArgs ?? []);
2771
+ const plan = await planSpawn(agentDef, params.agentArgs ?? []);
2423
2772
  const agent = this.spawner({
2424
2773
  agentId: params.agentId,
2425
2774
  cwd: params.cwd,
@@ -2431,14 +2780,36 @@ var SessionManager = class {
2431
2780
  clientCapabilities: {},
2432
2781
  clientInfo: { name: "hydra", version: "0.1.0" }
2433
2782
  });
2434
- const newResult = await agent.connection.request("session/new", {
2435
- cwd: params.cwd,
2436
- mcpServers: params.mcpServers ?? []
2437
- });
2783
+ const newResult = await agent.connection.request(
2784
+ "session/new",
2785
+ {
2786
+ cwd: params.cwd,
2787
+ mcpServers: params.mcpServers ?? []
2788
+ }
2789
+ );
2790
+ const sessionIdRaw = newResult.sessionId;
2791
+ if (typeof sessionIdRaw !== "string") {
2792
+ throw new Error(
2793
+ `agent ${params.agentId} returned a non-string sessionId from session/new`
2794
+ );
2795
+ }
2796
+ let initialModel = extractInitialModel(newResult);
2797
+ const desired = params.model ?? this.defaultModels[params.agentId];
2798
+ if (desired && desired !== initialModel) {
2799
+ try {
2800
+ await agent.connection.request("session/set_model", {
2801
+ sessionId: sessionIdRaw,
2802
+ modelId: desired
2803
+ });
2804
+ initialModel = desired;
2805
+ } catch {
2806
+ }
2807
+ }
2438
2808
  return {
2439
2809
  agent,
2440
- upstreamSessionId: newResult.sessionId,
2441
- agentMeta: newResult._meta
2810
+ upstreamSessionId: sessionIdRaw,
2811
+ agentMeta: newResult._meta,
2812
+ initialModel
2442
2813
  };
2443
2814
  } catch (err) {
2444
2815
  await agent.kill().catch(() => void 0);
@@ -2449,7 +2820,7 @@ var SessionManager = class {
2449
2820
  // bookkeeping. Called from both create() and resurrect() so the same
2450
2821
  // session record + lifecycle handlers are wired regardless of origin.
2451
2822
  // Returns once the initial disk record is written — callers should
2452
- // await so a subsequent /hydra switch's persistAgentChange (which
2823
+ // await so a subsequent /hydra agent's persistAgentChange (which
2453
2824
  // does read-then-write) finds the file in place.
2454
2825
  async attachManagerHooks(session) {
2455
2826
  session.onClose(({ deleteRecord }) => {
@@ -2477,6 +2848,11 @@ var SessionManager = class {
2477
2848
  () => void 0
2478
2849
  );
2479
2850
  });
2851
+ session.onUsageChange((usage) => {
2852
+ void this.persistSnapshot(session.sessionId, {
2853
+ currentUsage: usageSnapshotToPersisted(usage)
2854
+ }).catch(() => void 0);
2855
+ });
2480
2856
  session.onAgentCommandsChange((commands) => {
2481
2857
  void this.persistSnapshot(session.sessionId, {
2482
2858
  agentCommands: commands.map((c) => ({
@@ -2525,6 +2901,7 @@ var SessionManager = class {
2525
2901
  agentArgs: record.agentArgs,
2526
2902
  currentModel: record.currentModel,
2527
2903
  currentMode: record.currentMode,
2904
+ currentUsage: persistedUsageToSnapshot(record.currentUsage),
2528
2905
  agentCommands: record.agentCommands,
2529
2906
  createdAt: record.createdAt
2530
2907
  };
@@ -2593,6 +2970,8 @@ var SessionManager = class {
2593
2970
  cwd: session.cwd,
2594
2971
  title: session.title,
2595
2972
  agentId: session.agentId,
2973
+ currentModel: session.currentModel,
2974
+ currentUsage: session.currentUsage,
2596
2975
  updatedAt: used,
2597
2976
  attachedClients: session.attachedCount,
2598
2977
  status: "live"
@@ -2613,6 +2992,8 @@ var SessionManager = class {
2613
2992
  cwd: r.cwd,
2614
2993
  title: r.title,
2615
2994
  agentId: r.agentId,
2995
+ currentModel: r.currentModel,
2996
+ currentUsage: r.currentUsage,
2616
2997
  updatedAt: used,
2617
2998
  attachedClients: 0,
2618
2999
  status: "cold"
@@ -2720,6 +3101,7 @@ var SessionManager = class {
2720
3101
  title: args.bundle.session.title,
2721
3102
  currentModel: args.bundle.session.currentModel,
2722
3103
  currentMode: args.bundle.session.currentMode,
3104
+ currentUsage: args.bundle.session.currentUsage,
2723
3105
  agentCommands: args.bundle.session.agentCommands,
2724
3106
  createdAt: args.preservedCreatedAt ?? now,
2725
3107
  updatedAt: now
@@ -2755,7 +3137,7 @@ var SessionManager = class {
2755
3137
  });
2756
3138
  });
2757
3139
  }
2758
- // Persist an agent swap from /hydra switch. The on-disk record's
3140
+ // Persist an agent swap from /hydra agent. The on-disk record's
2759
3141
  // agentId + upstreamSessionId both rotate so a daemon restart (and
2760
3142
  // later resurrect) brings the session back up on the agent the user
2761
3143
  // most recently switched to, not the one it was originally created on.
@@ -2787,6 +3169,7 @@ var SessionManager = class {
2787
3169
  ...record,
2788
3170
  ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
2789
3171
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
3172
+ ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
2790
3173
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
2791
3174
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2792
3175
  });
@@ -2841,13 +3224,73 @@ function mergeForPersistence(session, existing) {
2841
3224
  agentArgs: session.agentArgs,
2842
3225
  currentModel: session.currentModel ?? existing?.currentModel,
2843
3226
  currentMode: session.currentMode ?? existing?.currentMode,
3227
+ currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
2844
3228
  agentCommands,
2845
3229
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
2846
3230
  });
2847
3231
  }
3232
+ function usageSnapshotToPersisted(usage) {
3233
+ if (!usage) {
3234
+ return void 0;
3235
+ }
3236
+ const out = {};
3237
+ if (usage.used !== void 0) {
3238
+ out.used = usage.used;
3239
+ }
3240
+ if (usage.size !== void 0) {
3241
+ out.size = usage.size;
3242
+ }
3243
+ if (usage.costAmount !== void 0) {
3244
+ out.costAmount = usage.costAmount;
3245
+ }
3246
+ if (usage.costCurrency !== void 0) {
3247
+ out.costCurrency = usage.costCurrency;
3248
+ }
3249
+ return Object.keys(out).length > 0 ? out : void 0;
3250
+ }
3251
+ function persistedUsageToSnapshot(usage) {
3252
+ return usage ? { ...usage } : void 0;
3253
+ }
3254
+ function extractInitialModel(result) {
3255
+ const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
3256
+ if (direct) {
3257
+ return direct;
3258
+ }
3259
+ const models = result.models;
3260
+ if (models && typeof models === "object" && !Array.isArray(models)) {
3261
+ const m = asString(models.currentModelId) ?? asString(models.currentModel);
3262
+ if (m) {
3263
+ return m;
3264
+ }
3265
+ }
3266
+ const meta = result._meta;
3267
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
3268
+ for (const [key, value] of Object.entries(
3269
+ meta
3270
+ )) {
3271
+ if (key === "hydra-acp") {
3272
+ continue;
3273
+ }
3274
+ if (value && typeof value === "object" && !Array.isArray(value)) {
3275
+ const m = asString(value.modelId) ?? asString(value.model) ?? asString(value.currentModelId);
3276
+ if (m) {
3277
+ return m;
3278
+ }
3279
+ }
3280
+ }
3281
+ }
3282
+ return void 0;
3283
+ }
3284
+ function asString(value) {
3285
+ if (typeof value !== "string") {
3286
+ return void 0;
3287
+ }
3288
+ const trimmed = value.trim();
3289
+ return trimmed.length > 0 ? trimmed : void 0;
3290
+ }
2848
3291
  async function loadPromptHistorySafely(sessionId) {
2849
3292
  try {
2850
- const raw = await fs6.readFile(paths.tuiHistoryFile(sessionId), "utf8");
3293
+ const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
2851
3294
  const out = [];
2852
3295
  for (const line of raw.split("\n")) {
2853
3296
  if (line.length === 0) {
@@ -2868,7 +3311,7 @@ async function loadPromptHistorySafely(sessionId) {
2868
3311
  }
2869
3312
  async function historyMtimeIso(sessionId) {
2870
3313
  try {
2871
- const st = await fs6.stat(paths.historyFile(sessionId));
3314
+ const st = await fs7.stat(paths.historyFile(sessionId));
2872
3315
  return new Date(st.mtimeMs).toISOString();
2873
3316
  } catch {
2874
3317
  return void 0;
@@ -2876,10 +3319,10 @@ async function historyMtimeIso(sessionId) {
2876
3319
  }
2877
3320
 
2878
3321
  // 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";
3322
+ import { spawn as spawn3 } from "child_process";
3323
+ import * as fs8 from "fs";
3324
+ import * as fsp2 from "fs/promises";
3325
+ import * as path5 from "path";
2883
3326
  var RESTART_BASE_MS = 1e3;
2884
3327
  var RESTART_CAP_MS = 6e4;
2885
3328
  var STOP_GRACE_MS = 3e3;
@@ -2900,7 +3343,7 @@ var ExtensionManager = class {
2900
3343
  if (!this.context) {
2901
3344
  throw new Error("ExtensionManager: setContext must be called before start");
2902
3345
  }
2903
- await fsp.mkdir(paths.extensionsDir(), { recursive: true });
3346
+ await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
2904
3347
  await this.reapOrphans();
2905
3348
  for (const entry of this.entries.values()) {
2906
3349
  if (!entry.config.enabled) {
@@ -2926,9 +3369,9 @@ var ExtensionManager = class {
2926
3369
  } catch {
2927
3370
  }
2928
3371
  tasks.push(
2929
- new Promise((resolve2) => {
3372
+ new Promise((resolve3) => {
2930
3373
  if (child.exitCode !== null || child.signalCode !== null) {
2931
- resolve2();
3374
+ resolve3();
2932
3375
  return;
2933
3376
  }
2934
3377
  const timer = setTimeout(() => {
@@ -2936,11 +3379,11 @@ var ExtensionManager = class {
2936
3379
  child.kill("SIGKILL");
2937
3380
  } catch {
2938
3381
  }
2939
- resolve2();
3382
+ resolve3();
2940
3383
  }, STOP_GRACE_MS);
2941
3384
  child.on("exit", () => {
2942
3385
  clearTimeout(timer);
2943
- resolve2();
3386
+ resolve3();
2944
3387
  });
2945
3388
  })
2946
3389
  );
@@ -3048,8 +3491,8 @@ var ExtensionManager = class {
3048
3491
  if (child.exitCode !== null || child.signalCode !== null) {
3049
3492
  return;
3050
3493
  }
3051
- const exited = new Promise((resolve2) => {
3052
- entry.exitWaiters.push(resolve2);
3494
+ const exited = new Promise((resolve3) => {
3495
+ entry.exitWaiters.push(resolve3);
3053
3496
  });
3054
3497
  try {
3055
3498
  child.kill("SIGTERM");
@@ -3109,7 +3552,7 @@ var ExtensionManager = class {
3109
3552
  async reapOrphans() {
3110
3553
  let entries;
3111
3554
  try {
3112
- entries = await fsp.readdir(paths.extensionsDir());
3555
+ entries = await fsp2.readdir(paths.extensionsDir());
3113
3556
  } catch (err) {
3114
3557
  const e = err;
3115
3558
  if (e.code === "ENOENT") {
@@ -3121,10 +3564,10 @@ var ExtensionManager = class {
3121
3564
  if (!entry.endsWith(".pid")) {
3122
3565
  continue;
3123
3566
  }
3124
- const pidPath = path4.join(paths.extensionsDir(), entry);
3567
+ const pidPath = path5.join(paths.extensionsDir(), entry);
3125
3568
  let pid;
3126
3569
  try {
3127
- const raw = await fsp.readFile(pidPath, "utf8");
3570
+ const raw = await fsp2.readFile(pidPath, "utf8");
3128
3571
  const parsed = Number.parseInt(raw.trim(), 10);
3129
3572
  if (Number.isInteger(parsed) && parsed > 0) {
3130
3573
  pid = parsed;
@@ -3147,7 +3590,7 @@ var ExtensionManager = class {
3147
3590
  }
3148
3591
  }
3149
3592
  }
3150
- await fsp.unlink(pidPath).catch(() => void 0);
3593
+ await fsp2.unlink(pidPath).catch(() => void 0);
3151
3594
  }
3152
3595
  }
3153
3596
  spawn(entry, attempt) {
@@ -3160,7 +3603,7 @@ var ExtensionManager = class {
3160
3603
  }
3161
3604
  const ext = entry.config;
3162
3605
  const command = ext.command.length > 0 ? ext.command : [ext.name];
3163
- const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
3606
+ const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
3164
3607
  flags: "a"
3165
3608
  });
3166
3609
  logStream.write(
@@ -3188,7 +3631,7 @@ var ExtensionManager = class {
3188
3631
  const args = [...baseArgs, ...ext.args];
3189
3632
  let child;
3190
3633
  try {
3191
- child = spawn2(cmd, args, {
3634
+ child = spawn3(cmd, args, {
3192
3635
  env,
3193
3636
  stdio: ["ignore", "pipe", "pipe"],
3194
3637
  detached: false
@@ -3210,7 +3653,7 @@ var ExtensionManager = class {
3210
3653
  }
3211
3654
  if (typeof child.pid === "number") {
3212
3655
  try {
3213
- fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3656
+ fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3214
3657
  `, {
3215
3658
  encoding: "utf8",
3216
3659
  mode: 384
@@ -3235,7 +3678,7 @@ var ExtensionManager = class {
3235
3678
  });
3236
3679
  child.on("exit", (code, signal) => {
3237
3680
  try {
3238
- fs7.unlinkSync(paths.extensionPidFile(ext.name));
3681
+ fs8.unlinkSync(paths.extensionPidFile(ext.name));
3239
3682
  } catch {
3240
3683
  }
3241
3684
  logStream.write(
@@ -3246,8 +3689,8 @@ var ExtensionManager = class {
3246
3689
  entry.pid = void 0;
3247
3690
  entry.lastExitCode = typeof code === "number" ? code : void 0;
3248
3691
  const waiters = entry.exitWaiters.splice(0);
3249
- for (const resolve2 of waiters) {
3250
- resolve2();
3692
+ for (const resolve3 of waiters) {
3693
+ resolve3();
3251
3694
  }
3252
3695
  if (this.stopping || entry.manuallyStopped) {
3253
3696
  try {
@@ -3365,6 +3808,7 @@ var BundleSession = z5.object({
3365
3808
  title: z5.string().optional(),
3366
3809
  currentModel: z5.string().optional(),
3367
3810
  currentMode: z5.string().optional(),
3811
+ currentUsage: PersistedUsage.optional(),
3368
3812
  agentCommands: z5.array(PersistedAgentCommand).optional(),
3369
3813
  createdAt: z5.string(),
3370
3814
  updatedAt: z5.string()
@@ -3396,6 +3840,7 @@ function encodeBundle(params) {
3396
3840
  ...params.record.title !== void 0 ? { title: params.record.title } : {},
3397
3841
  ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
3398
3842
  ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
3843
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
3399
3844
  ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
3400
3845
  createdAt: params.record.createdAt,
3401
3846
  updatedAt: params.record.updatedAt
@@ -3794,13 +4239,13 @@ function wsToMessageStream(ws) {
3794
4239
  throw new Error("ws is closed");
3795
4240
  }
3796
4241
  const text = JSON.stringify(message);
3797
- await new Promise((resolve2, reject) => {
4242
+ await new Promise((resolve3, reject) => {
3798
4243
  ws.send(text, (err) => {
3799
4244
  if (err) {
3800
4245
  reject(err);
3801
4246
  return;
3802
4247
  }
3803
- resolve2();
4248
+ resolve3();
3804
4249
  });
3805
4250
  });
3806
4251
  },
@@ -3864,7 +4309,8 @@ function registerAcpWsEndpoint(app, deps) {
3864
4309
  agentId: params.agentId ?? deps.defaultAgent,
3865
4310
  mcpServers: params.mcpServers,
3866
4311
  title: hydraMeta.name,
3867
- agentArgs: hydraMeta.agentArgs
4312
+ agentArgs: hydraMeta.agentArgs,
4313
+ model: hydraMeta.model
3868
4314
  });
3869
4315
  const client = bindClientToSession(connection, session, state);
3870
4316
  await session.attach(client, "full");
@@ -4128,10 +4574,10 @@ var HYDRA_VERSION3 = "0.1.0";
4128
4574
  async function startDaemon(config) {
4129
4575
  ensureLoopbackOrTls(config);
4130
4576
  const httpsOptions = config.daemon.tls ? {
4131
- key: await fsp2.readFile(config.daemon.tls.key),
4132
- cert: await fsp2.readFile(config.daemon.tls.cert)
4577
+ key: await fsp3.readFile(config.daemon.tls.key),
4578
+ cert: await fsp3.readFile(config.daemon.tls.cert)
4133
4579
  } : void 0;
4134
- await fsp2.mkdir(paths.home(), { recursive: true });
4580
+ await fsp3.mkdir(paths.home(), { recursive: true });
4135
4581
  const { stream: logStream, fileStream } = await buildLogStream(
4136
4582
  config.daemon.logLevel
4137
4583
  );
@@ -4143,6 +4589,9 @@ async function startDaemon(config) {
4143
4589
  https: httpsOptions ?? null
4144
4590
  });
4145
4591
  await app.register(websocketPlugin);
4592
+ setBinaryInstallLogger((msg) => {
4593
+ app.log.info(msg);
4594
+ });
4146
4595
  const auth = bearerAuth({ config });
4147
4596
  app.addHook("onRequest", async (request, reply) => {
4148
4597
  if (request.routeOptions.config?.skipAuth) {
@@ -4155,7 +4604,8 @@ async function startDaemon(config) {
4155
4604
  });
4156
4605
  const registry = new Registry(config);
4157
4606
  const manager = new SessionManager(registry, void 0, void 0, {
4158
- idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
4607
+ idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
4608
+ defaultModels: config.defaultModels
4159
4609
  });
4160
4610
  const extensions = new ExtensionManager(extensionList(config));
4161
4611
  registerHealthRoutes(app, HYDRA_VERSION3);
@@ -4177,8 +4627,8 @@ async function startDaemon(config) {
4177
4627
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
4178
4628
  const address = app.server.address();
4179
4629
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
4180
- await fsp2.mkdir(paths.home(), { recursive: true });
4181
- await fsp2.writeFile(
4630
+ await fsp3.mkdir(paths.home(), { recursive: true });
4631
+ await fsp3.writeFile(
4182
4632
  paths.pidFile(),
4183
4633
  JSON.stringify({
4184
4634
  pid: process.pid,
@@ -4203,9 +4653,10 @@ async function startDaemon(config) {
4203
4653
  await extensions.stop();
4204
4654
  await manager.closeAll();
4205
4655
  await manager.flushMetaWrites();
4656
+ setBinaryInstallLogger(null);
4206
4657
  await app.close();
4207
4658
  try {
4208
- fs8.unlinkSync(paths.pidFile());
4659
+ fs9.unlinkSync(paths.pidFile());
4209
4660
  } catch {
4210
4661
  }
4211
4662
  try {