@hydra-acp/cli 0.1.61 → 0.1.63

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.
Files changed (5) hide show
  1. package/README.md +123 -107
  2. package/dist/cli.js +3634 -1107
  3. package/dist/index.d.ts +265 -25
  4. package/dist/index.js +2103 -641
  5. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/daemon/server.ts
2
- import * as fs16 from "fs";
2
+ import * as fs18 from "fs";
3
3
  import * as fsp7 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -26,6 +26,19 @@ import { z } from "zod";
26
26
  import * as path from "path";
27
27
  import * as os from "os";
28
28
  var ROOT_ENV = "HYDRA_ACP_HOME";
29
+ function shortenHomePath(p) {
30
+ const home = os.homedir();
31
+ if (!home) {
32
+ return p;
33
+ }
34
+ if (p === home) {
35
+ return "~";
36
+ }
37
+ if (p.startsWith(home + "/")) {
38
+ return "~" + p.slice(home.length);
39
+ }
40
+ return p;
41
+ }
29
42
  function hydraHome() {
30
43
  const override = process.env[ROOT_ENV];
31
44
  if (override && override.length > 0) {
@@ -82,12 +95,35 @@ var paths = {
82
95
  sessionDir: (id) => path.join(hydraHome(), "sessions", id),
83
96
  sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
84
97
  historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
98
+ // Content-addressed store for heavy tool payload (diff bodies, stdout)
99
+ // externalized out of history.jsonl. One file per unique blob, named by
100
+ // its sha256, so repeated identical content (e.g. an agent re-emitting
101
+ // the same full-file diff on every status tick) dedupes to one file.
102
+ toolsDir: (id) => path.join(hydraHome(), "sessions", id, "tools"),
103
+ toolBlobFile: (id, hash) => path.join(hydraHome(), "sessions", id, "tools", hash),
85
104
  // Persisted prompt queue for a session. ndjson, one record per
86
105
  // entry. Survives daemon restarts so queued prompts get a chance to
87
106
  // run rather than being silently lost. Entries are removed BEFORE
88
107
  // the agent invocation (see Session.drainQueue) so a crash mid-
89
108
  // generation doesn't double-run on restart.
90
109
  queueFile: (id) => path.join(hydraHome(), "sessions", id, "queue.ndjson"),
110
+ // Tombstones for sessions that were deleted locally but might still
111
+ // be reported by an agent's session/list at the next periodic sync.
112
+ // One file per (agentId, upstreamSessionId); existence is the source
113
+ // of truth, contents are a small JSON blob for diagnostics and the
114
+ // "agent advanced past our snapshot → resurrect" decision. Hidden
115
+ // under sessions/ because SessionStore.read() filters non-conforming
116
+ // dir names (the leading dot fails SESSION_ID_PATTERN) so the
117
+ // directory cohabits safely with real session directories.
118
+ tombstonesDir: () => path.join(hydraHome(), "sessions", ".tombstones"),
119
+ tombstoneAgentDir: (agentId) => path.join(hydraHome(), "sessions", ".tombstones", encodeURIComponent(agentId)),
120
+ tombstoneFile: (agentId, upstreamSessionId) => path.join(
121
+ hydraHome(),
122
+ "sessions",
123
+ ".tombstones",
124
+ encodeURIComponent(agentId),
125
+ encodeURIComponent(upstreamSessionId)
126
+ ),
91
127
  extensionsDir: () => path.join(hydraHome(), "extensions"),
92
128
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
93
129
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
@@ -267,7 +303,27 @@ var DaemonConfig = z.object({
267
303
  });
268
304
  var RegistryConfig = z.object({
269
305
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
270
- ttlHours: z.number().positive().default(24)
306
+ ttlHours: z.number().positive().default(24),
307
+ // When true, the daemon never re-fetches the registry over the network:
308
+ // it serves whatever is in the on-disk cache (~/.hydra-acp/registry.json)
309
+ // indefinitely, ignoring ttlHours. An escape hatch for when a bad registry
310
+ // push breaks an agent — pin to the last-known-good cache until upstream
311
+ // is fixed. `hydra registry refresh` still forces a one-off fetch.
312
+ pinned: z.boolean().default(false)
313
+ });
314
+ var LocalAgentConfig = z.object({
315
+ name: z.string().optional(),
316
+ description: z.string().optional(),
317
+ // Optional: defaults to the agent id (the config.agents key), mirroring
318
+ // how extensions default their command to the extension name. Set it
319
+ // when the executable differs from the id, or to point at an absolute
320
+ // path / wrapper script.
321
+ command: z.string().optional(),
322
+ args: z.array(z.string()).optional(),
323
+ env: z.record(z.string()).optional()
324
+ });
325
+ var AgentOverrideConfig = z.object({
326
+ packageSpec: z.string().optional()
271
327
  });
272
328
  var TuiConfig = z.object({
273
329
  // Minimum interval (ms) between full-screen repaints driven by content
@@ -313,6 +369,29 @@ var TuiConfig = z.object({
313
369
  // suppress them — the TUI hotkey ^T toggles this at runtime without
314
370
  // persisting back to config.
315
371
  showThoughts: z.boolean().default(true),
372
+ // How the terminal renders East-Asian "Ambiguous" width glyphs (em-dash
373
+ // —, smart quotes “ ”, ellipsis …, middle-dot ·). Most modern terminals
374
+ // draw them 1 col wide ("narrow"); CJK-locale / legacy setups draw them 2
375
+ // cols wide ("wide"). Defaults to "wide": counting ambiguous glyphs as 2
376
+ // cols never overflows the right margin (the worst case is wrapping a
377
+ // column early on narrow terminals, which is benign), whereas "narrow"
378
+ // bleeds past the margin on wide terminals. The thought gutter uses an
379
+ // ASCII marker, so this no longer affects marker alignment either way.
380
+ ambiguousWidth: z.enum(["narrow", "wide"]).default("wide"),
381
+ // How the TUI receives tool payload on attach/replay.
382
+ // "references" — the lean path (default): the daemon ships blob refs and
383
+ // the TUI fetches a diff/output body on demand when
384
+ // expanded, cutting replay size on tool-heavy sessions.
385
+ // Collapsed rows never fetch (they show a size hint), and
386
+ // old inline sessions/live turns are unaffected (no refs).
387
+ // "inline" — full content up front (the pre-externalization shape).
388
+ toolContent: z.enum(["inline", "references"]).default("references"),
389
+ // Unchanged context lines shown around each change in an expanded Edited
390
+ // diff. Some agents (e.g. pi) report edits as full-file old/new text via
391
+ // ACP "diff" content blocks; without hunking a 1-line edit would render
392
+ // the entire file. This bounds the context so only the changed region (±N
393
+ // lines) shows, with runs of unchanged lines collapsed to a marker.
394
+ diffContextLines: z.number().int().min(0).default(3),
316
395
  // Cap on entries kept in the cross-session global prompt-history file
317
396
  // (~/.hydra-acp/prompt-history). This is the ^P / ^R recall list
318
397
  // shared across all sessions; it's append-only on disk, so long-lived
@@ -385,7 +464,18 @@ var TransformerBody = z.object({
385
464
  });
386
465
  var HydraConfig = z.object({
387
466
  daemon: DaemonConfig.default({}),
388
- registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
467
+ registry: RegistryConfig.default({
468
+ url: REGISTRY_URL_DEFAULT,
469
+ ttlHours: 24,
470
+ pinned: false
471
+ }),
472
+ // User-defined agents that bypass the network registry. Keyed by agent
473
+ // id; each is spawned via its `command`/`args` directly. Shadow registry
474
+ // agents of the same id.
475
+ agents: z.record(z.string(), LocalAgentConfig).default({}),
476
+ // Per-agent pin overrides applied to registry agents (e.g. force a
477
+ // specific npm version of opencode). Keyed by agent id.
478
+ agentOverrides: z.record(z.string(), AgentOverrideConfig).default({}),
389
479
  defaultAgent: z.string().default("opencode"),
390
480
  // Optional per-agent default model id. When a brand-new agent process
391
481
  // is spawned (session/new path), hydra issues session/set_model with
@@ -417,6 +507,12 @@ var HydraConfig = z.object({
417
507
  // a literal string ("~", "~/dev", "$HOME/work") so the config file is
418
508
  // portable across machines; expanded via expandHome at use time.
419
509
  defaultCwd: z.string().default("~"),
510
+ // Gzip externalized tool-content blobs at rest (tools/<sha256>.gz).
511
+ // Default true — text diffs/output compress ~3.5x and decompression is
512
+ // lazy (only on diff expand in references mode). Set false to write plain
513
+ // blobs instead, as an escape hatch if gzip CPU is ever a problem; reads
514
+ // transparently handle both, so flipping it only affects new writes.
515
+ compressToolContent: z.boolean().default(true),
420
516
  // Cap on cold sessions shown in CLI `sessions` listing and the TUI
421
517
  // picker. Live sessions are always included; cold are sorted by
422
518
  // recency and truncated to this count. `--all` overrides in the CLI.
@@ -438,6 +534,9 @@ var HydraConfig = z.object({
438
534
  progressIndicator: true,
439
535
  defaultEnterAction: "amend",
440
536
  showThoughts: true,
537
+ ambiguousWidth: "wide",
538
+ toolContent: "references",
539
+ diffContextLines: 3,
441
540
  promptHistoryMaxEntries: 2e3,
442
541
  maxToolItems: 5,
443
542
  maxPlanItems: 5,
@@ -516,13 +615,113 @@ function expandHome(p) {
516
615
  return p;
517
616
  }
518
617
 
618
+ // src/core/tool-store.ts
619
+ import * as fs4 from "fs/promises";
620
+ import { createHash } from "crypto";
621
+ import { gzip as gzipCb, gunzip as gunzipCb } from "zlib";
622
+ import { promisify } from "util";
623
+ var gzip = promisify(gzipCb);
624
+ var gunzip = promisify(gunzipCb);
625
+ var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
626
+ var HASH_PATTERN = /^[a-f0-9]{64}$/;
627
+ var compressBlobs = true;
628
+ function setToolBlobCompression(enabled) {
629
+ compressBlobs = enabled;
630
+ }
631
+ function safe(sessionId, hash) {
632
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
633
+ return false;
634
+ }
635
+ return hash === void 0 || HASH_PATTERN.test(hash);
636
+ }
637
+ function gzPath(sessionId, hash) {
638
+ return `${paths.toolBlobFile(sessionId, hash)}.gz`;
639
+ }
640
+ async function putToolBlob(sessionId, text) {
641
+ if (!safe(sessionId)) {
642
+ return null;
643
+ }
644
+ const hash = createHash("sha256").update(text, "utf8").digest("hex");
645
+ const gzFile = gzPath(sessionId, hash);
646
+ const plainFile = paths.toolBlobFile(sessionId, hash);
647
+ for (const existing of [gzFile, plainFile]) {
648
+ try {
649
+ await fs4.access(existing);
650
+ return hash;
651
+ } catch {
652
+ }
653
+ }
654
+ await fs4.mkdir(paths.toolsDir(sessionId), { recursive: true });
655
+ const file = compressBlobs ? gzFile : plainFile;
656
+ const data = compressBlobs ? await gzip(Buffer.from(text, "utf8")) : Buffer.from(text, "utf8");
657
+ await fs4.writeFile(file, data, { mode: 384, flag: "wx" }).catch(async (err) => {
658
+ if (err.code !== "EEXIST") {
659
+ throw err;
660
+ }
661
+ });
662
+ return hash;
663
+ }
664
+ async function getToolBlob(sessionId, hash) {
665
+ if (!safe(sessionId, hash)) {
666
+ return null;
667
+ }
668
+ try {
669
+ const buf = await fs4.readFile(gzPath(sessionId, hash));
670
+ return (await gunzip(buf)).toString("utf8");
671
+ } catch {
672
+ }
673
+ try {
674
+ return await fs4.readFile(paths.toolBlobFile(sessionId, hash), "utf8");
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+ async function readToolBlobGz(sessionId, hash) {
680
+ if (!safe(sessionId, hash)) {
681
+ return null;
682
+ }
683
+ try {
684
+ return await fs4.readFile(gzPath(sessionId, hash));
685
+ } catch {
686
+ }
687
+ try {
688
+ const plain = await fs4.readFile(paths.toolBlobFile(sessionId, hash));
689
+ return await gzip(plain);
690
+ } catch {
691
+ return null;
692
+ }
693
+ }
694
+ async function writeToolBlobGz(sessionId, hash, gzBytes) {
695
+ if (!safe(sessionId, hash)) {
696
+ return;
697
+ }
698
+ const file = gzPath(sessionId, hash);
699
+ try {
700
+ await fs4.access(file);
701
+ return;
702
+ } catch {
703
+ }
704
+ await fs4.mkdir(paths.toolsDir(sessionId), { recursive: true });
705
+ await fs4.writeFile(file, gzBytes, { mode: 384, flag: "wx" }).catch((err) => {
706
+ if (err.code !== "EEXIST") {
707
+ throw err;
708
+ }
709
+ });
710
+ }
711
+ async function deleteToolBlobs(sessionId) {
712
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
713
+ return;
714
+ }
715
+ await fs4.rm(paths.toolsDir(sessionId), { recursive: true, force: true }).catch(() => void 0);
716
+ }
717
+
519
718
  // src/core/registry.ts
520
- import * as fs5 from "fs/promises";
719
+ import * as fs6 from "fs/promises";
521
720
  import * as path4 from "path";
522
721
  import { z as z2 } from "zod";
523
722
 
524
723
  // src/core/binary-install.ts
525
- import * as fs4 from "fs";
724
+ import * as fs5 from "fs";
526
725
  import * as fsp from "fs/promises";
527
726
  import * as path2 from "path";
528
727
  import { spawn } from "child_process";
@@ -655,7 +854,7 @@ async function downloadTo(args) {
655
854
  );
656
855
  }
657
856
  const total = Number(response.headers.get("content-length") ?? "0");
658
- const out = fs4.createWriteStream(dest);
857
+ const out = fs5.createWriteStream(dest);
659
858
  const nodeStream = Readable.fromWeb(response.body);
660
859
  safeEmit(args.onProgress, {
661
860
  phase: "download_start",
@@ -1062,10 +1261,16 @@ var UvxDistribution = z2.object({
1062
1261
  args: z2.array(z2.string()).optional(),
1063
1262
  env: z2.record(z2.string()).optional()
1064
1263
  });
1264
+ var ExecDistribution = z2.object({
1265
+ command: z2.string(),
1266
+ args: z2.array(z2.string()).optional(),
1267
+ env: z2.record(z2.string()).optional()
1268
+ });
1065
1269
  var Distribution = z2.object({
1066
1270
  npx: NpxDistribution.optional(),
1067
1271
  binary: BinaryDistribution.optional(),
1068
- uvx: UvxDistribution.optional()
1272
+ uvx: UvxDistribution.optional(),
1273
+ exec: ExecDistribution.optional()
1069
1274
  });
1070
1275
  var RegistryAgent = z2.object({
1071
1276
  id: z2.string(),
@@ -1093,11 +1298,11 @@ var Registry = class {
1093
1298
  options;
1094
1299
  cache;
1095
1300
  async load() {
1096
- if (this.cache && this.isFresh(this.cache.fetchedAt)) {
1301
+ if (this.cache && (this.isPinned() || this.isFresh(this.cache.fetchedAt))) {
1097
1302
  return this.cache.data;
1098
1303
  }
1099
1304
  const onDisk = await this.readDiskCache();
1100
- if (onDisk && this.isFresh(onDisk.fetchedAt)) {
1305
+ if (onDisk && (this.isPinned() || this.isFresh(onDisk.fetchedAt))) {
1101
1306
  this.cache = onDisk;
1102
1307
  return onDisk.data;
1103
1308
  }
@@ -1128,12 +1333,57 @@ var Registry = class {
1128
1333
  return this.cache?.fetchedAt;
1129
1334
  }
1130
1335
  async getAgent(id) {
1336
+ const local = this.localAgents().find((a) => a.id === id);
1337
+ if (local) {
1338
+ return local;
1339
+ }
1131
1340
  const doc = await this.load();
1132
1341
  const exact = doc.agents.find((a) => a.id === id);
1133
1342
  if (exact) {
1134
- return exact;
1343
+ return this.applyOverride(exact);
1344
+ }
1345
+ const byBasename = doc.agents.find((a) => npxPackageBasename(a) === id);
1346
+ return byBasename ? this.applyOverride(byBasename) : void 0;
1347
+ }
1348
+ // Synthesize RegistryAgent entries from config.agents. These carry an
1349
+ // `exec` distribution and a fixed "local" version key (no install dir).
1350
+ localAgents() {
1351
+ return Object.entries(this.config.agents ?? {}).map(([id, def]) => ({
1352
+ id,
1353
+ name: def.name ?? id,
1354
+ description: def.description,
1355
+ version: "local",
1356
+ distribution: {
1357
+ exec: {
1358
+ // Default the command to the agent id (like extensions default
1359
+ // theirs to the extension name) — resolved off PATH at spawn.
1360
+ command: def.command ?? id,
1361
+ args: def.args,
1362
+ env: def.env
1363
+ }
1364
+ }
1365
+ }));
1366
+ }
1367
+ // Apply a config.agentOverrides[id] pin to a registry agent: swap the
1368
+ // npx package spec and key the install dir on the pinned version so it
1369
+ // never collides with the floating "current" install. No-op when the
1370
+ // agent has no override or isn't npx-distributed.
1371
+ applyOverride(agent) {
1372
+ const override = this.config.agentOverrides?.[agent.id];
1373
+ if (!override?.packageSpec || !agent.distribution.npx) {
1374
+ return agent;
1135
1375
  }
1136
- return doc.agents.find((a) => npxPackageBasename(a) === id);
1376
+ return {
1377
+ ...agent,
1378
+ version: versionKeyFromSpec(override.packageSpec),
1379
+ distribution: {
1380
+ ...agent.distribution,
1381
+ npx: { ...agent.distribution.npx, package: override.packageSpec }
1382
+ }
1383
+ };
1384
+ }
1385
+ isPinned() {
1386
+ return this.config.registry?.pinned === true;
1137
1387
  }
1138
1388
  isFresh(fetchedAt) {
1139
1389
  const ageMs = Date.now() - fetchedAt;
@@ -1175,6 +1425,12 @@ var Registry = class {
1175
1425
  });
1176
1426
  }
1177
1427
  };
1428
+ function versionKeyFromSpec(spec) {
1429
+ const lastAt = spec.lastIndexOf("@");
1430
+ const version = lastAt > 0 ? spec.slice(lastAt + 1) : "";
1431
+ const sanitized = version.replace(/[^a-zA-Z0-9._-]/g, "_");
1432
+ return sanitized.length > 0 ? `pin-${sanitized}` : "pinned";
1433
+ }
1178
1434
  function npxPackageBasename(agent) {
1179
1435
  const pkg = agent.distribution.npx?.package;
1180
1436
  if (!pkg) {
@@ -1185,12 +1441,45 @@ function npxPackageBasename(agent) {
1185
1441
  const atIdx = afterSlash.lastIndexOf("@");
1186
1442
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
1187
1443
  }
1444
+ async function listAgents(registry) {
1445
+ const local = typeof registry.localAgents === "function" ? registry.localAgents() : [];
1446
+ let doc;
1447
+ try {
1448
+ doc = await registry.load();
1449
+ } catch (err) {
1450
+ if (local.length === 0) {
1451
+ throw err;
1452
+ }
1453
+ doc = { version: "local-only", agents: [] };
1454
+ }
1455
+ const localIds = new Set(local.map((a) => a.id));
1456
+ const merged = [...local, ...doc.agents.filter((a) => !localIds.has(a.id))];
1457
+ const agents = await Promise.all(
1458
+ merged.map(async (a) => ({
1459
+ id: a.id,
1460
+ name: a.name,
1461
+ version: a.version,
1462
+ description: a.description,
1463
+ distributions: Object.keys(a.distribution),
1464
+ installed: await agentInstallState(a),
1465
+ source: localIds.has(a.id) ? "local" : "registry"
1466
+ }))
1467
+ );
1468
+ return {
1469
+ version: doc.version,
1470
+ fetchedAt: registry.lastFetchedAt(),
1471
+ agents
1472
+ };
1473
+ }
1188
1474
  async function agentInstallState(agent) {
1189
1475
  const platformKey = currentPlatformKey();
1190
1476
  if (!platformKey) {
1191
1477
  return "no";
1192
1478
  }
1193
1479
  const version = agent.version ?? "current";
1480
+ if (agent.distribution.exec) {
1481
+ return "yes";
1482
+ }
1194
1483
  if (agent.distribution.binary) {
1195
1484
  const target = pickBinaryTarget(agent.distribution.binary, platformKey);
1196
1485
  if (target?.cmd) {
@@ -1219,7 +1508,7 @@ async function agentInstallState(agent) {
1219
1508
  }
1220
1509
  async function fileExists3(p) {
1221
1510
  try {
1222
- await fs5.access(p);
1511
+ await fs6.access(p);
1223
1512
  return true;
1224
1513
  } catch {
1225
1514
  return false;
@@ -1287,12 +1576,22 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
1287
1576
  version
1288
1577
  };
1289
1578
  }
1579
+ if (agent.distribution.exec) {
1580
+ const exec = agent.distribution.exec;
1581
+ const tail = callerArgs.length > 0 ? callerArgs : exec.args ?? [];
1582
+ return {
1583
+ command: exec.command,
1584
+ args: tail,
1585
+ env: exec.env ?? {},
1586
+ version
1587
+ };
1588
+ }
1290
1589
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
1291
1590
  }
1292
1591
 
1293
1592
  // src/core/agent-instance.ts
1294
1593
  import { spawn as spawn3 } from "child_process";
1295
- import * as fs6 from "fs";
1594
+ import * as fs7 from "fs";
1296
1595
  import * as path5 from "path";
1297
1596
 
1298
1597
  // src/acp/types.ts
@@ -1344,8 +1643,8 @@ var HistoryPolicy = z3.enum([
1344
1643
  ]);
1345
1644
  var SessionNewParams = z3.object({
1346
1645
  cwd: z3.string(),
1347
- agentId: z3.string().optional(),
1348
- mcpServers: z3.array(z3.unknown()).optional()
1646
+ mcpServers: z3.array(z3.unknown()).optional(),
1647
+ _meta: z3.record(z3.unknown()).optional()
1349
1648
  });
1350
1649
  var SessionResumeHints = z3.object({
1351
1650
  upstreamSessionId: z3.string(),
@@ -1373,23 +1672,10 @@ var SessionAttachParams = z3.object({
1373
1672
  name: z3.string(),
1374
1673
  version: z3.string().optional()
1375
1674
  }).optional(),
1376
- // When true, the connection observes the session but cannot mutate
1377
- // it: state-changing methods (session/prompt, session/cancel,
1378
- // session/set_model, etc.) are rejected with -32011, and attaching
1379
- // to a cold session does not resurrect or spawn an agent — just
1380
- // streams history from disk. Used by the TUI's view-only mode.
1381
- readonly: z3.boolean().optional(),
1382
- // Debug-only replay pacing. When "drip", the daemon skips chunk
1383
- // coalescing and re-emits each recorded session/update individually,
1384
- // spacing them by their original recordedAt deltas (scaled by
1385
- // dripSpeed, with a per-gap cap) so a session's streaming render can
1386
- // be reproduced at its real granularity for flicker investigation.
1387
- // Omitted/"instant" preserves the normal coalesced, as-fast-as-possible
1388
- // replay.
1389
- replayMode: z3.enum(["instant", "drip"]).optional(),
1390
- // Multiplier applied to original inter-entry gaps in drip mode. >1
1391
- // compresses time (faster), <1 stretches it. Defaults to 1.
1392
- dripSpeed: z3.number().positive().optional(),
1675
+ // Hydra-specific attach options (readonly, replayMode, dripSpeed) are
1676
+ // NOT top-level they ride under `_meta["hydra-acp"]` (read via
1677
+ // extractHydraMeta) so session/attach carries only RFD #533's own
1678
+ // fields at the top level.
1393
1679
  _meta: z3.record(z3.unknown()).optional()
1394
1680
  });
1395
1681
  var HYDRA_META_KEY = "hydra-acp";
@@ -1412,8 +1698,26 @@ function extractHydraMeta(meta) {
1412
1698
  if (typeof obj.cwd === "string") {
1413
1699
  out.cwd = obj.cwd;
1414
1700
  }
1415
- if (typeof obj.name === "string") {
1416
- out.name = obj.name;
1701
+ if (typeof obj.clientId === "string") {
1702
+ out.clientId = obj.clientId;
1703
+ }
1704
+ if (typeof obj.readonly === "boolean") {
1705
+ out.readonly = obj.readonly;
1706
+ }
1707
+ if (obj.replayMode === "instant" || obj.replayMode === "drip") {
1708
+ out.replayMode = obj.replayMode;
1709
+ }
1710
+ if (typeof obj.dripSpeed === "number" && obj.dripSpeed > 0) {
1711
+ out.dripSpeed = obj.dripSpeed;
1712
+ }
1713
+ if (obj.toolContent === "inline" || obj.toolContent === "references") {
1714
+ out.toolContent = obj.toolContent;
1715
+ }
1716
+ if (obj.detachStatus === "detached") {
1717
+ out.detachStatus = obj.detachStatus;
1718
+ }
1719
+ if (typeof obj.title === "string") {
1720
+ out.title = obj.title;
1417
1721
  }
1418
1722
  if (Array.isArray(obj.agentArgs) && obj.agentArgs.every((a) => typeof a === "string")) {
1419
1723
  out.agentArgs = obj.agentArgs;
@@ -1465,14 +1769,22 @@ function extractHydraMeta(meta) {
1465
1769
  out.availableCommands = cmds;
1466
1770
  }
1467
1771
  }
1468
- if (typeof obj.promptQueueing === "boolean") {
1469
- out.promptQueueing = obj.promptQueueing;
1772
+ if (obj.prompt && typeof obj.prompt === "object" && !Array.isArray(obj.prompt)) {
1773
+ const p = obj.prompt;
1774
+ const caps = {};
1775
+ if (typeof p.queueing === "boolean") caps.queueing = p.queueing;
1776
+ if (typeof p.cancelling === "boolean") caps.cancelling = p.cancelling;
1777
+ if (typeof p.updating === "boolean") caps.updating = p.updating;
1778
+ if (typeof p.amending === "boolean") caps.amending = p.amending;
1779
+ if (typeof p.pipelining === "boolean") caps.pipelining = p.pipelining;
1780
+ out.prompt = caps;
1470
1781
  }
1471
- if (typeof obj.promptCancelling === "boolean") {
1472
- out.promptCancelling = obj.promptCancelling;
1473
- }
1474
- if (typeof obj.promptUpdating === "boolean") {
1475
- out.promptUpdating = obj.promptUpdating;
1782
+ if (obj.agents && typeof obj.agents === "object" && !Array.isArray(obj.agents)) {
1783
+ const a = obj.agents;
1784
+ const caps = {};
1785
+ if (typeof a.list === "boolean") caps.list = a.list;
1786
+ if (typeof a.installProgress === "boolean") caps.installProgress = a.installProgress;
1787
+ out.agents = caps;
1476
1788
  }
1477
1789
  if (typeof obj.mcpStdin === "boolean") {
1478
1790
  out.mcpStdin = obj.mcpStdin;
@@ -1483,12 +1795,6 @@ function extractHydraMeta(meta) {
1483
1795
  if (typeof obj.ancillary === "boolean") {
1484
1796
  out.ancillary = obj.ancillary;
1485
1797
  }
1486
- if (typeof obj.promptAmending === "boolean") {
1487
- out.promptAmending = obj.promptAmending;
1488
- }
1489
- if (typeof obj.promptPipelining === "boolean") {
1490
- out.promptPipelining = obj.promptPipelining;
1491
- }
1492
1798
  if (Array.isArray(obj.queue)) {
1493
1799
  const entries = [];
1494
1800
  for (const raw of obj.queue) {
@@ -1561,6 +1867,43 @@ function extractHydraMeta(meta) {
1561
1867
  out.availableModels = models;
1562
1868
  }
1563
1869
  }
1870
+ if (obj.status === "live" || obj.status === "cold") {
1871
+ out.status = obj.status;
1872
+ }
1873
+ if (typeof obj.busy === "boolean") {
1874
+ out.busy = obj.busy;
1875
+ }
1876
+ if (typeof obj.awaitingInput === "boolean") {
1877
+ out.awaitingInput = obj.awaitingInput;
1878
+ }
1879
+ if (typeof obj.attachedClients === "number") {
1880
+ out.attachedClients = obj.attachedClients;
1881
+ }
1882
+ if (typeof obj.importedFromMachine === "string") {
1883
+ out.importedFromMachine = obj.importedFromMachine;
1884
+ }
1885
+ if (typeof obj.importedFromUpstreamSessionId === "string") {
1886
+ out.importedFromUpstreamSessionId = obj.importedFromUpstreamSessionId;
1887
+ }
1888
+ if (typeof obj.parentSessionId === "string") {
1889
+ out.parentSessionId = obj.parentSessionId;
1890
+ }
1891
+ if (typeof obj.forkedFromSessionId === "string") {
1892
+ out.forkedFromSessionId = obj.forkedFromSessionId;
1893
+ }
1894
+ if (typeof obj.forkedFromMessageId === "string") {
1895
+ out.forkedFromMessageId = obj.forkedFromMessageId;
1896
+ }
1897
+ if (obj.originatingClient && typeof obj.originatingClient === "object" && !Array.isArray(obj.originatingClient) && typeof obj.originatingClient.name === "string") {
1898
+ const oc = obj.originatingClient;
1899
+ out.originatingClient = {
1900
+ name: oc.name,
1901
+ ...typeof oc.version === "string" ? { version: oc.version } : {}
1902
+ };
1903
+ }
1904
+ if (obj.agentCapabilities !== void 0) {
1905
+ out.agentCapabilities = obj.agentCapabilities;
1906
+ }
1564
1907
  return out;
1565
1908
  }
1566
1909
  function mergeMeta(passthrough, ours) {
@@ -1598,7 +1941,7 @@ var SessionListEntry = z3.object({
1598
1941
  importedFromUpstreamSessionId: z3.string().optional(),
1599
1942
  // Set when this session was spawned as a child by a transformer.
1600
1943
  parentSessionId: z3.string().optional(),
1601
- // Local-fork breadcrumbs set by hydra-acp/fork_session. Distinct from
1944
+ // Local-fork breadcrumbs set by hydra-acp/session/fork. Distinct from
1602
1945
  // the imported* family above: a fork is a local branch off another
1603
1946
  // local session, an import is a cross-machine takeover.
1604
1947
  forkedFromSessionId: z3.string().optional(),
@@ -1636,36 +1979,89 @@ var SessionListResult = z3.object({
1636
1979
  sessions: z3.array(SessionListEntryWire),
1637
1980
  nextCursor: z3.string().optional()
1638
1981
  });
1639
- function sessionListEntryToWire(entry) {
1640
- const hydraMeta = {
1982
+ function buildHydraSessionMeta(entry, extras) {
1983
+ const meta = {
1641
1984
  attachedClients: entry.attachedClients,
1642
1985
  status: entry.status,
1643
1986
  busy: entry.busy,
1644
1987
  awaitingInput: entry.awaitingInput
1645
1988
  };
1989
+ if (entry.cwd !== void 0) {
1990
+ meta.cwd = entry.cwd;
1991
+ }
1992
+ if (entry.title !== void 0) {
1993
+ meta.title = entry.title;
1994
+ }
1646
1995
  if (entry.agentId !== void 0) {
1647
- hydraMeta.agentId = entry.agentId;
1996
+ meta.agentId = entry.agentId;
1648
1997
  }
1649
1998
  if (entry.upstreamSessionId !== void 0) {
1650
- hydraMeta.upstreamSessionId = entry.upstreamSessionId;
1999
+ meta.upstreamSessionId = entry.upstreamSessionId;
1651
2000
  }
1652
2001
  if (entry.currentModel !== void 0) {
1653
- hydraMeta.currentModel = entry.currentModel;
2002
+ meta.currentModel = entry.currentModel;
1654
2003
  }
1655
2004
  if (entry.currentUsage !== void 0) {
1656
- hydraMeta.currentUsage = entry.currentUsage;
2005
+ meta.currentUsage = entry.currentUsage;
1657
2006
  }
1658
2007
  if (entry.importedFromMachine !== void 0) {
1659
- hydraMeta.importedFromMachine = entry.importedFromMachine;
2008
+ meta.importedFromMachine = entry.importedFromMachine;
1660
2009
  }
1661
2010
  if (entry.importedFromUpstreamSessionId !== void 0) {
1662
- hydraMeta.importedFromUpstreamSessionId = entry.importedFromUpstreamSessionId;
2011
+ meta.importedFromUpstreamSessionId = entry.importedFromUpstreamSessionId;
2012
+ }
2013
+ if (entry.parentSessionId !== void 0) {
2014
+ meta.parentSessionId = entry.parentSessionId;
2015
+ }
2016
+ if (entry.forkedFromSessionId !== void 0) {
2017
+ meta.forkedFromSessionId = entry.forkedFromSessionId;
2018
+ }
2019
+ if (entry.forkedFromMessageId !== void 0) {
2020
+ meta.forkedFromMessageId = entry.forkedFromMessageId;
2021
+ }
2022
+ if (entry.originatingClient !== void 0) {
2023
+ meta.originatingClient = entry.originatingClient;
2024
+ }
2025
+ if (entry.interactive !== void 0) {
2026
+ meta.interactive = entry.interactive;
2027
+ }
2028
+ if (extras) {
2029
+ if (extras.clientId !== void 0) {
2030
+ meta.clientId = extras.clientId;
2031
+ }
2032
+ if (extras.currentMode !== void 0) {
2033
+ meta.currentMode = extras.currentMode;
2034
+ }
2035
+ if (extras.agentArgs !== void 0 && extras.agentArgs.length > 0) {
2036
+ meta.agentArgs = extras.agentArgs;
2037
+ }
2038
+ if (extras.availableCommands !== void 0 && extras.availableCommands.length > 0) {
2039
+ meta.availableCommands = extras.availableCommands;
2040
+ }
2041
+ if (extras.availableModes !== void 0 && extras.availableModes.length > 0) {
2042
+ meta.availableModes = extras.availableModes;
2043
+ }
2044
+ if (extras.availableModels !== void 0 && extras.availableModels.length > 0) {
2045
+ meta.availableModels = extras.availableModels;
2046
+ }
2047
+ if (extras.turnStartedAt !== void 0) {
2048
+ meta.turnStartedAt = extras.turnStartedAt;
2049
+ }
2050
+ if (extras.agentCapabilities !== void 0) {
2051
+ meta.agentCapabilities = extras.agentCapabilities;
2052
+ }
2053
+ if (extras.queue !== void 0 && extras.queue.length > 0) {
2054
+ meta.queue = extras.queue;
2055
+ }
1663
2056
  }
2057
+ return meta;
2058
+ }
2059
+ function sessionListEntryToWire(entry) {
1664
2060
  const wire = {
1665
2061
  sessionId: entry.sessionId,
1666
2062
  cwd: entry.cwd,
1667
2063
  updatedAt: entry.updatedAt,
1668
- _meta: mergeMeta(entry._meta, hydraMeta)
2064
+ _meta: mergeMeta(entry._meta, buildHydraSessionMeta(entry))
1669
2065
  };
1670
2066
  if (entry.title !== void 0) {
1671
2067
  wire.title = entry.title;
@@ -1753,65 +2149,6 @@ var PromptAmendedParams = z3.object({
1753
2149
  originator: PromptOriginatorSchema,
1754
2150
  amendedAt: z3.number()
1755
2151
  });
1756
- var StreamOpenParams = z3.object({
1757
- sessionId: z3.string(),
1758
- // 'memory' keeps the ring in RAM only — needed for the eventual MCP
1759
- // tool surface. 'file' adds a temp file projection that the agent can
1760
- // consume with shell tools (tail -f / head / grep) when MCP isn't
1761
- // available. The temp file's path is returned in the response.
1762
- mode: z3.enum(["memory", "file"]).optional(),
1763
- // Ring capacity in bytes. Server clamps to a reasonable minimum and
1764
- // its configured max; omitted falls back to the daemon default.
1765
- capacityBytes: z3.number().int().positive().optional(),
1766
- // File mode only. Soft cap in bytes; after this many bytes are
1767
- // written to the file, further appends still land in the ring but
1768
- // stop being mirrored to disk. The daemon emits one stream_truncated
1769
- // session/update notification when the cap is first hit.
1770
- fileCapBytes: z3.number().int().positive().optional()
1771
- });
1772
- var StreamOpenResult = z3.object({
1773
- // Only present when mode === "file".
1774
- filePath: z3.string().optional(),
1775
- capacityBytes: z3.number().int().positive(),
1776
- fileCapBytes: z3.number().int().positive().optional()
1777
- });
1778
- var StreamWriteParams = z3.object({
1779
- sessionId: z3.string(),
1780
- // Base64-encoded bytes. UTF-8 stdin gets re-encoded on the wire; the
1781
- // ring is byte-exact so binary streams (audio, framed protocols) work
1782
- // identically.
1783
- chunk: z3.string(),
1784
- // True on the final write. Pending long-poll reads / waits return with
1785
- // eof:true once this is observed.
1786
- eof: z3.boolean().optional()
1787
- });
1788
- var StreamWriteResult = z3.object({
1789
- // Absolute writeCursor after this append landed.
1790
- writeCursor: z3.number().int().nonnegative()
1791
- });
1792
- var StreamReadParams = z3.object({
1793
- sessionId: z3.string(),
1794
- cursor: z3.number().int().nonnegative(),
1795
- // Cap on bytes returned. Server enforces a hard ceiling (STREAM_READ_MAX_BYTES,
1796
- // currently 64 KiB) even when the caller asks for more.
1797
- maxBytes: z3.number().int().positive().optional(),
1798
- // Long-poll timeout in ms. 0 / omitted returns immediately with
1799
- // whatever's available (possibly empty). Server cap 60s.
1800
- waitMs: z3.number().int().nonnegative().optional()
1801
- });
1802
- var StreamReadResult = z3.object({
1803
- // Base64-encoded bytes. Empty string when nothing new is available
1804
- // and either waitMs was 0 or the long-poll expired without data.
1805
- bytes: z3.string(),
1806
- nextCursor: z3.number().int().nonnegative(),
1807
- // Set when `cursor` pointed before the oldest still-resident byte —
1808
- // value is the count of bytes that were evicted between the caller's
1809
- // cursor and what we still have.
1810
- gap: z3.number().int().nonnegative().optional(),
1811
- // True when the producer has closed AND there are no more bytes
1812
- // after nextCursor.
1813
- eof: z3.boolean().optional()
1814
- });
1815
2152
  var AgentInstallProgressParams = z3.object({
1816
2153
  agentId: z3.string(),
1817
2154
  version: z3.string(),
@@ -1828,7 +2165,7 @@ var AgentInstallProgressParams = z3.object({
1828
2165
  totalBytes: z3.number().optional(),
1829
2166
  packageSpec: z3.string().optional()
1830
2167
  });
1831
- var AGENT_INSTALL_PROGRESS_METHOD = "hydra-acp/agent_install_progress";
2168
+ var AGENT_INSTALL_PROGRESS_METHOD = "hydra-acp/agents/install_progress";
1832
2169
 
1833
2170
  // src/acp/framing.ts
1834
2171
  function ndjsonStreamFromStdio(stdout, stdin) {
@@ -1931,6 +2268,13 @@ var JsonRpcConnection = class _JsonRpcConnection {
1931
2268
  pending = /* @__PURE__ */ new Map();
1932
2269
  closed = false;
1933
2270
  closeHandlers = [];
2271
+ // Observers for error frames that arrive with no matching pending
2272
+ // request — e.g. an agent replying with an error to a notification
2273
+ // (which carries no id, so it can't be correlated the normal way).
2274
+ // session/cancel is the canonical case: an agent that doesn't support
2275
+ // it (current opencode) emits a MethodNotFound/UnsupportedOperation
2276
+ // error frame we'd otherwise silently drop. See handleResponse.
2277
+ orphanErrorHandlers = [];
1934
2278
  onRequest(method, handler) {
1935
2279
  this.requestHandlers.set(method, handler);
1936
2280
  }
@@ -1966,6 +2310,10 @@ var JsonRpcConnection = class _JsonRpcConnection {
1966
2310
  onClose(handler) {
1967
2311
  this.closeHandlers.push(handler);
1968
2312
  }
2313
+ // Subscribe to error frames that can't be matched to a pending request.
2314
+ onOrphanError(handler) {
2315
+ this.orphanErrorHandlers.push(handler);
2316
+ }
1969
2317
  async request(method, params) {
1970
2318
  return this.requestWithId(method, params).response;
1971
2319
  }
@@ -2022,6 +2370,8 @@ var JsonRpcConnection = class _JsonRpcConnection {
2022
2370
  }
2023
2371
  } else if ("id" in message) {
2024
2372
  this.handleResponse(message);
2373
+ } else if ("error" in message) {
2374
+ this.handleResponse(message);
2025
2375
  }
2026
2376
  }
2027
2377
  async handleRequest(req) {
@@ -2069,6 +2419,18 @@ var JsonRpcConnection = class _JsonRpcConnection {
2069
2419
  handleResponse(res) {
2070
2420
  const pending = this.pending.get(res.id);
2071
2421
  if (!pending) {
2422
+ if (res.error) {
2423
+ for (const handler of this.orphanErrorHandlers) {
2424
+ try {
2425
+ handler({
2426
+ code: res.error.code,
2427
+ message: res.error.message,
2428
+ data: res.error.data
2429
+ });
2430
+ } catch {
2431
+ }
2432
+ }
2433
+ }
2072
2434
  return;
2073
2435
  }
2074
2436
  this.pending.delete(res.id);
@@ -2244,8 +2606,8 @@ stderr: ${tail}` : reason;
2244
2606
  function openAgentLog(agentId) {
2245
2607
  try {
2246
2608
  const logPath = paths.agentLogFile(agentId);
2247
- fs6.mkdirSync(path5.dirname(logPath), { recursive: true });
2248
- const stream = fs6.createWriteStream(logPath, { flags: "a" });
2609
+ fs7.mkdirSync(path5.dirname(logPath), { recursive: true });
2610
+ const stream = fs7.createWriteStream(logPath, { flags: "a" });
2249
2611
  stream.on("error", () => void 0);
2250
2612
  return stream;
2251
2613
  } catch {
@@ -2254,7 +2616,7 @@ function openAgentLog(agentId) {
2254
2616
  }
2255
2617
 
2256
2618
  // src/core/session-manager.ts
2257
- import * as fs13 from "fs/promises";
2619
+ import * as fs15 from "fs/promises";
2258
2620
  import * as os2 from "os";
2259
2621
  import * as path9 from "path";
2260
2622
  import { customAlphabet as customAlphabet3 } from "nanoid";
@@ -2298,8 +2660,8 @@ var SessionStreamBuffer = class {
2298
2660
  fileCapReached = false;
2299
2661
  onFileCapReached;
2300
2662
  logWriteError;
2301
- // Single-flight chain for file appends so concurrent stream_write
2302
- // calls don't interleave their writes.
2663
+ // Single-flight chain for file appends so concurrent stdin writes
2664
+ // don't interleave their file writes.
2303
2665
  fileWriteChain = Promise.resolve();
2304
2666
  constructor(opts = {}) {
2305
2667
  this.maxCapacityBytes = opts.capacityBytes ?? DEFAULT_CAPACITY_BYTES;
@@ -2728,6 +3090,30 @@ var HYDRA_COMMANDS = [
2728
3090
  ];
2729
3091
  var VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
2730
3092
 
3093
+ // src/core/model-resolve.ts
3094
+ function trailingSegment(modelId) {
3095
+ const slash = modelId.lastIndexOf("/");
3096
+ const tail = slash === -1 ? modelId : modelId.slice(slash + 1);
3097
+ return tail.toLowerCase();
3098
+ }
3099
+ function resolveModelId(requested, advertised) {
3100
+ if (advertised.length === 0) {
3101
+ return { kind: "none", requested };
3102
+ }
3103
+ if (advertised.some((m) => m.modelId === requested)) {
3104
+ return { kind: "exact", modelId: requested };
3105
+ }
3106
+ const wantKey = trailingSegment(requested);
3107
+ const candidates = advertised.map((m) => m.modelId).filter((id) => trailingSegment(id) === wantKey);
3108
+ if (candidates.length === 1) {
3109
+ return { kind: "resolved", modelId: candidates[0], requested };
3110
+ }
3111
+ if (candidates.length > 1) {
3112
+ return { kind: "ambiguous", requested, candidates };
3113
+ }
3114
+ return { kind: "unknown", requested };
3115
+ }
3116
+
2731
3117
  // src/core/coalesce-replay.ts
2732
3118
  function coalesceReplay(entries) {
2733
3119
  if (entries.length === 0) {
@@ -2735,6 +3121,7 @@ function coalesceReplay(entries) {
2735
3121
  }
2736
3122
  const lastToolUpdateIndex = /* @__PURE__ */ new Map();
2737
3123
  const mergedToolContent = /* @__PURE__ */ new Map();
3124
+ const carriedRawInput = /* @__PURE__ */ new Map();
2738
3125
  for (let i = 0; i < entries.length; i++) {
2739
3126
  const entry = entries[i];
2740
3127
  if (entry === void 0) {
@@ -2749,6 +3136,9 @@ function coalesceReplay(entries) {
2749
3136
  continue;
2750
3137
  }
2751
3138
  lastToolUpdateIndex.set(id, i);
3139
+ if (upd.rawInput && typeof upd.rawInput === "object" && !Array.isArray(upd.rawInput) && Object.keys(upd.rawInput).length > 0) {
3140
+ carriedRawInput.set(id, upd.rawInput);
3141
+ }
2752
3142
  if (Array.isArray(upd.content) && upd.content.length > 0) {
2753
3143
  const buf = mergedToolContent.get(id);
2754
3144
  if (buf) {
@@ -2788,11 +3178,11 @@ function coalesceReplay(entries) {
2788
3178
  if (id !== void 0 && lastToolUpdateIndex.get(id) !== i) {
2789
3179
  continue;
2790
3180
  }
2791
- if (id !== void 0 && mergedToolContent.has(id)) {
2792
- out.push(withReplacedContent(entry, mergedToolContent.get(id) ?? []));
2793
- } else {
2794
- out.push(entry);
3181
+ let emitted = id !== void 0 && mergedToolContent.has(id) ? withReplacedContent(entry, mergedToolContent.get(id) ?? []) : entry;
3182
+ if (id !== void 0 && carriedRawInput.has(id) && !hasRawInput(emitted)) {
3183
+ emitted = withRawInput(emitted, carriedRawInput.get(id));
2795
3184
  }
3185
+ out.push(emitted);
2796
3186
  continue;
2797
3187
  }
2798
3188
  if (kind === "plan") {
@@ -2863,24 +3253,40 @@ function withReplacedContent(entry, content) {
2863
3253
  }
2864
3254
  };
2865
3255
  }
3256
+ function hasRawInput(entry) {
3257
+ const update = readUpdate(entry);
3258
+ const ri = update?.rawInput;
3259
+ return !!ri && typeof ri === "object" && !Array.isArray(ri) && Object.keys(ri).length > 0;
3260
+ }
3261
+ function withRawInput(entry, rawInput) {
3262
+ const params = entry.params ?? {};
3263
+ const update = params.update ?? {};
3264
+ return {
3265
+ ...entry,
3266
+ params: {
3267
+ ...params,
3268
+ update: { ...update, rawInput }
3269
+ }
3270
+ };
3271
+ }
2866
3272
 
2867
3273
  // src/core/queue-store.ts
2868
- import * as fs7 from "fs/promises";
3274
+ import * as fs8 from "fs/promises";
2869
3275
  async function rewriteQueue(sessionId, entries) {
2870
3276
  const file = paths.queueFile(sessionId);
2871
3277
  if (entries.length === 0) {
2872
- await fs7.unlink(file).catch(() => void 0);
3278
+ await fs8.unlink(file).catch(() => void 0);
2873
3279
  return;
2874
3280
  }
2875
- await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
3281
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
2876
3282
  const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
2877
- await fs7.writeFile(file, body, "utf8");
3283
+ await fs8.writeFile(file, body, "utf8");
2878
3284
  }
2879
3285
  async function loadQueue(sessionId) {
2880
3286
  const file = paths.queueFile(sessionId);
2881
3287
  let text;
2882
3288
  try {
2883
- text = await fs7.readFile(file, "utf8");
3289
+ text = await fs8.readFile(file, "utf8");
2884
3290
  } catch (err) {
2885
3291
  if (err.code === "ENOENT") {
2886
3292
  return [];
@@ -2902,7 +3308,7 @@ async function loadQueue(sessionId) {
2902
3308
  }
2903
3309
  async function deleteQueue(sessionId) {
2904
3310
  const file = paths.queueFile(sessionId);
2905
- await fs7.unlink(file).catch(() => void 0);
3311
+ await fs8.unlink(file).catch(() => void 0);
2906
3312
  }
2907
3313
 
2908
3314
  // src/core/session.ts
@@ -2920,7 +3326,7 @@ function stripHydraSessionPrefix(id) {
2920
3326
  var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
2921
3327
  var TRANSFORMER_CLAIM_TIMEOUT_MS = 5 * 60 * 1e3;
2922
3328
  var RECENTLY_TERMINAL_LIMIT = 64;
2923
- var Session = class {
3329
+ var Session = class _Session {
2924
3330
  sessionId;
2925
3331
  cwd;
2926
3332
  // agent / agentId / upstreamSessionId are mutable so /hydra agent can
@@ -2994,6 +3400,16 @@ var Session = class {
2994
3400
  // endpoint uses this to tail a live session's conversation stream
2995
3401
  // without participating in turns or prompts.
2996
3402
  broadcastHandlers = [];
3403
+ // Epoch ms of the most recent session/cancel we sent to the agent.
3404
+ // Used to attribute an id-less error frame from the agent to a cancel
3405
+ // (see wireAgent's orphan-error observer). Window kept short so an
3406
+ // unrelated later error isn't mislabeled.
3407
+ lastCancelAt = 0;
3408
+ static CANCEL_ERROR_WINDOW_MS = 2e3;
3409
+ // Set by forceCancel() so the in-flight turn's agent-kill rejection is
3410
+ // reported to the originator as a clean "cancelled" stopReason instead of
3411
+ // a raw "connection closed" error.
3412
+ forceCancelling = false;
2997
3413
  // True once we've observed our first session/prompt; gates the
2998
3414
  // first-prompt-seeded title so subsequent prompts don't churn it.
2999
3415
  // Also read by SessionManager's onClose hook to decide whether to
@@ -3069,6 +3485,7 @@ var Session = class {
3069
3485
  agentCommandsHandlers = [];
3070
3486
  agentModesHandlers = [];
3071
3487
  agentModelsHandlers = [];
3488
+ availableAgentsFn;
3072
3489
  modelHandlers = [];
3073
3490
  modeHandlers = [];
3074
3491
  interactiveHandlers = [];
@@ -3141,6 +3558,7 @@ var Session = class {
3141
3558
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
3142
3559
  this.idleEventTimeoutMs = init.idleEventTimeoutMs ?? 3e4;
3143
3560
  this.spawnReplacementAgent = init.spawnReplacementAgent;
3561
+ this.availableAgentsFn = init.availableAgents;
3144
3562
  this.listSessions = init.listSessions;
3145
3563
  this.logger = init.logger;
3146
3564
  this.transformChain = init.transformChain ?? [];
@@ -3218,6 +3636,11 @@ var Session = class {
3218
3636
  agent.connection.onRequest("session/request_permission", async (params) => {
3219
3637
  return this.handlePermissionRequest(params);
3220
3638
  });
3639
+ if (typeof agent.connection.onOrphanError === "function") {
3640
+ agent.connection.onOrphanError((error) => {
3641
+ this.handleOrphanError(error);
3642
+ });
3643
+ }
3221
3644
  agent.onExit(() => {
3222
3645
  if (this.agent !== agent) {
3223
3646
  return;
@@ -3225,6 +3648,31 @@ var Session = class {
3225
3648
  this.markClosed({ deleteRecord: false });
3226
3649
  });
3227
3650
  }
3651
+ // An error frame arrived from the agent that couldn't be matched to a
3652
+ // pending request. The canonical case is a reply to our id-less
3653
+ // session/cancel notification: agents that don't support cancellation
3654
+ // (current opencode) answer with MethodNotFound (-32601) or an
3655
+ // UnsupportedOperation error. If one lands within the cancel window,
3656
+ // surface it to attached clients so the TUI can tell the user the
3657
+ // cancel didn't take rather than silently dropping it.
3658
+ handleOrphanError(error) {
3659
+ const sinceCancel = Date.now() - this.lastCancelAt;
3660
+ if (this.lastCancelAt === 0 || sinceCancel > _Session.CANCEL_ERROR_WINDOW_MS) {
3661
+ this.logger?.warn(
3662
+ `agent ${this.agentId} sent uncorrelated error frame code=${error.code} message=${error.message}`
3663
+ );
3664
+ return;
3665
+ }
3666
+ this.lastCancelAt = 0;
3667
+ this.logger?.warn(
3668
+ `agent ${this.agentId} rejected session/cancel code=${error.code} message=${error.message}`
3669
+ );
3670
+ this.broadcastQueueNotification("hydra-acp/cancel_failed", {
3671
+ sessionId: this.sessionId,
3672
+ code: error.code,
3673
+ message: error.message
3674
+ });
3675
+ }
3228
3676
  // Runs the response-side transformer chain, then the snapshot interceptors,
3229
3677
  // then recordAndBroadcast. All state mutation happens after the chain exits.
3230
3678
  // See forwardRequest for originatedBy / startIdx semantics.
@@ -3242,7 +3690,7 @@ var Session = class {
3242
3690
  const token = `t_${generateChainToken()}`;
3243
3691
  let result;
3244
3692
  try {
3245
- result = await t.connection.request("transformer/message", {
3693
+ result = await t.connection.request("hydra-acp/transformer/message", {
3246
3694
  token,
3247
3695
  phase: "response",
3248
3696
  method: "session/update",
@@ -3268,7 +3716,7 @@ var Session = class {
3268
3716
  const timer = setTimeout(() => {
3269
3717
  if (this.pendingClaims.delete(token)) {
3270
3718
  this.broadcastQueueNotification(
3271
- "hydra-acp/transformer_abandoned_request",
3719
+ "hydra-acp/transformer/abandoned_request",
3272
3720
  { sessionId: this.sessionId, token, transformerName: t.name }
3273
3721
  );
3274
3722
  void this.runResponseChain(
@@ -3315,7 +3763,10 @@ var Session = class {
3315
3763
  return;
3316
3764
  }
3317
3765
  if (this.maybeApplyAgentConfigOption(envelope)) {
3318
- this.recordAndBroadcast("session/update", envelope);
3766
+ this.recordAndBroadcast(
3767
+ "session/update",
3768
+ this.mergeAgentOptionIntoEnvelope(envelope)
3769
+ );
3319
3770
  return;
3320
3771
  }
3321
3772
  if (this.maybeApplyAgentUsage(rawParams)) {
@@ -3373,11 +3824,11 @@ var Session = class {
3373
3824
  // Read the persisted history from disk. Returns [] if no history
3374
3825
  // file exists (fresh session, never prompted). Used by attach() and
3375
3826
  // the HTTP /history endpoint.
3376
- async getHistorySnapshot() {
3827
+ async getHistorySnapshot(tools = "inline") {
3377
3828
  if (!this.historyStore) {
3378
3829
  return [];
3379
3830
  }
3380
- return this.historyStore.load(this.sessionId).catch(() => []);
3831
+ return this.historyStore.load(this.sessionId, { tools }).catch(() => []);
3381
3832
  }
3382
3833
  // Subscribe to recordable broadcast entries — fires once per entry
3383
3834
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -3427,7 +3878,7 @@ var Session = class {
3427
3878
  }
3428
3879
  async loadReplay(historyPolicy, opts) {
3429
3880
  const maybeCoalesce = (entries) => opts.raw ? entries : coalesceReplay(entries);
3430
- const raw = await this.getHistorySnapshot();
3881
+ const raw = await this.getHistorySnapshot(opts.toolContent ?? "inline");
3431
3882
  const state = this.buildStateSnapshotReplay();
3432
3883
  if (historyPolicy === "after_message") {
3433
3884
  const cutoff = opts.afterMessageId ? findMessageIdIndex(raw, opts.afterMessageId) : -1;
@@ -3650,7 +4101,7 @@ var Session = class {
3650
4101
  // prompt_received a single, useful meaning ("the agent is now taking
3651
4102
  // a turn on this prompt"), which is how attached clients (notably
3652
4103
  // agent-shell) consume it. The accept-time signal that peers can use
3653
- // for queue chip rendering is hydra-acp/prompt_queue_added instead.
4104
+ // for queue chip rendering is hydra-acp/prompt_queue/added instead.
3654
4105
  broadcastPromptReceived(entry) {
3655
4106
  const sentBy = { clientId: entry.originator.clientId };
3656
4107
  if (entry.originator.name) {
@@ -3741,7 +4192,7 @@ var Session = class {
3741
4192
  this.recentlyTerminal.delete(oldest);
3742
4193
  }
3743
4194
  }
3744
- // Fire hydra-acp/prompt_amended for the M1→M2 linkage. The amendment's
4195
+ // Fire hydra-acp/prompt/amended for the M1→M2 linkage. The amendment's
3745
4196
  // current content is read live from the queue entry so any update_prompt
3746
4197
  // calls during the amend window are reflected. Best-effort: if M2 has
3747
4198
  // already been cancelled out of the queue by the time we get here, we
@@ -3761,7 +4212,7 @@ var Session = class {
3761
4212
  amendedAt: Date.now()
3762
4213
  };
3763
4214
  this.broadcastQueueNotification(
3764
- "hydra-acp/prompt_amended",
4215
+ "hydra-acp/prompt/amended",
3765
4216
  params
3766
4217
  );
3767
4218
  }
@@ -3804,17 +4255,17 @@ var Session = class {
3804
4255
  "hydra-acp": { amending: options.amending }
3805
4256
  };
3806
4257
  }
3807
- this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
4258
+ this.broadcastQueueNotification("hydra-acp/prompt_queue/added", params);
3808
4259
  }
3809
4260
  broadcastQueueUpdated(messageId, prompt) {
3810
- this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
4261
+ this.broadcastQueueNotification("hydra-acp/prompt_queue/updated", {
3811
4262
  sessionId: this.sessionId,
3812
4263
  messageId,
3813
4264
  prompt
3814
4265
  });
3815
4266
  }
3816
4267
  broadcastQueueRemoved(messageId, reason) {
3817
- this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
4268
+ this.broadcastQueueNotification("hydra-acp/prompt_queue/removed", {
3818
4269
  sessionId: this.sessionId,
3819
4270
  messageId,
3820
4271
  reason
@@ -4095,6 +4546,7 @@ var Session = class {
4095
4546
  JsonRpcErrorCodes.SessionNotFound
4096
4547
  );
4097
4548
  }
4549
+ this.lastCancelAt = Date.now();
4098
4550
  await this.agent.connection.notify("session/cancel", {
4099
4551
  sessionId: this.upstreamSessionId
4100
4552
  });
@@ -4110,7 +4562,7 @@ var Session = class {
4110
4562
  this.transformChain.push(ref);
4111
4563
  }
4112
4564
  if (ref.intercepts.has("lifecycle:session.opened")) {
4113
- void ref.connection.notify("transformer/session_event", {
4565
+ void ref.connection.notify("hydra-acp/transformer/session_event", {
4114
4566
  event: "session.opened",
4115
4567
  sessionId: this.sessionId
4116
4568
  }).catch(() => void 0);
@@ -4134,7 +4586,7 @@ var Session = class {
4134
4586
  const token = `t_${generateChainToken()}`;
4135
4587
  let result;
4136
4588
  try {
4137
- result = await t.connection.request("transformer/message", {
4589
+ result = await t.connection.request("hydra-acp/transformer/message", {
4138
4590
  token,
4139
4591
  phase: "request",
4140
4592
  method,
@@ -4160,7 +4612,7 @@ var Session = class {
4160
4612
  const timer = setTimeout(() => {
4161
4613
  if (this.pendingClaims.delete(token)) {
4162
4614
  this.broadcastQueueNotification(
4163
- "hydra-acp/transformer_abandoned_request",
4615
+ "hydra-acp/transformer/abandoned_request",
4164
4616
  { sessionId: this.sessionId, token, transformerName: t.name }
4165
4617
  );
4166
4618
  void this.forwardRequest(
@@ -4202,7 +4654,7 @@ var Session = class {
4202
4654
  claim.resolve(result);
4203
4655
  return true;
4204
4656
  }
4205
- // Called by the WS handler on hydra-acp/keep_alive.
4657
+ // Called by the WS handler on hydra-acp/connection/keep_alive.
4206
4658
  // Resets the abandonment timer for an outstanding processing claim.
4207
4659
  keepAliveClaim(token, estimatedRemainingMs) {
4208
4660
  const claim = this.pendingClaims.get(token);
@@ -4214,7 +4666,7 @@ var Session = class {
4214
4666
  const timer = setTimeout(() => {
4215
4667
  if (this.pendingClaims.delete(token)) {
4216
4668
  this.broadcastQueueNotification(
4217
- "hydra-acp/transformer_abandoned_request",
4669
+ "hydra-acp/transformer/abandoned_request",
4218
4670
  { sessionId: this.sessionId, token, transformerName: claim.transformerName }
4219
4671
  );
4220
4672
  if (claim.side === "response") {
@@ -4379,18 +4831,20 @@ var Session = class {
4379
4831
  } catch {
4380
4832
  }
4381
4833
  }
4834
+ this.broadcastConfigOptions();
4382
4835
  return true;
4383
4836
  }
4384
- // Apply an opencode-style config_option_update. opencode emits this
4385
- // (not the spec-shaped current_model_update / available_models_update)
4386
- // to carry both the current model and the list of available models.
4387
- // The payload is `configOptions: [{ id: "model", currentValue, options:
4388
- // [{ value, name }] }, ...]`. We harvest only the entry whose id is
4389
- // "model" — other ids ("mode", "effort", etc.) are opencode-internal
4390
- // and not consumed by hydra. Returns true when we recognized and
4391
- // handled the notification so the wireAgent loop can stop trying
4392
- // further extractors (the broadcast still fires; clients that grok
4393
- // config_option_update render it directly).
4837
+ // Apply an agent-emitted config_option_update. claude-acp and opencode
4838
+ // emit this (not the spec-shaped current_model_update /
4839
+ // available_modes_update) to carry the current value AND option list for
4840
+ // model and mode. The payload is `configOptions: [{ id, currentValue,
4841
+ // options: [{ value, name }] }, ...]`. We harvest the "model" and "mode"
4842
+ // entries — other ids (e.g. "effort") are agent-internal and ignored.
4843
+ // Harvesting the mode list here is what populates availableModes for
4844
+ // agents that never send available_modes_update (so the TUI's Shift+Tab
4845
+ // cycle has something to cycle through). Returns true when recognized so
4846
+ // the wireAgent loop stops trying further extractors (the original frame
4847
+ // still broadcasts; config-options-aware clients render it directly).
4394
4848
  maybeApplyAgentConfigOption(params) {
4395
4849
  const obj = params ?? {};
4396
4850
  const update = obj.update ?? {};
@@ -4406,24 +4860,37 @@ var Session = class {
4406
4860
  continue;
4407
4861
  }
4408
4862
  const opt = raw;
4409
- if (opt.id !== "model") {
4410
- continue;
4411
- }
4412
- const models = parseModelsList(opt.options);
4413
- if (models.length > 0) {
4414
- this.setAgentAdvertisedModels(models);
4415
- }
4416
- const cv = opt.currentValue;
4417
- if (typeof cv === "string") {
4418
- const trimmed = cv.trim();
4419
- if (trimmed && trimmed !== this.currentModel) {
4420
- this.logger?.info(
4421
- `live config_option_update(model): sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
4422
- );
4423
- this.applyModelChange(trimmed);
4863
+ if (opt.id === "model") {
4864
+ const models = parseModelsList(opt.options);
4865
+ if (models.length > 0) {
4866
+ this.setAgentAdvertisedModels(models);
4867
+ }
4868
+ const cv = opt.currentValue;
4869
+ if (typeof cv === "string") {
4870
+ const trimmed = cv.trim();
4871
+ if (trimmed && trimmed !== this.currentModel) {
4872
+ this.logger?.info(
4873
+ `live config_option_update(model): sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
4874
+ );
4875
+ this.applyModelChange(trimmed);
4876
+ }
4877
+ }
4878
+ } else if (opt.id === "mode") {
4879
+ const modes = parseModesList(opt.options);
4880
+ if (modes.length > 0) {
4881
+ this.setAgentAdvertisedModes(modes);
4882
+ }
4883
+ const cv = opt.currentValue;
4884
+ if (typeof cv === "string") {
4885
+ const trimmed = cv.trim();
4886
+ if (trimmed && trimmed !== this.currentMode) {
4887
+ this.logger?.info(
4888
+ `live config_option_update(mode): sessionId=${this.sessionId} ${JSON.stringify(this.currentMode)} \u2192 ${JSON.stringify(trimmed)}`
4889
+ );
4890
+ this.applyModeChange(trimmed);
4891
+ }
4424
4892
  }
4425
4893
  }
4426
- break;
4427
4894
  }
4428
4895
  return true;
4429
4896
  }
@@ -4451,6 +4918,7 @@ var Session = class {
4451
4918
  } catch {
4452
4919
  }
4453
4920
  }
4921
+ this.broadcastConfigOptions();
4454
4922
  return true;
4455
4923
  }
4456
4924
  // usage_update carries any subset of {used, size, cost.amount,
@@ -4661,6 +5129,7 @@ var Session = class {
4661
5129
  sessionId: this.upstreamSessionId,
4662
5130
  update
4663
5131
  });
5132
+ this.broadcastConfigOptions();
4664
5133
  }
4665
5134
  // Apply a mode change initiated by a client request (session/set_mode)
4666
5135
  // when the agent doesn't emit a current_mode_update notification on its
@@ -4697,6 +5166,115 @@ var Session = class {
4697
5166
  sessionId: this.upstreamSessionId,
4698
5167
  update
4699
5168
  });
5169
+ this.broadcastConfigOptions();
5170
+ }
5171
+ // Assemble the spec-shaped configOptions snapshot for this session.
5172
+ // Order reflects the agent's preferred prominence: model and mode (the
5173
+ // dimensions users toggle constantly) first, then the hydra-native
5174
+ // `agent` selector. model/mode are included only when hydra actually
5175
+ // knows that dimension for the connected agent; `agent` is always
5176
+ // present since hydra owns the swap concept regardless of the backend.
5177
+ buildConfigOptions() {
5178
+ const out = [];
5179
+ if (this.agentAdvertisedModels.length > 0) {
5180
+ const options = this.agentAdvertisedModels.map(
5181
+ (m) => ({
5182
+ value: m.modelId,
5183
+ name: m.name ?? m.modelId,
5184
+ ...m.description !== void 0 ? { description: m.description } : {}
5185
+ })
5186
+ );
5187
+ const currentValue = this.currentModel && options.some((o) => o.value === this.currentModel) ? this.currentModel : options[0].value;
5188
+ out.push({
5189
+ id: "model",
5190
+ name: "Model",
5191
+ category: "model",
5192
+ type: "select",
5193
+ currentValue,
5194
+ options
5195
+ });
5196
+ }
5197
+ if (this.agentAdvertisedModes.length > 0) {
5198
+ const options = this.agentAdvertisedModes.map(
5199
+ (m) => ({
5200
+ value: m.id,
5201
+ name: m.name ?? m.id,
5202
+ ...m.description !== void 0 ? { description: m.description } : {}
5203
+ })
5204
+ );
5205
+ const currentValue = this.currentMode && options.some((o) => o.value === this.currentMode) ? this.currentMode : options[0].value;
5206
+ out.push({
5207
+ id: "mode",
5208
+ name: "Session Mode",
5209
+ category: "mode",
5210
+ type: "select",
5211
+ currentValue,
5212
+ options
5213
+ });
5214
+ }
5215
+ const agents = this.availableAgentsFn?.() ?? [];
5216
+ const agentOptions = agents.map((a) => ({
5217
+ value: a.id,
5218
+ name: a.name ?? a.id,
5219
+ ...a.description !== void 0 ? { description: a.description } : {}
5220
+ }));
5221
+ if (!agentOptions.some((o) => o.value === this.agentId)) {
5222
+ agentOptions.unshift({ value: this.agentId, name: this.agentId });
5223
+ }
5224
+ out.push({
5225
+ id: "agent",
5226
+ name: "Agent",
5227
+ category: "_hydra_agent",
5228
+ type: "select",
5229
+ currentValue: this.agentId,
5230
+ options: agentOptions
5231
+ });
5232
+ return out;
5233
+ }
5234
+ // Broadcast a config_option_update carrying the full snapshot. Fired
5235
+ // alongside the legacy current_mode_update / current_model_update and on
5236
+ // agent swaps so config-options-aware clients stay in sync via the
5237
+ // spec mechanism. config_option_update is a STATE_UPDATE_KIND, so this
5238
+ // broadcasts live but is not recorded to history.
5239
+ broadcastConfigOptions() {
5240
+ this.recordAndBroadcast("session/update", {
5241
+ sessionId: this.upstreamSessionId,
5242
+ update: {
5243
+ sessionUpdate: "config_option_update",
5244
+ configOptions: this.buildConfigOptions()
5245
+ }
5246
+ });
5247
+ }
5248
+ // Return a shallow clone of an agent-emitted config_option_update
5249
+ // envelope with the hydra-native `agent` option appended to its
5250
+ // configOptions (unless the agent already advertised one). Preserves
5251
+ // the agent's own options (mode/model/effort/…) and their order; the
5252
+ // `agent` selector rides last, matching its lower prominence. The
5253
+ // original envelope is not mutated.
5254
+ mergeAgentOptionIntoEnvelope(envelope) {
5255
+ if (!envelope || typeof envelope !== "object") {
5256
+ return envelope;
5257
+ }
5258
+ const env = envelope;
5259
+ if (!env.update || typeof env.update !== "object") {
5260
+ return envelope;
5261
+ }
5262
+ const update = env.update;
5263
+ const list = Array.isArray(update.configOptions) ? [...update.configOptions] : [];
5264
+ const hasAgent = list.some(
5265
+ (o) => o && typeof o === "object" && o.id === "agent"
5266
+ );
5267
+ if (hasAgent) {
5268
+ return envelope;
5269
+ }
5270
+ const agentOption = this.buildConfigOptions().find((o) => o.id === "agent");
5271
+ if (!agentOption) {
5272
+ return envelope;
5273
+ }
5274
+ return {
5275
+ ...env,
5276
+ update: { ...update, configOptions: [...list, agentOption] }
5277
+ };
4700
5278
  }
4701
5279
  onUsageChange(handler) {
4702
5280
  this.usageHandlers.push(handler);
@@ -4832,7 +5410,7 @@ var Session = class {
4832
5410
  // "/hydra <name> <verb> [args]" — name matches a registered extension
4833
5411
  // or transformer. We split the remainder into verb + args, validate the
4834
5412
  // verb against what the process advertised, and forward as a
4835
- // hydra-acp/extension_command request on the process's WS connection.
5413
+ // hydra-acp/commands/invoke request on the process's WS connection.
4836
5414
  // The reply's text (if any) is broadcast as a synthetic
4837
5415
  // agent_message_chunk so it appears in the conversation alongside the
4838
5416
  // user's invocation.
@@ -4861,7 +5439,7 @@ var Session = class {
4861
5439
  }
4862
5440
  let reply;
4863
5441
  try {
4864
- reply = await entry.connection.request("hydra-acp/extension_command", {
5442
+ reply = await entry.connection.request("hydra-acp/commands/invoke", {
4865
5443
  sessionId: this.sessionId,
4866
5444
  verb,
4867
5445
  args
@@ -4969,6 +5547,27 @@ ${text}
4969
5547
  sessionUpdate: "agent_message_chunk",
4970
5548
  content: { type: "text", text: `
4971
5549
  ${body}
5550
+ ` },
5551
+ _meta: { "hydra-acp": { synthetic: true } }
5552
+ }
5553
+ });
5554
+ return { stopReason: "end_turn" };
5555
+ }
5556
+ const resolution = resolveModelId(arg, this.agentAdvertisedModels);
5557
+ let modelId = arg;
5558
+ if (resolution.kind === "resolved") {
5559
+ modelId = resolution.modelId;
5560
+ } else if (resolution.kind === "ambiguous" || resolution.kind === "unknown") {
5561
+ const known = this.agentAdvertisedModels.map((m) => m.modelId).join("\n ");
5562
+ const reason = resolution.kind === "ambiguous" ? `"${arg}" matches multiple models: ${resolution.candidates.join(", ")}` : `"${arg}" is not an available model`;
5563
+ this.recordAndBroadcast("session/update", {
5564
+ sessionId: this.upstreamSessionId,
5565
+ update: {
5566
+ sessionUpdate: "agent_message_chunk",
5567
+ content: { type: "text", text: `
5568
+ ${reason}.
5569
+ Available models:
5570
+ ${known}
4972
5571
  ` },
4973
5572
  _meta: { "hydra-acp": { synthetic: true } }
4974
5573
  }
@@ -4977,7 +5576,7 @@ ${body}
4977
5576
  }
4978
5577
  await this.forwardRequest("session/set_model", {
4979
5578
  sessionId: this.sessionId,
4980
- modelId: arg
5579
+ modelId
4981
5580
  });
4982
5581
  return { stopReason: "end_turn" };
4983
5582
  }
@@ -5074,6 +5673,14 @@ ${body}
5074
5673
  // record. Spawns the new agent first so a failure leaves the old one
5075
5674
  // intact; then injects a synthesized transcript so the new agent has
5076
5675
  // context for the next turn.
5676
+ // Public entry for swapping the underlying agent from a client request
5677
+ // (session/set_config_option with configId "agent"), the protocol twin
5678
+ // of the `/hydra agent` text command. Delegates to the same swap
5679
+ // machinery so both paths share validation, transcript replay, and the
5680
+ // config_option_update broadcast.
5681
+ setAgent(newAgentId) {
5682
+ return this.runAgentCommand(newAgentId);
5683
+ }
5077
5684
  runAgentCommand(newAgentId) {
5078
5685
  if (!newAgentId) {
5079
5686
  throw withCode(
@@ -5112,12 +5719,10 @@ ${body}
5112
5719
  this.agentCapabilities = fresh.agentCapabilities;
5113
5720
  this.agentAdvertisedCommands = [];
5114
5721
  this.broadcastMergedCommands();
5115
- if (this.agentAdvertisedModels.length > 0) {
5116
- this.setAgentAdvertisedModels([]);
5117
- }
5118
- if (this.agentAdvertisedModes.length > 0) {
5119
- this.setAgentAdvertisedModes([]);
5120
- }
5722
+ this.currentModel = fresh.initialModel;
5723
+ this.currentMode = fresh.initialMode;
5724
+ this.setAgentAdvertisedModels(fresh.initialModels ?? []);
5725
+ this.setAgentAdvertisedModes(fresh.initialModes ?? []);
5121
5726
  await oldAgent.kill().catch(() => void 0);
5122
5727
  if (transcript) {
5123
5728
  await this.runInternalPrompt(transcript).catch(() => void 0);
@@ -5141,7 +5746,7 @@ ${body}
5141
5746
  // down any in-flight request as a side effect. The record is kept
5142
5747
  // (deleteRecord:false) so the session goes cold and can be resurrected.
5143
5748
  // Returns end_turn so the prompt() caller's response resolves normally,
5144
- // but every attached client has already received hydra-acp/session_closed
5749
+ // but every attached client has already received hydra-acp/session/closed
5145
5750
  // by the time this returns.
5146
5751
  async runKillCommand() {
5147
5752
  await this.close({ deleteRecord: false });
@@ -5159,45 +5764,71 @@ ${body}
5159
5764
  JsonRpcErrorCodes.InternalError
5160
5765
  );
5161
5766
  }
5162
- const spawnAgent = this.spawnReplacementAgent;
5163
- const agentId = this.agentId;
5164
5767
  return this.enqueuePrompt(async () => {
5165
- const transcript = await this.buildSwitchTranscript(agentId);
5166
- const fresh = await spawnAgent({
5167
- agentId,
5168
- cwd: this.cwd,
5169
- agentArgs: this.agentArgs
5170
- });
5171
- this.accumulateAndResetCost();
5172
- this.wireAgent(fresh.agent);
5173
- const oldAgent = this.agent;
5174
- this.agent = fresh.agent;
5175
- this.upstreamSessionId = fresh.upstreamSessionId;
5176
- this.agentMeta = fresh.agentMeta;
5177
- this.agentCapabilities = fresh.agentCapabilities;
5178
- this.agentAdvertisedCommands = [];
5179
- this.broadcastMergedCommands();
5180
- if (this.agentAdvertisedModels.length > 0) {
5181
- this.setAgentAdvertisedModels([]);
5182
- }
5183
- if (this.agentAdvertisedModes.length > 0) {
5184
- this.setAgentAdvertisedModes([]);
5185
- }
5186
- await oldAgent.kill().catch(() => void 0);
5187
- if (transcript) {
5188
- await this.runInternalPrompt(transcript).catch(() => void 0);
5189
- }
5190
- this.broadcastAgentSwitch(agentId, agentId);
5191
- const info = { agentId, upstreamSessionId: this.upstreamSessionId };
5192
- for (const handler of this.agentChangeHandlers) {
5193
- try {
5194
- handler(info);
5195
- } catch {
5196
- }
5197
- }
5768
+ await this.respawnAgent();
5198
5769
  return { stopReason: "end_turn" };
5199
5770
  });
5200
5771
  }
5772
+ // Last-resort cancellation. When an agent ignores session/cancel (current
5773
+ // opencode returns UnsupportedOperation and keeps generating), the only
5774
+ // way to actually stop the turn is to tear the subprocess down. Rather
5775
+ // than respawn + replay the transcript (slow, and renders as an agent
5776
+ // "switch"), we close the session keeping its record: agent.kill() aborts
5777
+ // the in-flight turn, and the next prompt auto-resurrects via session/load
5778
+ // (cheap — the agent restores its own context). The forceCancelling flag
5779
+ // makes runQueueEntry render the aborted turn as "cancelled" rather than a
5780
+ // raw connection error. Runs OUTSIDE the prompt queue so a wedged turn
5781
+ // can't block it.
5782
+ async forceCancel() {
5783
+ if (this.closed || this.closing) {
5784
+ throw withCode(
5785
+ new Error("session is closing"),
5786
+ JsonRpcErrorCodes.SessionClosing
5787
+ );
5788
+ }
5789
+ this.lastCancelAt = 0;
5790
+ this.forceCancelling = true;
5791
+ await this.close({ deleteRecord: false });
5792
+ return { stopReason: "cancelled" };
5793
+ }
5794
+ // Shared kill-and-respawn used by /hydra restart (queued) and forceCancel
5795
+ // (immediate). Spawns a fresh agent, swaps it in, kills the old one, and
5796
+ // re-seeds the conversation transcript so the new process has context.
5797
+ async respawnAgent() {
5798
+ const spawnAgent = this.spawnReplacementAgent;
5799
+ const agentId = this.agentId;
5800
+ const transcript = await this.buildSwitchTranscript(agentId);
5801
+ const fresh = await spawnAgent({
5802
+ agentId,
5803
+ cwd: this.cwd,
5804
+ agentArgs: this.agentArgs
5805
+ });
5806
+ this.accumulateAndResetCost();
5807
+ this.wireAgent(fresh.agent);
5808
+ const oldAgent = this.agent;
5809
+ this.agent = fresh.agent;
5810
+ this.upstreamSessionId = fresh.upstreamSessionId;
5811
+ this.agentMeta = fresh.agentMeta;
5812
+ this.agentCapabilities = fresh.agentCapabilities;
5813
+ this.agentAdvertisedCommands = [];
5814
+ this.broadcastMergedCommands();
5815
+ this.currentModel = fresh.initialModel;
5816
+ this.currentMode = fresh.initialMode;
5817
+ this.setAgentAdvertisedModels(fresh.initialModels ?? []);
5818
+ this.setAgentAdvertisedModes(fresh.initialModes ?? []);
5819
+ await oldAgent.kill().catch(() => void 0);
5820
+ if (transcript) {
5821
+ await this.runInternalPrompt(transcript).catch(() => void 0);
5822
+ }
5823
+ this.broadcastAgentSwitch(agentId, agentId);
5824
+ const info = { agentId, upstreamSessionId: this.upstreamSessionId };
5825
+ for (const handler of this.agentChangeHandlers) {
5826
+ try {
5827
+ handler(info);
5828
+ } catch {
5829
+ }
5830
+ }
5831
+ }
5201
5832
  // Walk the persisted history and produce a labeled transcript suitable
5202
5833
  // for handing to a fresh agent. Includes user prompts, agent replies,
5203
5834
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
@@ -5298,15 +5929,13 @@ ${body}
5298
5929
  return void 0;
5299
5930
  });
5300
5931
  }
5301
- // Tell every attached client (a) the agent identity has changed
5302
- // (session_info_update carrying agentId inside _meta["hydra-acp"] —
5932
+ // Tell every attached client the agent identity has changed via
5933
+ // session_info_update carrying agentId inside _meta["hydra-acp"] —
5303
5934
  // the ACP schema for session_info_update is just title/updatedAt/_meta,
5304
5935
  // so non-hydra clients harmlessly ignore the extension; hydra-aware
5305
- // ones read it and relabel) and (b) drop a visible banner into the
5306
- // transcript so users see the switch rather than just suddenly getting
5307
- // answers from a different agent. Both updates carry synthetic=true
5308
- // so a future /hydra agent's transcript builder filters them out.
5309
- broadcastAgentSwitch(oldAgentId, newAgentId) {
5936
+ // ones read it and relabel. synthetic=true so a future /hydra agent's
5937
+ // transcript builder filters it out.
5938
+ broadcastAgentSwitch(_oldAgentId, newAgentId) {
5310
5939
  this.recordAndBroadcast("session/update", {
5311
5940
  sessionId: this.sessionId,
5312
5941
  update: {
@@ -5314,19 +5943,7 @@ ${body}
5314
5943
  _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
5315
5944
  }
5316
5945
  });
5317
- this.recordAndBroadcast("session/update", {
5318
- sessionId: this.sessionId,
5319
- update: {
5320
- sessionUpdate: "agent_message_chunk",
5321
- content: {
5322
- type: "text",
5323
- text: `
5324
- _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5325
- `
5326
- },
5327
- _meta: { "hydra-acp": { synthetic: true } }
5328
- }
5329
- });
5946
+ this.broadcastConfigOptions();
5330
5947
  }
5331
5948
  // stdin-stream lifecycle. Cat --stream calls openStream() once after
5332
5949
  // session/new, then forwards stdin chunks via streamWrite(). The agent
@@ -5457,7 +6074,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5457
6074
  requireStreamBuffer() {
5458
6075
  if (this.streamBuffer === void 0) {
5459
6076
  const err = new Error(
5460
- `session ${this.sessionId} has no stream buffer; call hydra-acp/stream_open first`
6077
+ `session ${this.sessionId} has no stream buffer; POST /v1/sessions/:id/stdin/open first`
5461
6078
  );
5462
6079
  err.code = JsonRpcErrorCodes.StreamNotEnabled;
5463
6080
  throw err;
@@ -5500,7 +6117,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5500
6117
  const sessionId = this.sessionId;
5501
6118
  this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
5502
6119
  for (const client of this.clients.values()) {
5503
- void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
6120
+ void client.connection.notify("hydra-acp/session/closed", { sessionId: this.sessionId }).catch(() => void 0);
5504
6121
  }
5505
6122
  this.clients.clear();
5506
6123
  if (this.streamBuffer !== void 0) {
@@ -5607,7 +6224,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5607
6224
  if (!t.intercepts.has(intercept)) {
5608
6225
  continue;
5609
6226
  }
5610
- void t.connection.notify("transformer/session_event", {
6227
+ void t.connection.notify("hydra-acp/transformer/session_event", {
5611
6228
  event,
5612
6229
  sessionId: this.sessionId,
5613
6230
  payload
@@ -5887,6 +6504,10 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5887
6504
  }
5888
6505
  );
5889
6506
  } catch (err) {
6507
+ if (this.forceCancelling) {
6508
+ this.clearAmendIfMatches(entry.messageId);
6509
+ return { stopReason: "cancelled" };
6510
+ }
5890
6511
  if (!this.closed) {
5891
6512
  this.broadcastTurnComplete(
5892
6513
  entry.clientId,
@@ -6002,6 +6623,31 @@ function parseModelsList(list) {
6002
6623
  }
6003
6624
  return out;
6004
6625
  }
6626
+ function parseModesList(list) {
6627
+ if (!Array.isArray(list)) {
6628
+ return [];
6629
+ }
6630
+ const out = [];
6631
+ for (const raw of list) {
6632
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
6633
+ continue;
6634
+ }
6635
+ const r = raw;
6636
+ const id = typeof r.value === "string" && r.value.trim() || typeof r.id === "string" && r.id.trim() || void 0;
6637
+ if (!id) {
6638
+ continue;
6639
+ }
6640
+ const mode = { id };
6641
+ if (typeof r.name === "string" && r.name.length > 0) {
6642
+ mode.name = r.name;
6643
+ }
6644
+ if (typeof r.description === "string" && r.description.length > 0) {
6645
+ mode.description = r.description;
6646
+ }
6647
+ out.push(mode);
6648
+ }
6649
+ return out;
6650
+ }
6005
6651
  function extractAdvertisedModes(params) {
6006
6652
  const obj = params ?? {};
6007
6653
  const update = obj.update ?? {};
@@ -6193,7 +6839,7 @@ function firstLine(text, max) {
6193
6839
  }
6194
6840
 
6195
6841
  // src/core/session-store.ts
6196
- import * as fs8 from "fs/promises";
6842
+ import * as fs9 from "fs/promises";
6197
6843
  import * as path6 from "path";
6198
6844
  import { customAlphabet as customAlphabet2 } from "nanoid";
6199
6845
  import { z as z5 } from "zod";
@@ -6378,9 +7024,9 @@ var SessionRecord = z5.object({
6378
7024
  // memory. Cleared after that first resurrect completes.
6379
7025
  pendingHistorySync: z5.boolean().optional(),
6380
7026
  // Set when this session was spawned as a child by a transformer via
6381
- // hydra-acp/spawn_child_session. Points to the spawning session's id.
7027
+ // hydra-acp/child_session/spawn. Points to the spawning session's id.
6382
7028
  parentSessionId: z5.string().optional(),
6383
- // Set when this session was created by hydra-acp/fork_session.
7029
+ // Set when this session was created by hydra-acp/session/fork.
6384
7030
  // forkedFromSessionId points to the local source session; forkedFromMessageId
6385
7031
  // is the resolved forkAt — the messageId of the turn_complete the slice
6386
7032
  // ended at. Kept so future UI can show "branched from turn N of session X".
@@ -6400,9 +7046,9 @@ var SessionRecord = z5.object({
6400
7046
  createdAt: z5.string(),
6401
7047
  updatedAt: z5.string()
6402
7048
  });
6403
- var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7049
+ var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
6404
7050
  function assertSafeId(id) {
6405
- if (!SESSION_ID_PATTERN.test(id)) {
7051
+ if (!SESSION_ID_PATTERN2.test(id)) {
6406
7052
  throw new Error(`unsafe session id: ${id}`);
6407
7053
  }
6408
7054
  }
@@ -6415,7 +7061,7 @@ var SessionStore = class {
6415
7061
  });
6416
7062
  }
6417
7063
  async read(sessionId) {
6418
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7064
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6419
7065
  return void 0;
6420
7066
  }
6421
7067
  const parsed = await readJsonSafe(paths.sessionFile(sessionId));
@@ -6429,11 +7075,11 @@ var SessionStore = class {
6429
7075
  }
6430
7076
  }
6431
7077
  async delete(sessionId) {
6432
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7078
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6433
7079
  return;
6434
7080
  }
6435
7081
  try {
6436
- await fs8.unlink(paths.sessionFile(sessionId));
7082
+ await fs9.unlink(paths.sessionFile(sessionId));
6437
7083
  } catch (err) {
6438
7084
  const e = err;
6439
7085
  if (e.code !== "ENOENT") {
@@ -6441,7 +7087,7 @@ var SessionStore = class {
6441
7087
  }
6442
7088
  }
6443
7089
  try {
6444
- await fs8.rmdir(paths.sessionDir(sessionId));
7090
+ await fs9.rmdir(paths.sessionDir(sessionId));
6445
7091
  } catch (err) {
6446
7092
  const e = err;
6447
7093
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -6471,7 +7117,7 @@ var SessionStore = class {
6471
7117
  async list() {
6472
7118
  let entries;
6473
7119
  try {
6474
- entries = await fs8.readdir(paths.sessionsDir());
7120
+ entries = await fs9.readdir(paths.sessionsDir());
6475
7121
  } catch (err) {
6476
7122
  const e = err;
6477
7123
  if (e.code === "ENOENT") {
@@ -6521,20 +7167,167 @@ function recordFromMemorySession(args) {
6521
7167
  };
6522
7168
  }
6523
7169
 
6524
- // src/core/synopsis-coordinator.ts
7170
+ // src/core/tombstone-store.ts
6525
7171
  import * as fs10 from "fs/promises";
7172
+ import { z as z6 } from "zod";
7173
+ var Tombstone = z6.object({
7174
+ version: z6.literal(1),
7175
+ agentId: z6.string(),
7176
+ upstreamSessionId: z6.string(),
7177
+ deletedAt: z6.string(),
7178
+ // Agent's last-reported updatedAt for this session at the moment we
7179
+ // deleted, snapshotted from SessionRecord.updatedAt. Compared against
7180
+ // the listing's updatedAt on subsequent syncs to detect that the
7181
+ // conversation has moved on (the agent / user revived it), in which
7182
+ // case the tombstone is dropped and the session re-imports. Absent
7183
+ // when the deleted record never carried a meaningful updatedAt.
7184
+ upstreamUpdatedAt: z6.string().optional(),
7185
+ cwd: z6.string().optional(),
7186
+ title: z6.string().optional(),
7187
+ reason: z6.enum(["user", "expired"]).optional()
7188
+ });
7189
+ var TombstoneStore = class {
7190
+ async add(t) {
7191
+ const full = { version: 1, ...t };
7192
+ await writeJsonAtomic(
7193
+ paths.tombstoneFile(t.agentId, t.upstreamSessionId),
7194
+ full,
7195
+ { mode: 384 }
7196
+ );
7197
+ }
7198
+ async has(agentId, upstreamSessionId) {
7199
+ try {
7200
+ await fs10.access(paths.tombstoneFile(agentId, upstreamSessionId));
7201
+ return true;
7202
+ } catch {
7203
+ return false;
7204
+ }
7205
+ }
7206
+ // Returns the tombstone payload if the file exists. An unreadable or
7207
+ // unparseable file still counts as a tombstone — we synthesize a
7208
+ // bare record so the caller's "is this dead?" check stays correct,
7209
+ // but with no upstreamUpdatedAt the resurrection rule treats any
7210
+ // listed updatedAt as advancement (see SessionManager.syncFromAgent).
7211
+ async read(agentId, upstreamSessionId) {
7212
+ const file = paths.tombstoneFile(agentId, upstreamSessionId);
7213
+ const parsed = await readJsonSafe(file);
7214
+ if (parsed === void 0) {
7215
+ if (await this.has(agentId, upstreamSessionId)) {
7216
+ return {
7217
+ version: 1,
7218
+ agentId,
7219
+ upstreamSessionId,
7220
+ deletedAt: (/* @__PURE__ */ new Date(0)).toISOString()
7221
+ };
7222
+ }
7223
+ return void 0;
7224
+ }
7225
+ try {
7226
+ return Tombstone.parse(parsed);
7227
+ } catch {
7228
+ return {
7229
+ version: 1,
7230
+ agentId,
7231
+ upstreamSessionId,
7232
+ deletedAt: (/* @__PURE__ */ new Date(0)).toISOString()
7233
+ };
7234
+ }
7235
+ }
7236
+ async remove(agentId, upstreamSessionId) {
7237
+ try {
7238
+ await fs10.unlink(paths.tombstoneFile(agentId, upstreamSessionId));
7239
+ } catch (err) {
7240
+ const e = err;
7241
+ if (e.code !== "ENOENT") {
7242
+ throw err;
7243
+ }
7244
+ }
7245
+ try {
7246
+ await fs10.rmdir(paths.tombstoneAgentDir(agentId));
7247
+ } catch (err) {
7248
+ const e = err;
7249
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
7250
+ throw err;
7251
+ }
7252
+ }
7253
+ }
7254
+ async list(agentId) {
7255
+ if (agentId !== void 0) {
7256
+ return this.listForAgent(agentId);
7257
+ }
7258
+ let agents;
7259
+ try {
7260
+ agents = await fs10.readdir(paths.tombstonesDir());
7261
+ } catch (err) {
7262
+ const e = err;
7263
+ if (e.code === "ENOENT") {
7264
+ return [];
7265
+ }
7266
+ throw err;
7267
+ }
7268
+ const out = [];
7269
+ for (const enc of agents) {
7270
+ let decoded;
7271
+ try {
7272
+ decoded = decodeURIComponent(enc);
7273
+ } catch {
7274
+ continue;
7275
+ }
7276
+ out.push(...await this.listForAgent(decoded));
7277
+ }
7278
+ return out;
7279
+ }
7280
+ async listForAgent(agentId) {
7281
+ let files;
7282
+ try {
7283
+ files = await fs10.readdir(paths.tombstoneAgentDir(agentId));
7284
+ } catch (err) {
7285
+ const e = err;
7286
+ if (e.code === "ENOENT") {
7287
+ return [];
7288
+ }
7289
+ throw err;
7290
+ }
7291
+ const out = [];
7292
+ for (const f of files) {
7293
+ let upstreamId;
7294
+ try {
7295
+ upstreamId = decodeURIComponent(f);
7296
+ } catch {
7297
+ continue;
7298
+ }
7299
+ const t = await this.read(agentId, upstreamId);
7300
+ if (t) {
7301
+ out.push(t);
7302
+ }
7303
+ }
7304
+ return out;
7305
+ }
7306
+ };
7307
+ function shouldResurrectFromUpstream(tombstone, listingUpdatedAt) {
7308
+ if (listingUpdatedAt === void 0) {
7309
+ return false;
7310
+ }
7311
+ if (tombstone.upstreamUpdatedAt === void 0) {
7312
+ return true;
7313
+ }
7314
+ return listingUpdatedAt > tombstone.upstreamUpdatedAt;
7315
+ }
7316
+
7317
+ // src/core/synopsis-coordinator.ts
7318
+ import * as fs12 from "fs/promises";
6526
7319
 
6527
7320
  // src/core/hydra-version.ts
6528
7321
  import { fileURLToPath } from "url";
6529
7322
  import * as path7 from "path";
6530
- import * as fs9 from "fs";
7323
+ import * as fs11 from "fs";
6531
7324
  function resolveVersion() {
6532
7325
  try {
6533
7326
  let dir = path7.dirname(fileURLToPath(import.meta.url));
6534
7327
  for (let i = 0; i < 8; i += 1) {
6535
7328
  const candidate = path7.join(dir, "package.json");
6536
- if (fs9.existsSync(candidate)) {
6537
- const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
7329
+ if (fs11.existsSync(candidate)) {
7330
+ const pkg = JSON.parse(fs11.readFileSync(candidate, "utf8"));
6538
7331
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
6539
7332
  return pkg.version;
6540
7333
  }
@@ -7058,7 +7851,7 @@ var SynopsisCoordinator = class {
7058
7851
  });
7059
7852
  const modelId = this.opts.synopsisModel;
7060
7853
  const synopsisCwd = paths.sessionDir(sessionId);
7061
- await fs10.mkdir(synopsisCwd, { recursive: true }).catch(() => void 0);
7854
+ await fs12.mkdir(synopsisCwd, { recursive: true }).catch(() => void 0);
7062
7855
  this.opts.logger?.info(
7063
7856
  `synopsis: start sessionId=${sessionId} agentId=${synopsisAgentId} historyLen=${history.length} model=${JSON.stringify(modelId ?? "(default)")} cwd=${synopsisCwd}`
7064
7857
  );
@@ -7161,8 +7954,217 @@ function describeFields(s) {
7161
7954
  }
7162
7955
 
7163
7956
  // src/core/history-store.ts
7164
- import * as fs11 from "fs/promises";
7165
- var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
7957
+ import * as fs13 from "fs/promises";
7958
+
7959
+ // src/core/tool-content.ts
7960
+ function parseToolContentMode(raw) {
7961
+ return raw === "summary" ? "summary" : "inline";
7962
+ }
7963
+ var SUMMARY_TEXT_CAP = 256;
7964
+ function applyToolContentMode(entries, mode) {
7965
+ if (mode !== "summary") {
7966
+ return entries;
7967
+ }
7968
+ return entries.map(summarizeEntry);
7969
+ }
7970
+ function summarizeEntry(entry) {
7971
+ if (entry.method !== "session/update") {
7972
+ return entry;
7973
+ }
7974
+ const params = entry.params;
7975
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
7976
+ return entry;
7977
+ }
7978
+ const p = params;
7979
+ const update = p.update;
7980
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
7981
+ return entry;
7982
+ }
7983
+ const u = update;
7984
+ if (u.sessionUpdate !== "tool_call" && u.sessionUpdate !== "tool_call_update") {
7985
+ return entry;
7986
+ }
7987
+ const newUpdate = { ...u };
7988
+ if (Array.isArray(u.content)) {
7989
+ newUpdate.content = u.content.map(summarizeBlock);
7990
+ }
7991
+ const rawOutput = u.rawOutput;
7992
+ if (rawOutput && typeof rawOutput === "object" && !Array.isArray(rawOutput)) {
7993
+ const ro = rawOutput;
7994
+ const slim = {};
7995
+ if (ro.error !== void 0) {
7996
+ slim.error = clip(ro.error);
7997
+ }
7998
+ if (ro.metadata !== void 0) {
7999
+ slim.metadata = ro.metadata;
8000
+ }
8001
+ newUpdate.rawOutput = slim;
8002
+ }
8003
+ return { ...entry, params: { ...p, update: newUpdate } };
8004
+ }
8005
+ function isDiffBlock(block) {
8006
+ return !!block && typeof block === "object" && !Array.isArray(block) && block.type === "diff";
8007
+ }
8008
+ function summarizeBlock(block) {
8009
+ if (isDiffBlock(block)) {
8010
+ const b2 = block;
8011
+ const out2 = {
8012
+ type: "diff",
8013
+ oldText: "",
8014
+ newText: ""
8015
+ };
8016
+ if (typeof b2.path === "string") {
8017
+ out2.path = b2.path;
8018
+ }
8019
+ return out2;
8020
+ }
8021
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
8022
+ return block;
8023
+ }
8024
+ const b = block;
8025
+ const out = { ...b };
8026
+ if (typeof b.text === "string") {
8027
+ out.text = clip(b.text);
8028
+ }
8029
+ if (typeof b.content === "string") {
8030
+ out.content = clip(b.content);
8031
+ } else if (b.content && typeof b.content === "object") {
8032
+ out.content = summarizeBlock(b.content);
8033
+ }
8034
+ return out;
8035
+ }
8036
+ function clip(value) {
8037
+ if (typeof value === "string" && value.length > SUMMARY_TEXT_CAP) {
8038
+ const elided = value.length - SUMMARY_TEXT_CAP;
8039
+ return `${value.slice(0, SUMMARY_TEXT_CAP)}\u2026[+${elided} chars omitted from summary export]`;
8040
+ }
8041
+ return value;
8042
+ }
8043
+ var TOOL_BLOB_THRESHOLD = 2048;
8044
+ function collectToolBlobHashes(entries) {
8045
+ const out = /* @__PURE__ */ new Set();
8046
+ const walk = (value) => {
8047
+ if (isToolBlobRef(value)) {
8048
+ out.add(value.__hydraBlob);
8049
+ return;
8050
+ }
8051
+ if (Array.isArray(value)) {
8052
+ for (const item of value) {
8053
+ walk(item);
8054
+ }
8055
+ return;
8056
+ }
8057
+ if (value && typeof value === "object") {
8058
+ for (const v of Object.values(value)) {
8059
+ walk(v);
8060
+ }
8061
+ }
8062
+ };
8063
+ for (const entry of entries) {
8064
+ walk(entry.params);
8065
+ }
8066
+ return out;
8067
+ }
8068
+ function isToolBlobRef(value) {
8069
+ return !!value && typeof value === "object" && !Array.isArray(value) && typeof value.__hydraBlob === "string";
8070
+ }
8071
+ function isToolEntry(entry) {
8072
+ if (entry.method !== "session/update") {
8073
+ return false;
8074
+ }
8075
+ const params = entry.params;
8076
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
8077
+ return false;
8078
+ }
8079
+ const update = params.update;
8080
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
8081
+ return false;
8082
+ }
8083
+ const kind = update.sessionUpdate;
8084
+ return kind === "tool_call" || kind === "tool_call_update";
8085
+ }
8086
+ async function externalizeToolEntry(entry, put) {
8087
+ if (!isToolEntry(entry)) {
8088
+ return entry;
8089
+ }
8090
+ const p = entry.params;
8091
+ const update = p.update;
8092
+ const newUpdate = await deepExternalize(update, put);
8093
+ return { ...entry, params: { ...p, update: newUpdate } };
8094
+ }
8095
+ async function deepExternalize(value, put) {
8096
+ if (typeof value === "string") {
8097
+ if (value.length <= TOOL_BLOB_THRESHOLD) {
8098
+ return value;
8099
+ }
8100
+ const hash = await put(value);
8101
+ if (hash === null) {
8102
+ return value;
8103
+ }
8104
+ const ref = { __hydraBlob: hash, bytes: value.length };
8105
+ return ref;
8106
+ }
8107
+ if (Array.isArray(value)) {
8108
+ const out = [];
8109
+ for (const item of value) {
8110
+ out.push(await deepExternalize(item, put));
8111
+ }
8112
+ return out;
8113
+ }
8114
+ if (value && typeof value === "object") {
8115
+ const out = {};
8116
+ for (const [k, v] of Object.entries(value)) {
8117
+ out[k] = await deepExternalize(v, put);
8118
+ }
8119
+ return out;
8120
+ }
8121
+ return value;
8122
+ }
8123
+ async function expandToolRefs(entry, get) {
8124
+ const params = entry.params;
8125
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
8126
+ return entry;
8127
+ }
8128
+ const expanded = await deepExpand(params, get);
8129
+ if (expanded === params) {
8130
+ return entry;
8131
+ }
8132
+ return { ...entry, params: expanded };
8133
+ }
8134
+ async function deepExpand(value, get) {
8135
+ if (isToolBlobRef(value)) {
8136
+ const text = await get(value.__hydraBlob);
8137
+ return text ?? "";
8138
+ }
8139
+ if (Array.isArray(value)) {
8140
+ let changed = false;
8141
+ const out = [];
8142
+ for (const item of value) {
8143
+ const next = await deepExpand(item, get);
8144
+ if (next !== item) {
8145
+ changed = true;
8146
+ }
8147
+ out.push(next);
8148
+ }
8149
+ return changed ? out : value;
8150
+ }
8151
+ if (value && typeof value === "object") {
8152
+ let changed = false;
8153
+ const out = {};
8154
+ for (const [k, v] of Object.entries(value)) {
8155
+ const next = await deepExpand(v, get);
8156
+ if (next !== v) {
8157
+ changed = true;
8158
+ }
8159
+ out[k] = next;
8160
+ }
8161
+ return changed ? out : value;
8162
+ }
8163
+ return value;
8164
+ }
8165
+
8166
+ // src/core/history-store.ts
8167
+ var SESSION_ID_PATTERN3 = /^[A-Za-z0-9_-]+$/;
7166
8168
  var DEFAULT_MAX_ENTRIES = 1e3;
7167
8169
  var HistoryStore = class {
7168
8170
  // Serialize writes per session id so appends and rewrites don't
@@ -7174,26 +8176,34 @@ var HistoryStore = class {
7174
8176
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
7175
8177
  }
7176
8178
  async append(sessionId, entry) {
7177
- if (!SESSION_ID_PATTERN2.test(sessionId)) {
8179
+ if (!SESSION_ID_PATTERN3.test(sessionId)) {
7178
8180
  return;
7179
8181
  }
7180
8182
  return this.enqueue(sessionId, async () => {
7181
- await fs11.mkdir(paths.sessionDir(sessionId), { recursive: true });
7182
- const line = JSON.stringify(entry) + "\n";
7183
- await fs11.appendFile(paths.historyFile(sessionId), line, {
8183
+ await fs13.mkdir(paths.sessionDir(sessionId), { recursive: true });
8184
+ const stored = await externalizeToolEntry(
8185
+ entry,
8186
+ (t) => putToolBlob(sessionId, t)
8187
+ );
8188
+ const line = JSON.stringify(stored) + "\n";
8189
+ await fs13.appendFile(paths.historyFile(sessionId), line, {
7184
8190
  encoding: "utf8",
7185
8191
  mode: 384
7186
8192
  });
7187
8193
  });
7188
8194
  }
7189
8195
  async rewrite(sessionId, entries) {
7190
- if (!SESSION_ID_PATTERN2.test(sessionId)) {
8196
+ if (!SESSION_ID_PATTERN3.test(sessionId)) {
7191
8197
  return;
7192
8198
  }
7193
8199
  return this.enqueue(sessionId, async () => {
7194
- await fs11.mkdir(paths.sessionDir(sessionId), { recursive: true });
7195
- const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
7196
- await fs11.writeFile(paths.historyFile(sessionId), body, {
8200
+ await fs13.mkdir(paths.sessionDir(sessionId), { recursive: true });
8201
+ const stored = [];
8202
+ for (const e of entries) {
8203
+ stored.push(await externalizeToolEntry(e, (t) => putToolBlob(sessionId, t)));
8204
+ }
8205
+ const body = stored.length === 0 ? "" : stored.map((e) => JSON.stringify(e)).join("\n") + "\n";
8206
+ await fs13.writeFile(paths.historyFile(sessionId), body, {
7197
8207
  encoding: "utf8",
7198
8208
  mode: 384
7199
8209
  });
@@ -7204,13 +8214,13 @@ var HistoryStore = class {
7204
8214
  // it's safe to invoke alongside ongoing writes; a no-op if the file is
7205
8215
  // already at or below the cap.
7206
8216
  async compact(sessionId, maxEntries) {
7207
- if (!SESSION_ID_PATTERN2.test(sessionId)) {
8217
+ if (!SESSION_ID_PATTERN3.test(sessionId)) {
7208
8218
  return;
7209
8219
  }
7210
8220
  return this.enqueue(sessionId, async () => {
7211
8221
  let raw;
7212
8222
  try {
7213
- raw = await fs11.readFile(paths.historyFile(sessionId), "utf8");
8223
+ raw = await fs13.readFile(paths.historyFile(sessionId), "utf8");
7214
8224
  } catch (err) {
7215
8225
  const e = err;
7216
8226
  if (e.code === "ENOENT") {
@@ -7223,23 +8233,29 @@ var HistoryStore = class {
7223
8233
  return;
7224
8234
  }
7225
8235
  const trimmed = lines.slice(-maxEntries);
7226
- await fs11.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
8236
+ await fs13.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
7227
8237
  encoding: "utf8",
7228
8238
  mode: 384
7229
8239
  });
7230
8240
  });
7231
8241
  }
7232
- async load(sessionId) {
7233
- if (!SESSION_ID_PATTERN2.test(sessionId)) {
8242
+ // `tools` selects how externalized tool content is materialized:
8243
+ // "inline" (default) — expand blob refs back to full content, so every
8244
+ // consumer sees the original recorded shape.
8245
+ // "references" — leave references in place (the lean form) for
8246
+ // clients that fetch tool content on demand.
8247
+ async load(sessionId, opts = {}) {
8248
+ if (!SESSION_ID_PATTERN3.test(sessionId)) {
7234
8249
  return [];
7235
8250
  }
8251
+ const expand = (opts.tools ?? "inline") === "inline";
7236
8252
  const pending = this.writeQueues.get(sessionId);
7237
8253
  if (pending) {
7238
8254
  await pending;
7239
8255
  }
7240
8256
  let raw;
7241
8257
  try {
7242
- raw = await fs11.readFile(paths.historyFile(sessionId), "utf8");
8258
+ raw = await fs13.readFile(paths.historyFile(sessionId), "utf8");
7243
8259
  } catch (err) {
7244
8260
  const e = err;
7245
8261
  if (e.code === "ENOENT") {
@@ -7274,10 +8290,25 @@ var HistoryStore = class {
7274
8290
  recordedAt: obj.recordedAt
7275
8291
  });
7276
8292
  }
7277
- if (out.length > this.maxEntries) {
7278
- return out.slice(-this.maxEntries);
8293
+ const kept = out.length > this.maxEntries ? out.slice(-this.maxEntries) : out;
8294
+ if (!expand) {
8295
+ return kept;
7279
8296
  }
7280
- return out;
8297
+ const blobCache = /* @__PURE__ */ new Map();
8298
+ const get = async (hash) => {
8299
+ const cached = blobCache.get(hash);
8300
+ if (cached !== void 0) {
8301
+ return cached;
8302
+ }
8303
+ const value = await getToolBlob(sessionId, hash);
8304
+ blobCache.set(hash, value);
8305
+ return value;
8306
+ };
8307
+ const inlined = [];
8308
+ for (const entry of kept) {
8309
+ inlined.push(await expandToolRefs(entry, get));
8310
+ }
8311
+ return inlined;
7281
8312
  }
7282
8313
  // Wait for every pending append/rewrite/compact across all sessions to
7283
8314
  // settle. Daemon shutdown calls this after closing sessions so the final
@@ -7293,20 +8324,21 @@ var HistoryStore = class {
7293
8324
  await Promise.allSettled(pending);
7294
8325
  }
7295
8326
  async delete(sessionId) {
7296
- if (!SESSION_ID_PATTERN2.test(sessionId)) {
8327
+ if (!SESSION_ID_PATTERN3.test(sessionId)) {
7297
8328
  return;
7298
8329
  }
7299
8330
  return this.enqueue(sessionId, async () => {
7300
8331
  try {
7301
- await fs11.unlink(paths.historyFile(sessionId));
8332
+ await fs13.unlink(paths.historyFile(sessionId));
7302
8333
  } catch (err) {
7303
8334
  const e = err;
7304
8335
  if (e.code !== "ENOENT") {
7305
8336
  throw err;
7306
8337
  }
7307
8338
  }
8339
+ await deleteToolBlobs(sessionId);
7308
8340
  try {
7309
- await fs11.rmdir(paths.sessionDir(sessionId));
8341
+ await fs13.rmdir(paths.sessionDir(sessionId));
7310
8342
  } catch (err) {
7311
8343
  const e = err;
7312
8344
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -7330,74 +8362,79 @@ var HistoryStore = class {
7330
8362
  };
7331
8363
 
7332
8364
  // src/tui/history.ts
7333
- import { promises as fs12 } from "fs";
8365
+ import { promises as fs14 } from "fs";
7334
8366
  import * as path8 from "path";
7335
8367
  async function saveHistory(file, history) {
7336
- await fs12.mkdir(path8.dirname(file), { recursive: true });
8368
+ await fs14.mkdir(path8.dirname(file), { recursive: true });
7337
8369
  const lines = history.map((entry) => JSON.stringify(entry));
7338
- await fs12.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
8370
+ await fs14.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
7339
8371
  }
7340
8372
 
7341
8373
  // src/core/bundle.ts
7342
- import { z as z6 } from "zod";
7343
- var HistoryEntrySchema = z6.object({
7344
- method: z6.string(),
7345
- params: z6.unknown(),
7346
- recordedAt: z6.number()
8374
+ import { z as z7 } from "zod";
8375
+ var HistoryEntrySchema = z7.object({
8376
+ method: z7.string(),
8377
+ params: z7.unknown(),
8378
+ recordedAt: z7.number()
7347
8379
  });
7348
- var BundleSession = z6.object({
8380
+ var BundleSession = z7.object({
7349
8381
  // The exporter's local id. Regenerated fresh on import (sessionId is
7350
8382
  // the local namespace; lineageId is what survives across hops).
7351
- sessionId: z6.string(),
8383
+ sessionId: z7.string(),
7352
8384
  // Required on bundles — the export path backfills if the source
7353
8385
  // record was written before lineageId existed.
7354
- lineageId: z6.string(),
8386
+ lineageId: z7.string(),
7355
8387
  // The exporter's agent-side session id at export time. Carried so
7356
8388
  // importers can persist it as a breadcrumb (and, eventually, as the
7357
8389
  // handle a "connect back to origin" feature would need). Omitted on
7358
8390
  // bundles whose source record never bound to an agent (e.g. a
7359
8391
  // re-export of an imported, not-yet-attached session).
7360
- upstreamSessionId: z6.string().optional(),
7361
- agentId: z6.string(),
7362
- cwd: z6.string(),
7363
- title: z6.string().optional(),
8392
+ upstreamSessionId: z7.string().optional(),
8393
+ agentId: z7.string(),
8394
+ cwd: z7.string(),
8395
+ title: z7.string().optional(),
7364
8396
  // Structured snapshot. Carried across export/import so a sync'd
7365
8397
  // session on another machine surfaces the same synopsis in its
7366
8398
  // picker / list / info views without re-asking the agent.
7367
8399
  synopsis: SessionSynopsis.optional(),
7368
- summarizedThroughEntry: z6.number().int().nonnegative().optional(),
7369
- currentModel: z6.string().optional(),
7370
- currentMode: z6.string().optional(),
8400
+ summarizedThroughEntry: z7.number().int().nonnegative().optional(),
8401
+ currentModel: z7.string().optional(),
8402
+ currentMode: z7.string().optional(),
7371
8403
  currentUsage: PersistedUsage.optional(),
7372
- agentCommands: z6.array(PersistedAgentCommand).optional(),
7373
- agentModes: z6.array(PersistedAgentMode).optional(),
8404
+ agentCommands: z7.array(PersistedAgentCommand).optional(),
8405
+ agentModes: z7.array(PersistedAgentMode).optional(),
7374
8406
  // Raw interactive tristate (NOT the resolved effectiveInteractive) so
7375
8407
  // the value stays promotable on the destination: a cat/empty source
7376
8408
  // arrives as undefined and a real turn there can still flip it to
7377
8409
  // true. Carried alongside originatingClient so the importer's
7378
8410
  // effectiveInteractive can re-apply the cat-name hint at read time
7379
8411
  // without freezing a sticky `false` into the record.
7380
- interactive: z6.boolean().optional(),
8412
+ interactive: z7.boolean().optional(),
7381
8413
  originatingClient: PersistedOriginatingClient.optional(),
7382
- createdAt: z6.string(),
7383
- updatedAt: z6.string()
8414
+ createdAt: z7.string(),
8415
+ updatedAt: z7.string()
7384
8416
  });
7385
- var Bundle = z6.object({
7386
- version: z6.literal(1),
7387
- exportedAt: z6.string(),
7388
- exportedFrom: z6.object({
7389
- hydraVersion: z6.string(),
7390
- machine: z6.string(),
8417
+ var Bundle = z7.object({
8418
+ version: z7.literal(1),
8419
+ exportedAt: z7.string(),
8420
+ exportedFrom: z7.object({
8421
+ hydraVersion: z7.string(),
8422
+ machine: z7.string(),
7391
8423
  // Externally-reachable name (and optional ":port") for the exporting
7392
8424
  // daemon, sourced from config.daemon.publicHost (or daemon.host when
7393
8425
  // non-loopback). Carried so an importer can construct a hydra:// URL
7394
8426
  // that dials back to the origin — e.g. over Tailscale. Omitted when
7395
8427
  // the exporter has no routable address; never falls back to loopback.
7396
- hydraHost: z6.string().optional()
8428
+ hydraHost: z7.string().optional()
7397
8429
  }),
7398
8430
  session: BundleSession,
7399
- history: z6.array(HistoryEntrySchema),
7400
- promptHistory: z6.array(z6.string()).optional()
8431
+ history: z7.array(HistoryEntrySchema),
8432
+ promptHistory: z7.array(z7.string()).optional(),
8433
+ // Externalized tool-content blobs referenced by the history, present when
8434
+ // the bundle was exported with tools=references. Map of sha256 → base64
8435
+ // of the gzipped content. Restored to the session's tools/ store on
8436
+ // import; the ref-form history hydrates from them.
8437
+ toolBlobs: z7.record(z7.string()).optional()
7401
8438
  });
7402
8439
  function encodeBundle(params) {
7403
8440
  const bundle = {
@@ -7432,6 +8469,9 @@ function encodeBundle(params) {
7432
8469
  if (params.promptHistory !== void 0) {
7433
8470
  bundle.promptHistory = params.promptHistory;
7434
8471
  }
8472
+ if (params.toolBlobs !== void 0 && Object.keys(params.toolBlobs).length > 0) {
8473
+ bundle.toolBlobs = params.toolBlobs;
8474
+ }
7435
8475
  return bundle;
7436
8476
  }
7437
8477
  function decodeBundle(raw) {
@@ -7447,6 +8487,7 @@ var SessionManager = class {
7447
8487
  this.registry = registry;
7448
8488
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
7449
8489
  this.store = store ?? new SessionStore();
8490
+ this.tombstones = options.tombstones ?? new TombstoneStore();
7450
8491
  this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
7451
8492
  this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
7452
8493
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
@@ -7478,12 +8519,14 @@ var SessionManager = class {
7478
8519
  logger: this.logger,
7479
8520
  npmRegistry: this.npmRegistry
7480
8521
  });
8522
+ void this.refreshAgentCatalog();
7481
8523
  }
7482
8524
  registry;
7483
8525
  sessions = /* @__PURE__ */ new Map();
7484
8526
  resurrectionInflight = /* @__PURE__ */ new Map();
7485
8527
  spawner;
7486
8528
  store;
8529
+ tombstones;
7487
8530
  histories;
7488
8531
  idleTimeoutMs;
7489
8532
  defaultModels;
@@ -7505,6 +8548,25 @@ var SessionManager = class {
7505
8548
  // out-of-band so session close is instant; persists synopsis/title
7506
8549
  // via the same enqueueMetaWrite path the in-session handlers used.
7507
8550
  synopsisCoordinator;
8551
+ // Cached agent catalog used to populate the `agent` config option's
8552
+ // value list. Refreshed lazily (fire-and-forget) since the underlying
8553
+ // registry load may hit the network; sessions read whatever snapshot is
8554
+ // current and always inject their own live agent if it's missing.
8555
+ agentCatalog = [];
8556
+ // Refresh the cached agent catalog from the registry. Fire-and-forget;
8557
+ // failures leave the prior snapshot in place. Called at construction and
8558
+ // after each session creation so the list tracks newly-installed agents.
8559
+ async refreshAgentCatalog() {
8560
+ try {
8561
+ const { agents } = await listAgents(this.registry);
8562
+ this.agentCatalog = agents.map((a) => ({
8563
+ id: a.id,
8564
+ name: a.name,
8565
+ ...a.description !== void 0 ? { description: a.description } : {}
8566
+ }));
8567
+ } catch {
8568
+ }
8569
+ }
7508
8570
  async create(params) {
7509
8571
  const fresh = await this.bootstrapAgent({
7510
8572
  agentId: params.agentId,
@@ -7521,7 +8583,7 @@ var SessionManager = class {
7521
8583
  continue;
7522
8584
  }
7523
8585
  try {
7524
- const result = await t.connection.request("transformer/message", {
8586
+ const result = await t.connection.request("hydra-acp/transformer/message", {
7525
8587
  token: `t_${generateRawSessionId()}`,
7526
8588
  phase: "response",
7527
8589
  method: "initialize",
@@ -7551,6 +8613,7 @@ var SessionManager = class {
7551
8613
  logger: this.logger,
7552
8614
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7553
8615
  listSessions: () => this.list(),
8616
+ availableAgents: () => this.agentCatalog,
7554
8617
  historyStore: this.histories,
7555
8618
  historyMaxEntries: this.sessionHistoryMaxEntries,
7556
8619
  currentModel: fresh.initialModel,
@@ -7718,6 +8781,7 @@ var SessionManager = class {
7718
8781
  logger: this.logger,
7719
8782
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7720
8783
  listSessions: () => this.list(),
8784
+ availableAgents: () => this.agentCatalog,
7721
8785
  historyStore: this.histories,
7722
8786
  historyMaxEntries: this.sessionHistoryMaxEntries,
7723
8787
  currentModel: effectiveModel,
@@ -7798,6 +8862,7 @@ var SessionManager = class {
7798
8862
  logger: this.logger,
7799
8863
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7800
8864
  listSessions: () => this.list(),
8865
+ availableAgents: () => this.agentCatalog,
7801
8866
  historyStore: this.histories,
7802
8867
  historyMaxEntries: this.sessionHistoryMaxEntries,
7803
8868
  currentModel: effectiveModel,
@@ -7821,7 +8886,7 @@ var SessionManager = class {
7821
8886
  }
7822
8887
  async dirExists(cwd) {
7823
8888
  try {
7824
- return (await fs13.stat(cwd)).isDirectory();
8889
+ return (await fs15.stat(cwd)).isDirectory();
7825
8890
  } catch {
7826
8891
  return false;
7827
8892
  }
@@ -7927,7 +8992,7 @@ var SessionManager = class {
7927
8992
  for (const rec of stored) {
7928
8993
  existing.add(`${rec.agentId}::${rec.upstreamSessionId}`);
7929
8994
  }
7930
- const hydraHomeDir = paths.home();
8995
+ const synopsisSandboxDir = paths.sessionsDir();
7931
8996
  const synced = [];
7932
8997
  let skipped = 0;
7933
8998
  for (const entry of entries) {
@@ -7936,10 +9001,21 @@ var SessionManager = class {
7936
9001
  skipped += 1;
7937
9002
  continue;
7938
9003
  }
7939
- if (isUnderHydraHome(entry.cwd, hydraHomeDir)) {
9004
+ if (isSynopsisSession(entry.cwd, synopsisSandboxDir)) {
7940
9005
  skipped += 1;
7941
9006
  continue;
7942
9007
  }
9008
+ const tombstone = await this.tombstones.read(agentId, entry.sessionId).catch(() => void 0);
9009
+ if (tombstone) {
9010
+ if (!shouldResurrectFromUpstream(tombstone, entry.updatedAt)) {
9011
+ skipped += 1;
9012
+ continue;
9013
+ }
9014
+ await this.tombstones.remove(agentId, entry.sessionId).catch(() => void 0);
9015
+ this.logger?.info(
9016
+ `syncFromAgent: resurrecting tombstoned ${agentId}/${entry.sessionId} (upstream updatedAt advanced past ${tombstone.upstreamUpdatedAt ?? "<unset>"})`
9017
+ );
9018
+ }
7943
9019
  existing.add(dedupeKey);
7944
9020
  const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
7945
9021
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -8000,6 +9076,25 @@ var SessionManager = class {
8000
9076
  }
8001
9077
  return out;
8002
9078
  }
9079
+ // Issue session/set_model for a seed model (defaultModels / --model) at
9080
+ // bootstrap, logging success or a non-fatal rejection. `where` is the
9081
+ // human-readable provenance string used in log lines. A bad id in config
9082
+ // shouldn't break session creation, so a rejection is swallowed.
9083
+ async applySeedModel(agent, sessionId, modelId, where) {
9084
+ try {
9085
+ await agent.connection.request("session/set_model", {
9086
+ sessionId,
9087
+ modelId
9088
+ });
9089
+ this.logger?.info(`${where}: session/set_model accepted`);
9090
+ return true;
9091
+ } catch (err) {
9092
+ this.logger?.warn(
9093
+ `${where} rejected by agent (${err.message}); session will use the agent's own default`
9094
+ );
9095
+ return false;
9096
+ }
9097
+ }
8003
9098
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
8004
9099
  // → session/new. Shared by create() and the /hydra agent path so both
8005
9100
  // go through the same env / capabilities / error-handling.
@@ -8048,23 +9143,31 @@ var SessionManager = class {
8048
9143
  const initialModels = extractInitialModels(newResult);
8049
9144
  const desired = params.model ?? this.defaultModels[params.agentId];
8050
9145
  if (desired && desired !== initialModel) {
8051
- const validates = initialModels.length === 0 || initialModels.some((m) => m.modelId === desired);
8052
- if (validates) {
8053
- try {
8054
- await agent.connection.request("session/set_model", {
8055
- sessionId: sessionIdRaw,
8056
- modelId: desired
8057
- });
9146
+ const resolution = resolveModelId(desired, initialModels);
9147
+ const where = params.model !== void 0 ? `model=${JSON.stringify(desired)}` : `defaultModels[${params.agentId}]=${JSON.stringify(desired)}`;
9148
+ if (resolution.kind === "exact" || resolution.kind === "none") {
9149
+ if (await this.applySeedModel(agent, sessionIdRaw, desired, where)) {
8058
9150
  initialModel = desired;
8059
- } catch (err) {
8060
- this.logger?.warn(
8061
- `defaultModels[${params.agentId}]=${JSON.stringify(desired)} rejected by agent (${err.message}); session will use ${JSON.stringify(initialModel)}`
8062
- );
8063
9151
  }
9152
+ } else if (resolution.kind === "resolved") {
9153
+ if (resolution.modelId === initialModel) {
9154
+ initialModel = resolution.modelId;
9155
+ } else if (await this.applySeedModel(
9156
+ agent,
9157
+ sessionIdRaw,
9158
+ resolution.modelId,
9159
+ `${where} resolved to ${JSON.stringify(resolution.modelId)}`
9160
+ )) {
9161
+ initialModel = resolution.modelId;
9162
+ }
9163
+ } else if (resolution.kind === "ambiguous") {
9164
+ this.logger?.warn(
9165
+ `${where} is ambiguous (trailing-segment matches [${resolution.candidates.join(", ")}]); skipping session/set_model, session will use ${JSON.stringify(initialModel)}`
9166
+ );
8064
9167
  } else {
8065
9168
  const known = initialModels.map((m) => m.modelId).join(", ");
8066
9169
  this.logger?.warn(
8067
- `defaultModels[${params.agentId}]=${JSON.stringify(desired)} not in agent's availableModels ([${known}]); skipping session/set_model, session will use ${JSON.stringify(initialModel)}`
9170
+ `${where} not in agent's availableModels ([${known}]); skipping session/set_model, session will use ${JSON.stringify(initialModel)}`
8068
9171
  );
8069
9172
  }
8070
9173
  }
@@ -8095,6 +9198,17 @@ var SessionManager = class {
8095
9198
  session.onClose(({ deleteRecord }) => {
8096
9199
  this.sessions.delete(session.sessionId);
8097
9200
  if (deleteRecord) {
9201
+ if (session.upstreamSessionId) {
9202
+ void this.tombstones.add({
9203
+ agentId: session.agentId,
9204
+ upstreamSessionId: session.upstreamSessionId,
9205
+ deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
9206
+ upstreamUpdatedAt: new Date(session.updatedAt).toISOString(),
9207
+ cwd: session.cwd,
9208
+ title: session.title,
9209
+ reason: "user"
9210
+ }).catch(() => void 0);
9211
+ }
8098
9212
  void this.store.delete(session.sessionId).catch(() => void 0);
8099
9213
  void this.histories.delete(session.sessionId).catch(() => void 0);
8100
9214
  return;
@@ -8186,6 +9300,12 @@ var SessionManager = class {
8186
9300
  async loadHistory(sessionId) {
8187
9301
  return this.histories.load(sessionId);
8188
9302
  }
9303
+ // Read a single externalized tool-content blob by sha256 (the lean
9304
+ // `tools: "references"` fetch-on-expand path). Null if the session id or
9305
+ // hash is malformed, or the blob isn't present.
9306
+ async loadToolBlob(sessionId, hash) {
9307
+ return getToolBlob(sessionId, hash);
9308
+ }
8189
9309
  async loadFromDisk(sessionId) {
8190
9310
  const record = await this.store.read(sessionId);
8191
9311
  if (!record) {
@@ -8304,6 +9424,35 @@ var SessionManager = class {
8304
9424
  }
8305
9425
  return session;
8306
9426
  }
9427
+ // Synchronous SessionListEntry for a resident session. Mirrors the
9428
+ // live-session branch of list() but skips the async history probe:
9429
+ // callers on the attach/new hot path already hold the Session and
9430
+ // don't need the history-derived `interactive` inference (they pass
9431
+ // through the session's own tristate) or the history mtime (the
9432
+ // session's updatedAt is current). Used to build the reconciled
9433
+ // session/new + session/attach response `_meta["hydra-acp"]` from the
9434
+ // same shape session/list emits.
9435
+ liveListEntry(session) {
9436
+ return {
9437
+ sessionId: session.sessionId,
9438
+ upstreamSessionId: session.upstreamSessionId,
9439
+ cwd: session.cwd,
9440
+ title: session.title,
9441
+ agentId: session.agentId,
9442
+ currentModel: session.currentModel,
9443
+ currentUsage: session.currentUsage,
9444
+ parentSessionId: session.parentSessionId,
9445
+ forkedFromSessionId: session.forkedFromSessionId,
9446
+ forkedFromMessageId: session.forkedFromMessageId,
9447
+ originatingClient: session.originatingClient,
9448
+ interactive: session.interactive,
9449
+ updatedAt: new Date(session.updatedAt).toISOString(),
9450
+ attachedClients: session.attachedCount,
9451
+ status: "live",
9452
+ busy: session.turnStartedAt !== void 0,
9453
+ awaitingInput: session.awaitingInput
9454
+ };
9455
+ }
8307
9456
  async list(filter = {}) {
8308
9457
  const entries = [];
8309
9458
  const liveIds = /* @__PURE__ */ new Set();
@@ -8394,7 +9543,7 @@ var SessionManager = class {
8394
9543
  // disk. Backfills lineageId if the on-disk record pre-dates that
8395
9544
  // field. Returns undefined if the session doesn't exist. Callers
8396
9545
  // populate the bundle's exportedFrom metadata themselves.
8397
- async exportBundle(sessionId) {
9546
+ async exportBundle(sessionId, opts = {}) {
8398
9547
  const record = await this.store.read(sessionId);
8399
9548
  if (!record) {
8400
9549
  return void 0;
@@ -8417,9 +9566,20 @@ var SessionManager = class {
8417
9566
  }).catch(() => void 0);
8418
9567
  withLineage = backfilled;
8419
9568
  }
8420
- const history = await this.histories.load(sessionId).catch(() => []);
9569
+ const tools = opts.tools ?? "inline";
9570
+ const history = await this.histories.load(sessionId, tools === "references" ? { tools: "references" } : {}).catch(() => []);
8421
9571
  const promptHistory = await loadPromptHistorySafely(sessionId);
8422
- return { record: withLineage, history, promptHistory };
9572
+ if (tools !== "references") {
9573
+ return { record: withLineage, history, promptHistory };
9574
+ }
9575
+ const toolBlobs = {};
9576
+ for (const hash of collectToolBlobHashes(history)) {
9577
+ const gz = await readToolBlobGz(sessionId, hash);
9578
+ if (gz) {
9579
+ toolBlobs[hash] = gz.toString("base64");
9580
+ }
9581
+ }
9582
+ return { record: withLineage, history, promptHistory, toolBlobs };
8423
9583
  }
8424
9584
  // Create a local session from an imported bundle. Without `replace`,
8425
9585
  // a bundle with a lineageId we already have on disk throws
@@ -8580,9 +9740,18 @@ var SessionManager = class {
8580
9740
  args.sessionId,
8581
9741
  args.bundle.history
8582
9742
  );
9743
+ if (args.bundle.toolBlobs) {
9744
+ for (const [hash, b64] of Object.entries(args.bundle.toolBlobs)) {
9745
+ await writeToolBlobGz(
9746
+ args.sessionId,
9747
+ hash,
9748
+ Buffer.from(b64, "base64")
9749
+ ).catch(() => void 0);
9750
+ }
9751
+ }
8583
9752
  const sourceMtime = new Date(args.bundle.session.updatedAt);
8584
9753
  if (!Number.isNaN(sourceMtime.getTime())) {
8585
- await fs13.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
9754
+ await fs15.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
8586
9755
  }
8587
9756
  if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
8588
9757
  await saveHistory(
@@ -8637,6 +9806,17 @@ var SessionManager = class {
8637
9806
  if (!record) {
8638
9807
  return false;
8639
9808
  }
9809
+ if (record.upstreamSessionId) {
9810
+ await this.tombstones.add({
9811
+ agentId: record.agentId,
9812
+ upstreamSessionId: record.upstreamSessionId,
9813
+ deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
9814
+ upstreamUpdatedAt: record.updatedAt,
9815
+ cwd: record.cwd,
9816
+ title: record.title,
9817
+ reason: "user"
9818
+ }).catch(() => void 0);
9819
+ }
8640
9820
  await this.store.delete(sessionId).catch(() => void 0);
8641
9821
  return true;
8642
9822
  }
@@ -8846,13 +10026,13 @@ var SessionManager = class {
8846
10026
  }
8847
10027
  }
8848
10028
  };
8849
- function isUnderHydraHome(cwd, hydraHomeDir) {
10029
+ function isSynopsisSession(cwd, sandboxDir) {
8850
10030
  if (typeof cwd !== "string" || cwd.length === 0) {
8851
10031
  return false;
8852
10032
  }
8853
10033
  const resolved = path9.resolve(cwd);
8854
- const home = path9.resolve(hydraHomeDir);
8855
- return resolved === home || resolved.startsWith(home + path9.sep);
10034
+ const base = path9.resolve(sandboxDir);
10035
+ return resolved === base || resolved.startsWith(base + path9.sep);
8856
10036
  }
8857
10037
  function mergeForPersistence(session, existing) {
8858
10038
  const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
@@ -8977,6 +10157,13 @@ function extractInitialModel(result) {
8977
10157
  }
8978
10158
  }
8979
10159
  }
10160
+ const fromConfig = findConfigOptionEntry(result, "model");
10161
+ if (fromConfig) {
10162
+ const cv = asString(fromConfig.currentValue);
10163
+ if (cv) {
10164
+ return cv;
10165
+ }
10166
+ }
8980
10167
  return void 0;
8981
10168
  }
8982
10169
  function asString(value) {
@@ -8986,6 +10173,22 @@ function asString(value) {
8986
10173
  const trimmed = value.trim();
8987
10174
  return trimmed.length > 0 ? trimmed : void 0;
8988
10175
  }
10176
+ function findConfigOptionEntry(result, id) {
10177
+ const list = result.configOptions;
10178
+ if (!Array.isArray(list)) {
10179
+ return void 0;
10180
+ }
10181
+ for (const raw of list) {
10182
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
10183
+ continue;
10184
+ }
10185
+ const entry = raw;
10186
+ if (entry.id === id) {
10187
+ return entry;
10188
+ }
10189
+ }
10190
+ return void 0;
10191
+ }
8989
10192
  function nonEmptyOrUndefined(arr) {
8990
10193
  return arr.length > 0 ? arr : void 0;
8991
10194
  }
@@ -9021,6 +10224,13 @@ function extractInitialModels(result) {
9021
10224
  }
9022
10225
  }
9023
10226
  }
10227
+ const fromConfig = findConfigOptionEntry(result, "model");
10228
+ if (fromConfig) {
10229
+ const parsed = parseModelsList(fromConfig.options);
10230
+ if (parsed.length > 0) {
10231
+ return parsed;
10232
+ }
10233
+ }
9024
10234
  return [];
9025
10235
  }
9026
10236
  function extractInitialModes(result) {
@@ -9055,6 +10265,13 @@ function extractInitialModes(result) {
9055
10265
  }
9056
10266
  }
9057
10267
  }
10268
+ const fromConfig = findConfigOptionEntry(result, "mode");
10269
+ if (fromConfig) {
10270
+ const parsed = parseModesList(fromConfig.options);
10271
+ if (parsed.length > 0) {
10272
+ return parsed;
10273
+ }
10274
+ }
9058
10275
  return [];
9059
10276
  }
9060
10277
  function extractInitialCurrentMode(result) {
@@ -9085,6 +10302,13 @@ function extractInitialCurrentMode(result) {
9085
10302
  }
9086
10303
  }
9087
10304
  }
10305
+ const fromConfig = findConfigOptionEntry(result, "mode");
10306
+ if (fromConfig) {
10307
+ const cv = asString(fromConfig.currentValue);
10308
+ if (cv) {
10309
+ return cv;
10310
+ }
10311
+ }
9088
10312
  return void 0;
9089
10313
  }
9090
10314
  async function restoreCurrentMode(opts) {
@@ -9146,41 +10370,14 @@ async function restoreCurrentModel(opts) {
9146
10370
  });
9147
10371
  logger?.info(
9148
10372
  `resurrect: session/set_model accepted, effectiveModel=${JSON.stringify(persistedModel)}`
9149
- );
9150
- return persistedModel;
9151
- } catch (err) {
9152
- logger?.warn(
9153
- `resurrect: session/set_model rejected by agent for modelId=${JSON.stringify(persistedModel)} (${err.message}); session will use ${JSON.stringify(agentReportedModel)}`
9154
- );
9155
- return agentReportedModel;
9156
- }
9157
- }
9158
- function parseModesList(list) {
9159
- if (!Array.isArray(list)) {
9160
- return [];
9161
- }
9162
- const out = [];
9163
- for (const raw of list) {
9164
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
9165
- continue;
9166
- }
9167
- const r = raw;
9168
- const id = asString(r.id) ?? asString(r.modeId);
9169
- if (!id) {
9170
- continue;
9171
- }
9172
- const mode = { id };
9173
- const name = asString(r.name);
9174
- if (name) {
9175
- mode.name = name;
9176
- }
9177
- const description = asString(r.description);
9178
- if (description) {
9179
- mode.description = description;
9180
- }
9181
- out.push(mode);
10373
+ );
10374
+ return persistedModel;
10375
+ } catch (err) {
10376
+ logger?.warn(
10377
+ `resurrect: session/set_model rejected by agent for modelId=${JSON.stringify(persistedModel)} (${err.message}); session will use ${JSON.stringify(agentReportedModel)}`
10378
+ );
10379
+ return agentReportedModel;
9182
10380
  }
9183
- return out;
9184
10381
  }
9185
10382
  function findLastTurnComplete(history) {
9186
10383
  for (let i = history.length - 1; i >= 0; i--) {
@@ -9201,7 +10398,7 @@ function findLastTurnComplete(history) {
9201
10398
  }
9202
10399
  async function loadPromptHistorySafely(sessionId) {
9203
10400
  try {
9204
- const raw = await fs13.readFile(paths.tuiHistoryFile(sessionId), "utf8");
10401
+ const raw = await fs15.readFile(paths.tuiHistoryFile(sessionId), "utf8");
9205
10402
  const out = [];
9206
10403
  for (const line of raw.split("\n")) {
9207
10404
  if (line.length === 0) {
@@ -9222,7 +10419,7 @@ async function loadPromptHistorySafely(sessionId) {
9222
10419
  }
9223
10420
  async function historyStatus(sessionId) {
9224
10421
  try {
9225
- const st = await fs13.stat(paths.historyFile(sessionId));
10422
+ const st = await fs15.stat(paths.historyFile(sessionId));
9226
10423
  return {
9227
10424
  mtime: new Date(st.mtimeMs).toISOString(),
9228
10425
  hasContent: st.size > 0
@@ -9243,7 +10440,7 @@ function effectiveInteractive(record, hasContent) {
9243
10440
 
9244
10441
  // src/core/child-supervisor.ts
9245
10442
  import { spawn as spawn4 } from "child_process";
9246
- import * as fs14 from "fs";
10443
+ import * as fs16 from "fs";
9247
10444
  import * as fsp5 from "fs/promises";
9248
10445
  import * as path10 from "path";
9249
10446
 
@@ -9616,7 +10813,7 @@ var ChildSupervisor = class {
9616
10813
  }
9617
10814
  const cfg = entry.config;
9618
10815
  const command = cfg.command.length > 0 ? cfg.command : [cfg.name];
9619
- const logStream = fs14.createWriteStream(
10816
+ const logStream = fs16.createWriteStream(
9620
10817
  this.adapter.paths.logFile(cfg.name),
9621
10818
  { flags: "a" }
9622
10819
  );
@@ -9672,7 +10869,7 @@ var ChildSupervisor = class {
9672
10869
  }
9673
10870
  if (typeof child.pid === "number") {
9674
10871
  try {
9675
- fs14.writeFileSync(
10872
+ fs16.writeFileSync(
9676
10873
  this.adapter.paths.pidFile(cfg.name),
9677
10874
  `${child.pid}
9678
10875
  `,
@@ -9698,7 +10895,7 @@ var ChildSupervisor = class {
9698
10895
  });
9699
10896
  child.on("exit", (code, signal) => {
9700
10897
  try {
9701
- fs14.unlinkSync(this.adapter.paths.pidFile(cfg.name));
10898
+ fs16.unlinkSync(this.adapter.paths.pidFile(cfg.name));
9702
10899
  } catch {
9703
10900
  }
9704
10901
  logStream.write(
@@ -9808,13 +11005,13 @@ var TRANSFORMER_ADAPTER = {
9808
11005
  }
9809
11006
  };
9810
11007
  var TransformerManager = class extends ChildSupervisor {
9811
- // Transformers that have completed transformer/initialize and are ready to
11008
+ // Transformers that have completed hydra-acp/transformer/initialize and are ready to
9812
11009
  // participate in chains. Keyed by transformer name.
9813
11010
  connected = /* @__PURE__ */ new Map();
9814
11011
  constructor(transformers, context, options = {}) {
9815
11012
  super(transformers, TRANSFORMER_ADAPTER, context, options);
9816
11013
  }
9817
- // Called by the WS handler after transformer/initialize completes. The
11014
+ // Called by the WS handler after hydra-acp/transformer/initialize completes. The
9818
11015
  // transformer is now eligible to participate in session chains.
9819
11016
  registerConnection(name, connection, intercepts) {
9820
11017
  this.connected.set(name, {
@@ -10038,7 +11235,7 @@ function startAgentSyncScheduler(opts) {
10038
11235
 
10039
11236
  // src/core/session-tokens.ts
10040
11237
  import * as path12 from "path";
10041
- import { createHash, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
11238
+ import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
10042
11239
  var TOKEN_PREFIX = "hydra_session_";
10043
11240
  var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
10044
11241
  var ID_LENGTH = 12;
@@ -10048,7 +11245,7 @@ function tokensFilePath() {
10048
11245
  return path12.join(paths.home(), "session-tokens.json");
10049
11246
  }
10050
11247
  function sha256Hex(input) {
10051
- return createHash("sha256").update(input).digest("hex");
11248
+ return createHash2("sha256").update(input).digest("hex");
10052
11249
  }
10053
11250
  function randomHex(bytes) {
10054
11251
  return randomBytes2(bytes).toString("hex");
@@ -10064,20 +11261,27 @@ var SessionTokenStore = class _SessionTokenStore {
10064
11261
  // keyed by hash
10065
11262
  writeTimer = null;
10066
11263
  writeInflight = null;
10067
- constructor(records) {
11264
+ // Bound at construction so a debounced write that fires after the
11265
+ // process's HYDRA_ACP_HOME has changed (e.g. between tests) targets the
11266
+ // path this store was loaded from rather than re-resolving paths.home()
11267
+ // and clobbering an unrelated home's token file.
11268
+ filePath;
11269
+ constructor(records, filePath) {
11270
+ this.filePath = filePath;
10068
11271
  for (const r of records) {
10069
11272
  this.records.set(r.hash, r);
10070
11273
  }
10071
11274
  }
10072
11275
  static async load() {
10073
11276
  let records = [];
11277
+ const filePath = tokensFilePath();
10074
11278
  const parsed = await readJsonSafe(
10075
- tokensFilePath()
11279
+ filePath
10076
11280
  );
10077
11281
  if (parsed && Array.isArray(parsed.records)) {
10078
11282
  records = parsed.records.filter(isRecord);
10079
11283
  }
10080
- const store = new _SessionTokenStore(records);
11284
+ const store = new _SessionTokenStore(records, filePath);
10081
11285
  const removed = store.sweepExpired(/* @__PURE__ */ new Date());
10082
11286
  if (removed > 0) {
10083
11287
  await store.flush();
@@ -10187,21 +11391,29 @@ var SessionTokenStore = class _SessionTokenStore {
10187
11391
  });
10188
11392
  }, WRITE_DEBOUNCE_MS);
10189
11393
  }
10190
- async persist() {
10191
- if (this.writeInflight) {
10192
- await this.writeInflight;
10193
- }
10194
- const records = Array.from(this.records.values());
10195
- this.writeInflight = writeJsonAtomic(
10196
- tokensFilePath(),
10197
- { records },
10198
- { mode: 384 }
11394
+ // Serialize writes by chaining onto whatever write is in flight. Two
11395
+ // concurrent persists (e.g. a debounced scheduleWrite timer firing while
11396
+ // flush() awaits) would otherwise both write a temp file and race their
11397
+ // renames onto the same path — the older snapshot could win. Chaining
11398
+ // guarantees writes run one after another, each snapshotting records at
11399
+ // the moment it actually runs, so the final on-disk state reflects the
11400
+ // latest records. The returned promise resolves once THIS persist's
11401
+ // write has completed.
11402
+ persist() {
11403
+ const run2 = (this.writeInflight ?? Promise.resolve()).catch(() => void 0).then(
11404
+ () => writeJsonAtomic(
11405
+ this.filePath,
11406
+ { records: Array.from(this.records.values()) },
11407
+ { mode: 384 }
11408
+ )
10199
11409
  );
10200
- try {
10201
- await this.writeInflight;
10202
- } finally {
10203
- this.writeInflight = null;
10204
- }
11410
+ this.writeInflight = run2;
11411
+ run2.catch(() => void 0).finally(() => {
11412
+ if (this.writeInflight === run2) {
11413
+ this.writeInflight = null;
11414
+ }
11415
+ });
11416
+ return run2;
10205
11417
  }
10206
11418
  };
10207
11419
  function isRecord(value) {
@@ -10363,6 +11575,7 @@ var AuthRateLimiter = class {
10363
11575
 
10364
11576
  // src/daemon/routes/sessions.ts
10365
11577
  import * as os3 from "os";
11578
+ import * as path13 from "path";
10366
11579
 
10367
11580
  // src/core/render-update.ts
10368
11581
  import stripAnsi from "strip-ansi";
@@ -10412,10 +11625,52 @@ function mapUpdate(update) {
10412
11625
  return mapAvailableModes(u);
10413
11626
  case "session_info_update":
10414
11627
  return mapSessionInfo(u);
11628
+ case "config_option_update":
11629
+ return mapConfigOptions(u);
10415
11630
  default:
10416
11631
  return { kind: "unknown", sessionUpdate: tag, raw: update };
10417
11632
  }
10418
11633
  }
11634
+ function mapConfigOptions(u) {
11635
+ const list = u.configOptions;
11636
+ if (!Array.isArray(list)) {
11637
+ return null;
11638
+ }
11639
+ const options = [];
11640
+ for (const raw of list) {
11641
+ if (!raw || typeof raw !== "object") {
11642
+ continue;
11643
+ }
11644
+ const o = raw;
11645
+ if (typeof o.id !== "string" || typeof o.currentValue !== "string" || !Array.isArray(o.options)) {
11646
+ continue;
11647
+ }
11648
+ const values = [];
11649
+ for (const v of o.options) {
11650
+ if (!v || typeof v !== "object") {
11651
+ continue;
11652
+ }
11653
+ const vv = v;
11654
+ if (typeof vv.value !== "string") {
11655
+ continue;
11656
+ }
11657
+ values.push({
11658
+ value: vv.value,
11659
+ name: typeof vv.name === "string" ? vv.name : vv.value,
11660
+ ...typeof vv.description === "string" ? { description: vv.description } : {}
11661
+ });
11662
+ }
11663
+ options.push({
11664
+ id: o.id,
11665
+ name: typeof o.name === "string" ? o.name : o.id,
11666
+ type: "select",
11667
+ currentValue: o.currentValue,
11668
+ options: values,
11669
+ ...typeof o.category === "string" ? { category: o.category } : {}
11670
+ });
11671
+ }
11672
+ return { kind: "config-options", options };
11673
+ }
10419
11674
  function mapSessionInfo(u) {
10420
11675
  const rawTitle = readString(u, "title");
10421
11676
  const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
@@ -10557,6 +11812,23 @@ function isExitPlanModeTool(name) {
10557
11812
  const normalised = name.toLowerCase().replace(/[_\s-]/g, "");
10558
11813
  return normalised === "exitplanmode";
10559
11814
  }
11815
+ function readDiffField(value) {
11816
+ if (typeof value === "string") {
11817
+ return { text: value };
11818
+ }
11819
+ if (value && typeof value === "object" && !Array.isArray(value)) {
11820
+ const v = value;
11821
+ if (typeof v.__hydraBlob === "string") {
11822
+ return {
11823
+ ref: {
11824
+ hash: v.__hydraBlob,
11825
+ bytes: typeof v.bytes === "number" ? v.bytes : 0
11826
+ }
11827
+ };
11828
+ }
11829
+ }
11830
+ return void 0;
11831
+ }
10560
11832
  function extractEditDiff(u) {
10561
11833
  const content = u.content;
10562
11834
  if (Array.isArray(content)) {
@@ -10568,16 +11840,18 @@ function extractEditDiff(u) {
10568
11840
  if (b.type !== "diff") {
10569
11841
  continue;
10570
11842
  }
10571
- const oldText = typeof b.oldText === "string" ? b.oldText : void 0;
10572
- const newText = typeof b.newText === "string" ? b.newText : void 0;
10573
- if (oldText === void 0 && newText === void 0) {
11843
+ const oldField = readDiffField(b.oldText);
11844
+ const newField = readDiffField(b.newText);
11845
+ if (oldField === void 0 && newField === void 0) {
10574
11846
  continue;
10575
11847
  }
10576
11848
  const path15 = typeof b.path === "string" ? b.path : void 0;
10577
11849
  return {
10578
11850
  ...path15 !== void 0 ? { path: path15 } : {},
10579
- oldText: oldText ?? "",
10580
- newText: newText ?? ""
11851
+ oldText: oldField?.text ?? "",
11852
+ newText: newField?.text ?? "",
11853
+ ...oldField?.ref ? { oldRef: oldField.ref } : {},
11854
+ ...newField?.ref ? { newRef: newField.ref } : {}
10581
11855
  };
10582
11856
  }
10583
11857
  }
@@ -10645,8 +11919,36 @@ function mapToolCall(u) {
10645
11919
  if (diff !== null) {
10646
11920
  event.editDiff = diff;
10647
11921
  }
11922
+ const detail = extractToolDetail(u);
11923
+ if (detail !== void 0) {
11924
+ event.detail = detail;
11925
+ }
10648
11926
  return event;
10649
11927
  }
11928
+ var TOOL_DETAIL_MAX = 64;
11929
+ function extractToolDetail(u) {
11930
+ const rawInput = u.rawInput;
11931
+ if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput)) {
11932
+ return void 0;
11933
+ }
11934
+ const r = rawInput;
11935
+ if (typeof r.command === "string" && r.command.trim().length > 0) {
11936
+ const firstLine2 = sanitizeSingleLine(r.command).trim();
11937
+ const cmd = firstLine2.replace(/^cd\s+\S+\s+&&\s+/, "");
11938
+ return clipHead(cmd, TOOL_DETAIL_MAX);
11939
+ }
11940
+ const path15 = typeof r.file_path === "string" ? r.file_path : typeof r.path === "string" ? r.path : void 0;
11941
+ if (path15 !== void 0 && path15.length > 0) {
11942
+ return clipTail(shortenHomePath(sanitizeSingleLine(path15)), TOOL_DETAIL_MAX);
11943
+ }
11944
+ return void 0;
11945
+ }
11946
+ function clipHead(s, max) {
11947
+ return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
11948
+ }
11949
+ function clipTail(s, max) {
11950
+ return s.length > max ? `\u2026${s.slice(-(max - 1))}` : s;
11951
+ }
10650
11952
  function mapToolCallUpdate(u) {
10651
11953
  const toolCallId = readString(u, "toolCallId") ?? readString(u, "id");
10652
11954
  if (!toolCallId) {
@@ -10656,7 +11958,8 @@ function mapToolCallUpdate(u) {
10656
11958
  const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
10657
11959
  const status = readString(u, "status");
10658
11960
  const diff = extractEditDiff(u);
10659
- const meaningful = title !== void 0 || diff !== null || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
11961
+ const detail = extractToolDetail(u);
11962
+ const meaningful = title !== void 0 || diff !== null || detail !== void 0 || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
10660
11963
  if (!meaningful) {
10661
11964
  return null;
10662
11965
  }
@@ -10676,6 +11979,9 @@ function mapToolCallUpdate(u) {
10676
11979
  if (title !== void 0) {
10677
11980
  event.title = title;
10678
11981
  }
11982
+ if (detail !== void 0) {
11983
+ event.detail = detail;
11984
+ }
10679
11985
  if (status !== void 0) {
10680
11986
  event.status = status;
10681
11987
  }
@@ -11518,6 +12824,53 @@ function registerSessionRoutes(app, manager, defaults) {
11518
12824
  }
11519
12825
  reply.code(204).send();
11520
12826
  });
12827
+ app.post("/v1/sessions/:id/stdin/open", async (request, reply) => {
12828
+ const raw = request.params.id;
12829
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
12830
+ const session = manager.get(id);
12831
+ if (!session) {
12832
+ reply.code(404).send({ error: "session not found" });
12833
+ return reply;
12834
+ }
12835
+ const body = request.body ?? {};
12836
+ const openOpts = {};
12837
+ if (body.mode === "memory" || body.mode === "file") {
12838
+ openOpts.mode = body.mode;
12839
+ }
12840
+ if (typeof body.capacityBytes === "number") {
12841
+ openOpts.capacityBytes = body.capacityBytes;
12842
+ }
12843
+ if (typeof body.fileCapBytes === "number") {
12844
+ openOpts.fileCapBytes = body.fileCapBytes;
12845
+ }
12846
+ if ((openOpts.mode ?? "memory") === "file") {
12847
+ openOpts.filePathFor = (sid) => path13.join(os3.tmpdir(), `hydra-acp-stdin-${sid}.log`);
12848
+ }
12849
+ try {
12850
+ return session.openStream(openOpts);
12851
+ } catch (err) {
12852
+ reply.code(409).send({ error: err.message });
12853
+ return reply;
12854
+ }
12855
+ });
12856
+ app.post("/v1/sessions/:id/stdin", async (request, reply) => {
12857
+ const raw = request.params.id;
12858
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
12859
+ const session = manager.get(id);
12860
+ if (!session) {
12861
+ reply.code(404).send({ error: "session not found" });
12862
+ return reply;
12863
+ }
12864
+ const body = request.body ?? {};
12865
+ const chunk = typeof body.chunk === "string" ? body.chunk : "";
12866
+ const eof = body.eof === true;
12867
+ try {
12868
+ return session.streamWrite(chunk, eof);
12869
+ } catch (err) {
12870
+ reply.code(409).send({ error: err.message });
12871
+ return reply;
12872
+ }
12873
+ });
11521
12874
  app.patch("/v1/sessions/:id", async (request, reply) => {
11522
12875
  const raw = request.params.id;
11523
12876
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -11562,15 +12915,21 @@ function registerSessionRoutes(app, manager, defaults) {
11562
12915
  app.get("/v1/sessions/:id/export", async (request, reply) => {
11563
12916
  const raw = request.params.id;
11564
12917
  const id = await manager.resolveCanonicalId(raw) ?? raw;
11565
- const exported = await manager.exportBundle(id);
12918
+ const toolsRaw = request.query?.tools;
12919
+ const toolMode = toolsRaw === "references" ? "references" : parseToolContentMode(toolsRaw);
12920
+ const exported = await manager.exportBundle(
12921
+ id,
12922
+ toolMode === "references" ? { tools: "references" } : {}
12923
+ );
11566
12924
  if (!exported) {
11567
12925
  reply.code(404).send({ error: "session not found" });
11568
12926
  return;
11569
12927
  }
11570
12928
  const bundle = encodeBundle({
11571
12929
  record: exported.record,
11572
- history: exported.history,
12930
+ history: toolMode === "summary" ? applyToolContentMode(exported.history, "summary") : exported.history,
11573
12931
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
12932
+ ...exported.toolBlobs !== void 0 ? { toolBlobs: exported.toolBlobs } : {},
11574
12933
  hydraVersion: HYDRA_VERSION,
11575
12934
  machine: os3.hostname(),
11576
12935
  hydraHost: resolveHydraHost(defaults)
@@ -11582,6 +12941,17 @@ function registerSessionRoutes(app, manager, defaults) {
11582
12941
  );
11583
12942
  reply.code(200).send(bundle);
11584
12943
  });
12944
+ app.get("/v1/sessions/:id/tools/:hash", async (request, reply) => {
12945
+ const params = request.params;
12946
+ const id = await manager.resolveCanonicalId(params.id) ?? params.id;
12947
+ const blob = await manager.loadToolBlob(id, params.hash);
12948
+ if (blob === null) {
12949
+ reply.code(404).send({ error: "tool blob not found" });
12950
+ return;
12951
+ }
12952
+ reply.header("Content-Type", "text/plain; charset=utf-8");
12953
+ reply.code(200).send(blob);
12954
+ });
11585
12955
  app.get("/v1/sessions/:id/transcript", async (request, reply) => {
11586
12956
  const raw = request.params.id;
11587
12957
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -11754,22 +13124,7 @@ function registerSessionRoutes(app, manager, defaults) {
11754
13124
  // src/daemon/routes/agents.ts
11755
13125
  function registerAgentRoutes(app, registry, manager, opts = {}) {
11756
13126
  app.get("/v1/agents", async () => {
11757
- const doc = await registry.load();
11758
- const agents = await Promise.all(
11759
- doc.agents.map(async (a) => ({
11760
- id: a.id,
11761
- name: a.name,
11762
- version: a.version,
11763
- description: a.description,
11764
- distributions: Object.keys(a.distribution),
11765
- installed: await agentInstallState(a)
11766
- }))
11767
- );
11768
- return {
11769
- version: doc.version,
11770
- fetchedAt: registry.lastFetchedAt(),
11771
- agents
11772
- };
13127
+ return listAgents(registry);
11773
13128
  });
11774
13129
  app.get("/v1/registry", async () => {
11775
13130
  return registry.load();
@@ -12078,22 +13433,22 @@ function registerConfigRoutes(app, snapshot) {
12078
13433
  }
12079
13434
 
12080
13435
  // src/daemon/routes/auth.ts
12081
- import { z as z7 } from "zod";
13436
+ import { z as z8 } from "zod";
12082
13437
 
12083
13438
  // src/core/password.ts
12084
- import * as fs15 from "fs/promises";
12085
- import * as path13 from "path";
13439
+ import * as fs17 from "fs/promises";
13440
+ import * as path14 from "path";
12086
13441
  import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
12087
- import { promisify } from "util";
12088
- var scryptAsync = promisify(scrypt);
13442
+ import { promisify as promisify2 } from "util";
13443
+ var scryptAsync = promisify2(scrypt);
12089
13444
  function passwordHashPath() {
12090
- return path13.join(paths.home(), "password-hash");
13445
+ return path14.join(paths.home(), "password-hash");
12091
13446
  }
12092
13447
  var DEFAULT_N = 1 << 15;
12093
13448
  var MAX_MEM = 128 * 1024 * 1024;
12094
13449
  async function hasPassword() {
12095
13450
  try {
12096
- const text = await fs15.readFile(passwordHashPath(), "utf8");
13451
+ const text = await fs17.readFile(passwordHashPath(), "utf8");
12097
13452
  return text.trim().length > 0;
12098
13453
  } catch (err) {
12099
13454
  const e = err;
@@ -12109,7 +13464,7 @@ async function verifyPassword(plaintext) {
12109
13464
  }
12110
13465
  let line;
12111
13466
  try {
12112
- line = (await fs15.readFile(passwordHashPath(), "utf8")).trim();
13467
+ line = (await fs17.readFile(passwordHashPath(), "utf8")).trim();
12113
13468
  } catch (err) {
12114
13469
  const e = err;
12115
13470
  if (e.code === "ENOENT") {
@@ -12145,13 +13500,13 @@ async function verifyPassword(plaintext) {
12145
13500
  }
12146
13501
 
12147
13502
  // src/daemon/routes/auth.ts
12148
- var LoginBody = z7.object({
12149
- password: z7.string().min(1),
12150
- label: z7.string().min(1).max(256).optional(),
12151
- ttlSec: z7.number().int().positive().optional()
13503
+ var LoginBody = z8.object({
13504
+ password: z8.string().min(1),
13505
+ label: z8.string().min(1).max(256).optional(),
13506
+ ttlSec: z8.number().int().positive().optional()
12152
13507
  });
12153
- var LogoutBody = z7.object({
12154
- id: z7.string().optional()
13508
+ var LogoutBody = z8.object({
13509
+ id: z8.string().optional()
12155
13510
  }).optional();
12156
13511
  function registerAuthRoutes(app, deps) {
12157
13512
  app.post(
@@ -12303,8 +13658,6 @@ function wsToMessageStream(ws) {
12303
13658
  }
12304
13659
 
12305
13660
  // src/daemon/acp-ws.ts
12306
- import * as os4 from "os";
12307
- import * as path14 from "path";
12308
13661
  import { randomBytes as randomBytes4 } from "crypto";
12309
13662
  function registerAcpWsEndpoint(app, deps) {
12310
13663
  app.get("/acp", { websocket: true }, async (socket, request) => {
@@ -12361,7 +13714,7 @@ function registerAcpWsEndpoint(app, deps) {
12361
13714
  });
12362
13715
  if (processIdentity && deps.extensionCommands) {
12363
13716
  const registry = deps.extensionCommands;
12364
- connection.onRequest("hydra-acp/register_commands", async (raw) => {
13717
+ connection.onRequest("hydra-acp/commands/register", async (raw) => {
12365
13718
  const params = raw ?? {};
12366
13719
  const commands = Array.isArray(params.commands) ? params.commands.map((c) => {
12367
13720
  if (!c || typeof c !== "object") {
@@ -12389,7 +13742,7 @@ function registerAcpWsEndpoint(app, deps) {
12389
13742
  }
12390
13743
  if (processIdentity && deps.extensionMcp) {
12391
13744
  const mcpRegistry = deps.extensionMcp;
12392
- connection.onRequest("hydra-acp/register_mcp_tools", async (raw) => {
13745
+ connection.onRequest("hydra-acp/mcp_tools/register", async (raw) => {
12393
13746
  const params = raw ?? {};
12394
13747
  const instructions = typeof params.instructions === "string" ? params.instructions : void 0;
12395
13748
  const tools = Array.isArray(params.tools) ? params.tools.map((t) => {
@@ -12432,7 +13785,7 @@ function registerAcpWsEndpoint(app, deps) {
12432
13785
  });
12433
13786
  }
12434
13787
  if (processIdentity?.kind === "transformer") {
12435
- connection.onRequest("transformer/initialize", async (raw) => {
13788
+ connection.onRequest("hydra-acp/transformer/initialize", async (raw) => {
12436
13789
  const params = raw ?? {};
12437
13790
  const intercepts = Array.isArray(params.intercepts) ? params.intercepts.filter(
12438
13791
  (v) => typeof v === "string"
@@ -12457,7 +13810,7 @@ function registerAcpWsEndpoint(app, deps) {
12457
13810
  connection.onClose(() => {
12458
13811
  deps.transformers?.deregisterConnection(processIdentity.name);
12459
13812
  });
12460
- connection.onRequest("hydra-acp/emit_message", async (raw) => {
13813
+ connection.onRequest("hydra-acp/message/emit", async (raw) => {
12461
13814
  const params = raw ?? {};
12462
13815
  const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
12463
13816
  const method = typeof params.method === "string" ? params.method : void 0;
@@ -12485,7 +13838,7 @@ function registerAcpWsEndpoint(app, deps) {
12485
13838
  }
12486
13839
  throw Object.assign(new Error(`unsupported route: ${JSON.stringify(route)}`), { code: -32602 });
12487
13840
  });
12488
- connection.onRequest("hydra-acp/spawn_child_session", async (raw) => {
13841
+ connection.onRequest("hydra-acp/child_session/spawn", async (raw) => {
12489
13842
  const params = raw ?? {};
12490
13843
  const agentId = typeof params.agentId === "string" ? params.agentId : deps.defaultAgent;
12491
13844
  const cwd = typeof params.cwd === "string" ? params.cwd : void 0;
@@ -12502,7 +13855,7 @@ function registerAcpWsEndpoint(app, deps) {
12502
13855
  });
12503
13856
  return { childSessionId: child.sessionId };
12504
13857
  });
12505
- connection.onRequest("hydra-acp/fork_session", async (raw) => {
13858
+ connection.onRequest("hydra-acp/session/fork", async (raw) => {
12506
13859
  const params = raw ?? {};
12507
13860
  if (typeof params.sessionId !== "string") {
12508
13861
  throw Object.assign(
@@ -12519,7 +13872,30 @@ function registerAcpWsEndpoint(app, deps) {
12519
13872
  ...agentId !== void 0 ? { agentId } : {}
12520
13873
  });
12521
13874
  });
12522
- connection.onRequest("hydra-acp/await_child", async (raw) => {
13875
+ connection.onRequest("hydra-acp/session/delete", async (raw) => {
13876
+ const params = raw ?? {};
13877
+ if (typeof params.sessionId !== "string") {
13878
+ throw Object.assign(
13879
+ new Error("hydra-acp/session/delete requires sessionId"),
13880
+ { code: JsonRpcErrorCodes.InvalidParams }
13881
+ );
13882
+ }
13883
+ const id = await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
13884
+ const live = deps.manager.get(id);
13885
+ if (live) {
13886
+ await live.close({ deleteRecord: true });
13887
+ return { deleted: true, sessionId: id };
13888
+ }
13889
+ const removed = await deps.manager.deleteRecord(id);
13890
+ if (!removed) {
13891
+ throw Object.assign(
13892
+ new Error(`session ${id} not found`),
13893
+ { code: JsonRpcErrorCodes.SessionNotFound }
13894
+ );
13895
+ }
13896
+ return { deleted: true, sessionId: id };
13897
+ });
13898
+ connection.onRequest("hydra-acp/child_session/await", async (raw) => {
12523
13899
  const params = raw ?? {};
12524
13900
  const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
12525
13901
  const until = params.until === "idle" ? "idle" : "turn_complete";
@@ -12558,7 +13934,7 @@ function registerAcpWsEndpoint(app, deps) {
12558
13934
  child.onClose(() => finish());
12559
13935
  });
12560
13936
  });
12561
- connection.onRequest("hydra-acp/close_child_session", async (raw) => {
13937
+ connection.onRequest("hydra-acp/child_session/close", async (raw) => {
12562
13938
  const params = raw ?? {};
12563
13939
  const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
12564
13940
  if (!childSessionId) {
@@ -12570,7 +13946,7 @@ function registerAcpWsEndpoint(app, deps) {
12570
13946
  }
12571
13947
  return { ok: true };
12572
13948
  });
12573
- connection.onRequest("hydra-acp/keep_alive", async (raw) => {
13949
+ connection.onRequest("hydra-acp/connection/keep_alive", async (raw) => {
12574
13950
  const params = raw ?? {};
12575
13951
  const token2 = typeof params.token === "string" ? params.token : void 0;
12576
13952
  const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
@@ -12582,6 +13958,24 @@ function registerAcpWsEndpoint(app, deps) {
12582
13958
  return { ok: true };
12583
13959
  });
12584
13960
  }
13961
+ connection.onRequest("hydra-acp/session/tool_content", async (raw) => {
13962
+ const params = raw ?? {};
13963
+ if (typeof params.sessionId !== "string" || typeof params.hash !== "string") {
13964
+ throw Object.assign(
13965
+ new Error("hydra-acp/session/tool_content requires sessionId and hash"),
13966
+ { code: JsonRpcErrorCodes.InvalidParams }
13967
+ );
13968
+ }
13969
+ const id = await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
13970
+ const content = await deps.manager.loadToolBlob(id, params.hash);
13971
+ if (content === null) {
13972
+ throw Object.assign(
13973
+ new Error("tool content not found"),
13974
+ { code: JsonRpcErrorCodes.SessionNotFound }
13975
+ );
13976
+ }
13977
+ return { content };
13978
+ });
12585
13979
  connection.onRequest("session/new", async (raw) => {
12586
13980
  const params = SessionNewParams.parse(raw);
12587
13981
  const hydraMeta = extractHydraMeta(
@@ -12632,9 +14026,9 @@ function registerAcpWsEndpoint(app, deps) {
12632
14026
  try {
12633
14027
  session = await deps.manager.create({
12634
14028
  cwd: params.cwd,
12635
- agentId: params.agentId ?? deps.defaultAgent,
14029
+ agentId: hydraMeta.agentId ?? deps.defaultAgent,
12636
14030
  mcpServers: augmentedMcpServers,
12637
- title: hydraMeta.name,
14031
+ title: hydraMeta.title,
12638
14032
  agentArgs: hydraMeta.agentArgs,
12639
14033
  model: hydraMeta.model,
12640
14034
  onInstallProgress: makeInstallProgressForwarder(connection),
@@ -12685,14 +14079,16 @@ function registerAcpWsEndpoint(app, deps) {
12685
14079
  const modelsPayload = buildModelsPayload(session);
12686
14080
  return {
12687
14081
  sessionId: session.sessionId,
12688
- // session/new is implicitly an attach; mirror session/attach's
12689
- // shape by including the clientId so deferred-echo clients
12690
- // (TUI's queue work) can recognize their own prompt_queue_added
12691
- // events without an extra round-trip.
12692
- clientId: client.clientId,
12693
14082
  ...modesPayload ? { modes: modesPayload } : {},
12694
14083
  ...modelsPayload ? { models: modelsPayload } : {},
12695
- _meta: buildResponseMeta(session)
14084
+ configOptions: session.buildConfigOptions(),
14085
+ // session/new is a core ACP spec method, so the per-attachment
14086
+ // clientId rides under _meta["hydra-acp"] rather than top-level.
14087
+ // Deferred-echo clients (TUI's queue work) read it from there to
14088
+ // recognize their own prompt_queue_added events.
14089
+ _meta: buildResponseMeta(deps.manager, session, {
14090
+ clientId: client.clientId
14091
+ })
12696
14092
  };
12697
14093
  });
12698
14094
  connection.onRequest("session/attach", async (raw) => {
@@ -12705,8 +14101,9 @@ function registerAcpWsEndpoint(app, deps) {
12705
14101
  deps.onTransformerVersion?.(processIdentity.name, attachVersion);
12706
14102
  }
12707
14103
  }
12708
- const hydraHints = extractHydraMeta(params._meta).resume;
12709
- const readonly = params.readonly === true;
14104
+ const hydraAttach = extractHydraMeta(params._meta);
14105
+ const hydraHints = hydraAttach.resume;
14106
+ const readonly = hydraAttach.readonly === true;
12710
14107
  app.log.info(
12711
14108
  `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints} readonly=${readonly}`
12712
14109
  );
@@ -12781,11 +14178,17 @@ function registerAcpWsEndpoint(app, deps) {
12781
14178
  params.clientInfo,
12782
14179
  params.clientId
12783
14180
  );
12784
- const drip = params.replayMode === "drip";
14181
+ const drip = hydraAttach.replayMode === "drip";
12785
14182
  const { entries: replay, appliedPolicy } = await session.attach(
12786
14183
  client,
12787
14184
  params.historyPolicy,
12788
- { afterMessageId: params.afterMessageId, raw: drip }
14185
+ {
14186
+ afterMessageId: params.afterMessageId,
14187
+ raw: drip,
14188
+ // Lean clients opt into ref-form tool content via _meta; default
14189
+ // stays inline so existing/third-party clients are unaffected.
14190
+ ...hydraAttach.toolContent !== void 0 ? { toolContent: hydraAttach.toolContent } : {}
14191
+ }
12789
14192
  );
12790
14193
  state.attached.set(session.sessionId, {
12791
14194
  sessionId: session.sessionId,
@@ -12796,7 +14199,7 @@ function registerAcpWsEndpoint(app, deps) {
12796
14199
  `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length} readonly=${readonly}${drip ? " replayMode=drip" : ""}`
12797
14200
  );
12798
14201
  if (drip) {
12799
- const speed = params.dripSpeed && params.dripSpeed > 0 ? params.dripSpeed : 1;
14202
+ const speed = hydraAttach.dripSpeed && hydraAttach.dripSpeed > 0 ? hydraAttach.dripSpeed : 1;
12800
14203
  const MAX_GAP_MS = 750;
12801
14204
  void (async () => {
12802
14205
  let prev = null;
@@ -12819,8 +14222,13 @@ function registerAcpWsEndpoint(app, deps) {
12819
14222
  }
12820
14223
  })();
12821
14224
  } else {
12822
- for (const note of replay) {
12823
- await connection.notify(note.method, note.params);
14225
+ const REPLAY_FLUSH_EVERY = 200;
14226
+ for (let i = 0; i < replay.length; i++) {
14227
+ const note = replay[i];
14228
+ const pending = connection.notify(note.method, note.params).catch(() => void 0);
14229
+ if ((i + 1) % REPLAY_FLUSH_EVERY === 0) {
14230
+ await pending;
14231
+ }
12824
14232
  }
12825
14233
  }
12826
14234
  session.replayPendingPermissions(client);
@@ -12838,7 +14246,8 @@ function registerAcpWsEndpoint(app, deps) {
12838
14246
  replayed: replay.length,
12839
14247
  ...modesPayload ? { modes: modesPayload } : {},
12840
14248
  ...modelsPayload ? { models: modelsPayload } : {},
12841
- _meta: buildResponseMeta(session)
14249
+ configOptions: session.buildConfigOptions(),
14250
+ _meta: buildResponseMeta(deps.manager, session)
12842
14251
  };
12843
14252
  });
12844
14253
  connection.onRequest("session/detach", async (raw) => {
@@ -12855,7 +14264,10 @@ function registerAcpWsEndpoint(app, deps) {
12855
14264
  if (session) {
12856
14265
  void deps.manager.reapIfOrphanedNonInteractive(params.sessionId);
12857
14266
  }
12858
- return { sessionId: params.sessionId, status: "detached" };
14267
+ return {
14268
+ sessionId: params.sessionId,
14269
+ _meta: { [HYDRA_META_KEY]: { detachStatus: "detached" } }
14270
+ };
12859
14271
  });
12860
14272
  connection.onRequest("session/list", async (raw) => {
12861
14273
  const params = SessionListParams.parse(raw ?? {});
@@ -12868,6 +14280,14 @@ function registerAcpWsEndpoint(app, deps) {
12868
14280
  };
12869
14281
  return result;
12870
14282
  });
14283
+ connection.onRequest("hydra-acp/agents/list", async () => {
14284
+ if (!deps.registry) {
14285
+ const err = new Error("agent registry unavailable");
14286
+ err.code = JsonRpcErrorCodes.InternalError;
14287
+ throw err;
14288
+ }
14289
+ return listAgents(deps.registry);
14290
+ });
12871
14291
  connection.onRequest("session/prompt", async (raw) => {
12872
14292
  const params = SessionPromptParams.parse(raw);
12873
14293
  denyIfReadonly(params.sessionId, "session/prompt");
@@ -12943,9 +14363,9 @@ function registerAcpWsEndpoint(app, deps) {
12943
14363
  handleCancelParams(raw);
12944
14364
  return null;
12945
14365
  });
12946
- connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
14366
+ connection.onRequest("hydra-acp/prompt/cancel", async (raw) => {
12947
14367
  const params = CancelPromptParams.parse(raw);
12948
- denyIfReadonly(params.sessionId, "hydra-acp/cancel_prompt");
14368
+ denyIfReadonly(params.sessionId, "hydra-acp/prompt/cancel");
12949
14369
  const session = deps.manager.get(params.sessionId);
12950
14370
  if (!session) {
12951
14371
  const err = new Error(`session ${params.sessionId} not found`);
@@ -12954,78 +14374,44 @@ function registerAcpWsEndpoint(app, deps) {
12954
14374
  }
12955
14375
  return session.cancelQueuedPrompt(params.messageId);
12956
14376
  });
12957
- connection.onRequest("hydra-acp/update_prompt", async (raw) => {
12958
- const params = UpdatePromptParams.parse(raw);
12959
- denyIfReadonly(params.sessionId, "hydra-acp/update_prompt");
12960
- const session = deps.manager.get(params.sessionId);
12961
- if (!session) {
12962
- const err = new Error(`session ${params.sessionId} not found`);
12963
- err.code = JsonRpcErrorCodes.SessionNotFound;
12964
- throw err;
12965
- }
12966
- return session.updateQueuedPrompt(params.messageId, params.prompt);
12967
- });
12968
- connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
12969
- const params = AmendPromptParams.parse(raw);
12970
- denyIfReadonly(params.sessionId, "hydra-acp/amend_prompt");
12971
- const att = state.attached.get(params.sessionId);
12972
- if (!att) {
12973
- const err = new Error("not attached to session");
12974
- err.code = JsonRpcErrorCodes.SessionNotFound;
12975
- throw err;
12976
- }
14377
+ connection.onRequest("hydra-acp/session/force_cancel", async (raw) => {
14378
+ const params = SessionCancelParams.parse(raw);
14379
+ denyIfReadonly(params.sessionId, "hydra-acp/session/force_cancel");
12977
14380
  const session = deps.manager.get(params.sessionId);
12978
14381
  if (!session) {
12979
14382
  const err = new Error(`session ${params.sessionId} not found`);
12980
14383
  err.code = JsonRpcErrorCodes.SessionNotFound;
12981
14384
  throw err;
12982
14385
  }
12983
- return session.amendPrompt(att.clientId, params);
14386
+ return session.forceCancel();
12984
14387
  });
12985
- connection.onRequest("hydra-acp/stream_open", async (raw) => {
12986
- const params = StreamOpenParams.parse(raw);
12987
- denyIfReadonly(params.sessionId, "hydra-acp/stream_open");
14388
+ connection.onRequest("hydra-acp/prompt/update", async (raw) => {
14389
+ const params = UpdatePromptParams.parse(raw);
14390
+ denyIfReadonly(params.sessionId, "hydra-acp/prompt/update");
12988
14391
  const session = deps.manager.get(params.sessionId);
12989
14392
  if (!session) {
12990
14393
  const err = new Error(`session ${params.sessionId} not found`);
12991
14394
  err.code = JsonRpcErrorCodes.SessionNotFound;
12992
14395
  throw err;
12993
14396
  }
12994
- const openOpts = {};
12995
- if (params.mode !== void 0) {
12996
- openOpts.mode = params.mode;
12997
- }
12998
- if (params.capacityBytes !== void 0) {
12999
- openOpts.capacityBytes = params.capacityBytes;
13000
- }
13001
- if (params.fileCapBytes !== void 0) {
13002
- openOpts.fileCapBytes = params.fileCapBytes;
13003
- }
13004
- if ((params.mode ?? "memory") === "file") {
13005
- openOpts.filePathFor = (sid) => path14.join(os4.tmpdir(), `hydra-acp-stdin-${sid}.log`);
13006
- }
13007
- return session.openStream(openOpts);
14397
+ return session.updateQueuedPrompt(params.messageId, params.prompt);
13008
14398
  });
13009
- connection.onRequest("hydra-acp/stream_write", async (raw) => {
13010
- const params = StreamWriteParams.parse(raw);
13011
- denyIfReadonly(params.sessionId, "hydra-acp/stream_write");
13012
- const session = deps.manager.get(params.sessionId);
13013
- if (!session) {
13014
- const err = new Error(`session ${params.sessionId} not found`);
14399
+ connection.onRequest("hydra-acp/prompt/amend", async (raw) => {
14400
+ const params = AmendPromptParams.parse(raw);
14401
+ denyIfReadonly(params.sessionId, "hydra-acp/prompt/amend");
14402
+ const att = state.attached.get(params.sessionId);
14403
+ if (!att) {
14404
+ const err = new Error("not attached to session");
13015
14405
  err.code = JsonRpcErrorCodes.SessionNotFound;
13016
14406
  throw err;
13017
14407
  }
13018
- return session.streamWrite(params.chunk, params.eof);
13019
- });
13020
- connection.onRequest("hydra-acp/stream_read", async (raw) => {
13021
- const params = StreamReadParams.parse(raw);
13022
14408
  const session = deps.manager.get(params.sessionId);
13023
14409
  if (!session) {
13024
14410
  const err = new Error(`session ${params.sessionId} not found`);
13025
14411
  err.code = JsonRpcErrorCodes.SessionNotFound;
13026
14412
  throw err;
13027
14413
  }
13028
- return session.streamRead(params.cursor, params.maxBytes, params.waitMs);
14414
+ return session.amendPrompt(att.clientId, params);
13029
14415
  });
13030
14416
  connection.onRequest("session/load", async (raw) => {
13031
14417
  const rawObj = raw ?? {};
@@ -13064,12 +14450,15 @@ function registerAcpWsEndpoint(app, deps) {
13064
14450
  const modelsPayload = buildModelsPayload(session);
13065
14451
  return {
13066
14452
  sessionId: session.sessionId,
13067
- // Same as session/new: include clientId so the deferred-echo
13068
- // path in queue-aware clients can recognize own broadcasts.
13069
- clientId: client.clientId,
13070
14453
  ...modesPayload ? { modes: modesPayload } : {},
13071
14454
  ...modelsPayload ? { models: modelsPayload } : {},
13072
- _meta: buildResponseMeta(session)
14455
+ configOptions: session.buildConfigOptions(),
14456
+ // session/load is a core ACP spec method: clientId rides under
14457
+ // _meta["hydra-acp"] (not top-level), same as session/new. Lets
14458
+ // deferred-echo clients recognize their own broadcasts.
14459
+ _meta: buildResponseMeta(deps.manager, session, {
14460
+ clientId: client.clientId
14461
+ })
13073
14462
  };
13074
14463
  });
13075
14464
  connection.onRequest("session/set_model", async (rawParams) => {
@@ -13096,8 +14485,11 @@ function registerAcpWsEndpoint(app, deps) {
13096
14485
  return null;
13097
14486
  }
13098
14487
  app.log.info(decision.logMessage);
13099
- const { modelId } = rawParams;
13100
- const result = await decision.session.forwardRequest("session/set_model", rawParams);
14488
+ const { modelId } = decision;
14489
+ const result = await decision.session.forwardRequest("session/set_model", {
14490
+ ...rawParams,
14491
+ modelId
14492
+ });
13101
14493
  decision.session.applyModelChange(modelId);
13102
14494
  return result;
13103
14495
  });
@@ -13127,6 +14519,79 @@ function registerAcpWsEndpoint(app, deps) {
13127
14519
  session.applyModeChange(params.modeId);
13128
14520
  return result;
13129
14521
  });
14522
+ connection.onRequest("session/set_config_option", async (rawParams) => {
14523
+ const params = rawParams;
14524
+ const invalid = (msg) => {
14525
+ const err = new Error(msg);
14526
+ err.code = JsonRpcErrorCodes.InvalidParams;
14527
+ return err;
14528
+ };
14529
+ const sessionIdField = params?.sessionId;
14530
+ if (typeof sessionIdField === "string") {
14531
+ denyIfReadonly(sessionIdField, "session/set_config_option");
14532
+ }
14533
+ if (!params || typeof params.sessionId !== "string") {
14534
+ throw invalid("session/set_config_option requires string sessionId");
14535
+ }
14536
+ if (typeof params.configId !== "string") {
14537
+ throw invalid("session/set_config_option requires string configId");
14538
+ }
14539
+ if (typeof params.value !== "string") {
14540
+ throw invalid("session/set_config_option requires string value");
14541
+ }
14542
+ const session = deps.manager.get(params.sessionId);
14543
+ if (!session) {
14544
+ const err = new Error(
14545
+ `session ${params.sessionId} not found`
14546
+ );
14547
+ err.code = JsonRpcErrorCodes.SessionNotFound;
14548
+ throw err;
14549
+ }
14550
+ const option = session.buildConfigOptions().find((o) => o.id === params.configId);
14551
+ if (!option) {
14552
+ throw invalid(
14553
+ `unknown configId ${JSON.stringify(params.configId)} for this session`
14554
+ );
14555
+ }
14556
+ if (!option.options.some((v) => v.value === params.value)) {
14557
+ throw invalid(
14558
+ `value ${JSON.stringify(params.value)} is not valid for configId ${JSON.stringify(params.configId)}`
14559
+ );
14560
+ }
14561
+ switch (params.configId) {
14562
+ case "model": {
14563
+ if (params.value !== session.currentModel) {
14564
+ await session.forwardRequest("session/set_model", {
14565
+ sessionId: params.sessionId,
14566
+ modelId: params.value
14567
+ });
14568
+ }
14569
+ session.applyModelChange(params.value);
14570
+ break;
14571
+ }
14572
+ case "mode": {
14573
+ if (params.value !== session.currentMode) {
14574
+ await session.forwardRequest("session/set_mode", {
14575
+ sessionId: params.sessionId,
14576
+ modeId: params.value
14577
+ });
14578
+ }
14579
+ session.applyModeChange(params.value);
14580
+ break;
14581
+ }
14582
+ case "agent": {
14583
+ if (params.value !== session.agentId) {
14584
+ await session.setAgent(params.value);
14585
+ }
14586
+ break;
14587
+ }
14588
+ default:
14589
+ throw invalid(
14590
+ `configId ${JSON.stringify(params.configId)} is not settable`
14591
+ );
14592
+ }
14593
+ return { configOptions: session.buildConfigOptions() };
14594
+ });
13130
14595
  connection.setDefaultHandler(async (rawParams, method) => {
13131
14596
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
13132
14597
  const err = new Error(`Method not found: ${method}`);
@@ -13246,114 +14711,101 @@ function decideSetModel(rawParams, manager) {
13246
14711
  };
13247
14712
  }
13248
14713
  const advertised = session.availableModels();
13249
- if (advertised.length === 0) {
14714
+ const resolution = resolveModelId(params.modelId, advertised);
14715
+ if (resolution.kind === "none") {
13250
14716
  return {
13251
14717
  kind: "ok",
13252
14718
  session,
14719
+ modelId: params.modelId,
13253
14720
  logMessage: `session/set_model passthrough (no availableModels) sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
13254
14721
  };
13255
14722
  }
13256
- const match = advertised.find((m) => m.modelId === params.modelId);
13257
- if (!match) {
13258
- const known = advertised.map((m) => m.modelId).join(", ");
13259
- if (session.currentModel !== void 0 && session.currentModel.length > 0) {
13260
- return {
13261
- kind: "no_op",
13262
- session,
13263
- sessionId: params.sessionId,
13264
- currentModel: session.currentModel,
13265
- logMessage: `session/set_model no_op (resyncing client) sessionId=${params.sessionId} requested=${JSON.stringify(params.modelId)} actual=${JSON.stringify(session.currentModel)} agentId=${session.agentId} known=[${known}]`
13266
- };
13267
- }
14723
+ if (resolution.kind === "exact") {
13268
14724
  return {
13269
- kind: "error",
13270
- code: JsonRpcErrorCodes.InvalidParams,
13271
- message: `model "${params.modelId}" is not in this session's availableModels (agent ${session.agentId}); known models: ${known}`,
13272
- logMessage: `session/set_model rejected sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)} agentId=${session.agentId} known=[${known}] (no current model to fall back to)`
14725
+ kind: "ok",
14726
+ session,
14727
+ modelId: params.modelId,
14728
+ logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
14729
+ };
14730
+ }
14731
+ if (resolution.kind === "resolved") {
14732
+ return {
14733
+ kind: "ok",
14734
+ session,
14735
+ modelId: resolution.modelId,
14736
+ logMessage: `session/set_model resolved sessionId=${params.sessionId} requested=${JSON.stringify(params.modelId)} \u2192 ${JSON.stringify(resolution.modelId)}`
14737
+ };
14738
+ }
14739
+ const known = advertised.map((m) => m.modelId).join(", ");
14740
+ const detail = resolution.kind === "ambiguous" ? `ambiguous (trailing-segment matches [${resolution.candidates.join(", ")}])` : `not in availableModels`;
14741
+ if (session.currentModel !== void 0 && session.currentModel.length > 0) {
14742
+ return {
14743
+ kind: "no_op",
14744
+ session,
14745
+ sessionId: params.sessionId,
14746
+ currentModel: session.currentModel,
14747
+ logMessage: `session/set_model no_op (resyncing client) sessionId=${params.sessionId} requested=${JSON.stringify(params.modelId)} ${detail} actual=${JSON.stringify(session.currentModel)} agentId=${session.agentId} known=[${known}]`
13273
14748
  };
13274
14749
  }
13275
14750
  return {
13276
- kind: "ok",
13277
- session,
13278
- logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
14751
+ kind: "error",
14752
+ code: JsonRpcErrorCodes.InvalidParams,
14753
+ message: `model "${params.modelId}" is ${detail === "not in availableModels" ? "not in this session's availableModels" : detail} (agent ${session.agentId}); known models: ${known}`,
14754
+ logMessage: `session/set_model rejected sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)} ${detail} agentId=${session.agentId} known=[${known}] (no current model to fall back to)`
13279
14755
  };
13280
14756
  }
13281
14757
  function buildViewerResponseMeta(fromDisk) {
13282
- const ours = {
14758
+ const entry = {
14759
+ sessionId: fromDisk.hydraSessionId,
13283
14760
  upstreamSessionId: fromDisk.upstreamSessionId,
14761
+ cwd: fromDisk.cwd,
14762
+ title: fromDisk.title,
13284
14763
  agentId: fromDisk.agentId,
13285
- cwd: fromDisk.cwd
14764
+ currentModel: fromDisk.currentModel,
14765
+ currentUsage: fromDisk.currentUsage,
14766
+ forkedFromSessionId: fromDisk.forkedFromSessionId,
14767
+ forkedFromMessageId: fromDisk.forkedFromMessageId,
14768
+ originatingClient: fromDisk.originatingClient,
14769
+ interactive: fromDisk.interactive,
14770
+ updatedAt: fromDisk.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
14771
+ attachedClients: 0,
14772
+ status: "cold",
14773
+ busy: false,
14774
+ awaitingInput: false
13286
14775
  };
13287
- if (fromDisk.title !== void 0) {
13288
- ours.name = fromDisk.title;
13289
- }
13290
- if (fromDisk.agentArgs && fromDisk.agentArgs.length > 0) {
13291
- ours.agentArgs = fromDisk.agentArgs;
13292
- }
13293
- if (fromDisk.currentModel !== void 0) {
13294
- ours.currentModel = fromDisk.currentModel;
13295
- }
13296
- if (fromDisk.currentMode !== void 0) {
13297
- ours.currentMode = fromDisk.currentMode;
13298
- }
13299
- if (fromDisk.currentUsage !== void 0) {
13300
- ours.currentUsage = fromDisk.currentUsage;
13301
- }
13302
- if (fromDisk.agentCommands && fromDisk.agentCommands.length > 0) {
13303
- ours.availableCommands = fromDisk.agentCommands;
13304
- }
13305
- if (fromDisk.agentModes && fromDisk.agentModes.length > 0) {
13306
- ours.availableModes = fromDisk.agentModes;
13307
- }
13308
- if (fromDisk.agentModels && fromDisk.agentModels.length > 0) {
13309
- ours.availableModels = fromDisk.agentModels;
13310
- }
13311
- return { [HYDRA_META_KEY]: ours };
14776
+ const extras = {
14777
+ currentMode: fromDisk.currentMode,
14778
+ agentArgs: fromDisk.agentArgs,
14779
+ availableCommands: fromDisk.agentCommands,
14780
+ availableModes: fromDisk.agentModes,
14781
+ availableModels: fromDisk.agentModels
14782
+ };
14783
+ return { [HYDRA_META_KEY]: buildHydraSessionMeta(entry, extras) };
13312
14784
  }
13313
- function buildResponseMeta(session) {
13314
- const ours = {
13315
- upstreamSessionId: session.upstreamSessionId,
13316
- agentId: session.agentId,
13317
- cwd: session.cwd
14785
+ function buildResponseMeta(manager, session, opts = {}) {
14786
+ const entry = manager.liveListEntry(session);
14787
+ const extras = {
14788
+ clientId: opts.clientId,
14789
+ currentMode: session.currentMode,
14790
+ agentArgs: session.agentArgs,
14791
+ availableCommands: session.mergedAvailableCommands(),
14792
+ availableModes: session.availableModes(),
14793
+ availableModels: session.availableModels(),
14794
+ // Mid-turn at attach time: hand the client the original prompt's
14795
+ // recordedAt so it can boot directly into "busy · Ns" instead of
14796
+ // sitting on "ready" until the next live notification.
14797
+ turnStartedAt: session.turnStartedAt,
14798
+ // The underlying agent's own initialize-time capability claim, captured
14799
+ // verbatim. Lets capability-aware clients (cat --stream) pick the right
14800
+ // consumption surface without re-probing the agent.
14801
+ agentCapabilities: session.agentCapabilities,
14802
+ // Snapshot of the daemon-owned prompt queue. Lets a late attacher
14803
+ // paint queue chips for entries that landed before it joined without
14804
+ // waiting for new prompt_queue_added notifications. Omitted entirely
14805
+ // when the queue is empty (the common case).
14806
+ queue: session.queueSnapshot()
13318
14807
  };
13319
- if (session.title !== void 0) {
13320
- ours.name = session.title;
13321
- }
13322
- if (session.agentArgs && session.agentArgs.length > 0) {
13323
- ours.agentArgs = session.agentArgs;
13324
- }
13325
- if (session.currentModel !== void 0) {
13326
- ours.currentModel = session.currentModel;
13327
- }
13328
- if (session.currentMode !== void 0) {
13329
- ours.currentMode = session.currentMode;
13330
- }
13331
- if (session.currentUsage !== void 0) {
13332
- ours.currentUsage = session.currentUsage;
13333
- }
13334
- const commands = session.mergedAvailableCommands();
13335
- if (commands.length > 0) {
13336
- ours.availableCommands = commands;
13337
- }
13338
- const modes = session.availableModes();
13339
- if (modes.length > 0) {
13340
- ours.availableModes = modes;
13341
- }
13342
- const models = session.availableModels();
13343
- if (models.length > 0) {
13344
- ours.availableModels = models;
13345
- }
13346
- if (session.turnStartedAt !== void 0) {
13347
- ours.turnStartedAt = session.turnStartedAt;
13348
- }
13349
- if (session.agentCapabilities !== void 0) {
13350
- ours.agentCapabilities = session.agentCapabilities;
13351
- }
13352
- const queue = session.queueSnapshot();
13353
- if (queue.length > 0) {
13354
- ours.queue = queue;
13355
- }
13356
- return mergeMeta(session.agentMeta, ours);
14808
+ return mergeMeta(session.agentMeta, buildHydraSessionMeta(entry, extras));
13357
14809
  }
13358
14810
  function buildInitializeResult() {
13359
14811
  return {
@@ -13384,18 +14836,26 @@ function buildInitializeResult() {
13384
14836
  description: "Bearer token presented at WS upgrade"
13385
14837
  }
13386
14838
  ],
13387
- // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
13388
- // ACP clients ignore the field; capability-aware clients learn here
13389
- // which hydra-acp extensions the daemon supports so they can gate
13390
- // UI surface accordingly. promptPipelining is false until the
13391
- // streaming-input probe lands (Option A in the steering brief);
13392
- // the others are unconditional method-availability flags.
14839
+ // Advertise hydra-only capabilities via _meta["hydra-acp"], grouped by
14840
+ // resource to mirror the hydra-acp/<resource>/<action> method
14841
+ // namespaces. Generic ACP clients ignore the field; capability-aware
14842
+ // clients probe here to gate UI before calling a method. `pipelining`
14843
+ // is false until the streaming-input probe lands; the rest are
14844
+ // unconditional method-availability flags. (Named `prompt`/`agents`,
14845
+ // not `promptCapabilities`/`agentCapabilities` — those are ACP spec
14846
+ // names with different meanings.)
13393
14847
  _meta: mergeMeta(void 0, {
13394
- promptQueueing: true,
13395
- promptCancelling: true,
13396
- promptUpdating: true,
13397
- promptAmending: true,
13398
- promptPipelining: false
14848
+ prompt: {
14849
+ queueing: true,
14850
+ cancelling: true,
14851
+ updating: true,
14852
+ amending: true,
14853
+ pipelining: false
14854
+ },
14855
+ agents: {
14856
+ list: true,
14857
+ installProgress: true
14858
+ }
13399
14859
  })
13400
14860
  };
13401
14861
  }
@@ -13493,7 +14953,7 @@ var McpTokenRegistry = class {
13493
14953
  import { randomUUID } from "crypto";
13494
14954
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13495
14955
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13496
- import { z as z8 } from "zod";
14956
+ import { z as z9 } from "zod";
13497
14957
 
13498
14958
  // src/daemon/mcp/bearer.ts
13499
14959
  var BEARER_PREFIX2 = "Bearer ";
@@ -13522,7 +14982,7 @@ function buildMcpServer(session) {
13522
14982
  {
13523
14983
  description: "Return the most recent `bytes` bytes of piped stdin (capped server-side, default 64 KiB max). `truncated:true` means older bytes existed but have been evicted from the ring.",
13524
14984
  inputSchema: {
13525
- bytes: z8.number().int().min(1).describe("How many trailing bytes to return.")
14985
+ bytes: z9.number().int().min(1).describe("How many trailing bytes to return.")
13526
14986
  }
13527
14987
  },
13528
14988
  async ({ bytes }) => {
@@ -13543,7 +15003,7 @@ function buildMcpServer(session) {
13543
15003
  {
13544
15004
  description: "Return the first `bytes` bytes of piped stdin (capped server-side, default 64 KiB max). `truncated:true` means the head has already been evicted from the ring and the returned bytes start at the oldest still-resident cursor.",
13545
15005
  inputSchema: {
13546
- bytes: z8.number().int().min(1).describe("How many leading bytes to return.")
15006
+ bytes: z9.number().int().min(1).describe("How many leading bytes to return.")
13547
15007
  }
13548
15008
  },
13549
15009
  async ({ bytes }) => {
@@ -13564,13 +15024,13 @@ function buildMcpServer(session) {
13564
15024
  {
13565
15025
  description: "Read up to `max_bytes` bytes starting at absolute byte `cursor`. Returns `{bytes, nextCursor, gap?, eof?}` \u2014 `gap` is the number of bytes silently skipped because the ring had evicted them; `eof:true` means the producer closed and there is nothing left to read.",
13566
15026
  inputSchema: {
13567
- cursor: z8.number().int().min(0).describe(
15027
+ cursor: z9.number().int().min(0).describe(
13568
15028
  "Absolute byte offset to start reading from. Use 0 to read from the very beginning (may produce a gap if old bytes have been evicted)."
13569
15029
  ),
13570
- max_bytes: z8.number().int().min(1).optional().describe(
15030
+ max_bytes: z9.number().int().min(1).optional().describe(
13571
15031
  "Optional cap on how many bytes to return. Server caps at 64 KiB regardless."
13572
15032
  ),
13573
- wait_ms: z8.number().int().min(0).optional().describe(
15033
+ wait_ms: z9.number().int().min(0).optional().describe(
13574
15034
  "If no bytes are available, block up to this many ms for more (capped server-side at 60_000)."
13575
15035
  )
13576
15036
  }
@@ -13593,8 +15053,8 @@ function buildMcpServer(session) {
13593
15053
  {
13594
15054
  description: "Block until bytes are available past `cursor`, the stream closes, or `timeout_ms` elapses. Returns one of {data, eof, timeout} plus the current `writeCursor`. Use this when you've consumed everything up to a cursor and want to wait for more without busy-polling.",
13595
15055
  inputSchema: {
13596
- cursor: z8.number().int().min(0).describe("The cursor you've already consumed up to."),
13597
- timeout_ms: z8.number().int().min(0).describe("Maximum ms to block (server caps at 60_000).")
15056
+ cursor: z9.number().int().min(0).describe("The cursor you've already consumed up to."),
15057
+ timeout_ms: z9.number().int().min(0).describe("Maximum ms to block (server caps at 60_000).")
13598
15058
  }
13599
15059
  },
13600
15060
  async ({ cursor, timeout_ms }) => {
@@ -13617,17 +15077,17 @@ function buildMcpServer(session) {
13617
15077
  {
13618
15078
  description: "Scan piped stdin line-by-line and return lines matching `pattern`. Prefer this over `read` when the question is 'find lines that mention X' \u2014 it filters server-side so you don't pull and decode 64 KiB base64 windows. Returns `{matches: [{cursor, line, before?, after?}], truncated, nextCursor, gap?, scannedBytes, eof?}`. Lines come back as decoded UTF-8 strings (not base64). When `truncated:true`, re-call with `cursor: nextCursor` to resume.",
13619
15079
  inputSchema: {
13620
- pattern: z8.string().min(1).describe(
15080
+ pattern: z9.string().min(1).describe(
13621
15081
  "Search pattern. Treated as a JavaScript regular expression by default (set `regex:false` for a literal substring match)."
13622
15082
  ),
13623
- regex: z8.boolean().optional().describe("Default true. Pass false to treat `pattern` as a literal substring."),
13624
- case_insensitive: z8.boolean().optional().describe("Default false. Pass true for case-insensitive matching."),
13625
- invert: z8.boolean().optional().describe("Default false. Pass true to return lines that do NOT match the pattern."),
13626
- max_matches: z8.number().int().min(1).optional().describe("Default 100. Capped server-side at 1000."),
13627
- max_bytes: z8.number().int().min(1).optional().describe("Default 64 KiB output. Capped server-side at 256 KiB."),
13628
- context_before: z8.number().int().min(0).optional().describe("Default 0. Number of lines before each match to include (capped at 20)."),
13629
- context_after: z8.number().int().min(0).optional().describe("Default 0. Number of lines after each match to include (capped at 20)."),
13630
- cursor: z8.number().int().min(0).optional().describe(
15083
+ regex: z9.boolean().optional().describe("Default true. Pass false to treat `pattern` as a literal substring."),
15084
+ case_insensitive: z9.boolean().optional().describe("Default false. Pass true for case-insensitive matching."),
15085
+ invert: z9.boolean().optional().describe("Default false. Pass true to return lines that do NOT match the pattern."),
15086
+ max_matches: z9.number().int().min(1).optional().describe("Default 100. Capped server-side at 1000."),
15087
+ max_bytes: z9.number().int().min(1).optional().describe("Default 64 KiB output. Capped server-side at 256 KiB."),
15088
+ context_before: z9.number().int().min(0).optional().describe("Default 0. Number of lines before each match to include (capped at 20)."),
15089
+ context_after: z9.number().int().min(0).optional().describe("Default 0. Number of lines after each match to include (capped at 20)."),
15090
+ cursor: z9.number().int().min(0).optional().describe(
13631
15091
  "Optional absolute byte offset to start scanning from. Omit to scan from the oldest still-resident byte. Pass the `nextCursor` from a previous truncated call to resume."
13632
15092
  )
13633
15093
  }
@@ -13884,7 +15344,7 @@ async function invokeWithTimeout(connection, server, tool, args, timeoutMs) {
13884
15344
  });
13885
15345
  try {
13886
15346
  return await Promise.race([
13887
- connection.request("hydra-acp/invoke_mcp_tool", {
15347
+ connection.request("hydra-acp/mcp_tools/invoke", {
13888
15348
  server,
13889
15349
  tool,
13890
15350
  args
@@ -14091,6 +15551,7 @@ async function startDaemon(config, serviceToken) {
14091
15551
  stderrTailBytes: config.daemon.agentStderrTailBytes,
14092
15552
  logger: agentLogger
14093
15553
  });
15554
+ setToolBlobCompression(config.compressToolContent);
14094
15555
  const extensionCommands = new ExtensionCommandRegistry();
14095
15556
  const manager = new SessionManager(registry, spawner, void 0, {
14096
15557
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
@@ -14161,7 +15622,8 @@ async function startDaemon(config, serviceToken) {
14161
15622
  extensionCommands,
14162
15623
  mcpTokenRegistry,
14163
15624
  extensionMcp,
14164
- getDaemonOrigin
15625
+ getDaemonOrigin,
15626
+ registry
14165
15627
  });
14166
15628
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
14167
15629
  const address = app.server.address();
@@ -14221,7 +15683,7 @@ async function startDaemon(config, serviceToken) {
14221
15683
  setAgentPruneLogger(null);
14222
15684
  await app.close();
14223
15685
  try {
14224
- fs16.unlinkSync(paths.pidFile());
15686
+ fs18.unlinkSync(paths.pidFile());
14225
15687
  } catch {
14226
15688
  }
14227
15689
  try {