@hydra-acp/cli 0.1.61 → 0.1.62

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 +2275 -755
  3. package/dist/index.d.ts +231 -22
  4. package/dist/index.js +1372 -541
  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 fs17 from "fs";
3
3
  import * as fsp7 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -88,6 +88,23 @@ var paths = {
88
88
  // the agent invocation (see Session.drainQueue) so a crash mid-
89
89
  // generation doesn't double-run on restart.
90
90
  queueFile: (id) => path.join(hydraHome(), "sessions", id, "queue.ndjson"),
91
+ // Tombstones for sessions that were deleted locally but might still
92
+ // be reported by an agent's session/list at the next periodic sync.
93
+ // One file per (agentId, upstreamSessionId); existence is the source
94
+ // of truth, contents are a small JSON blob for diagnostics and the
95
+ // "agent advanced past our snapshot → resurrect" decision. Hidden
96
+ // under sessions/ because SessionStore.read() filters non-conforming
97
+ // dir names (the leading dot fails SESSION_ID_PATTERN) so the
98
+ // directory cohabits safely with real session directories.
99
+ tombstonesDir: () => path.join(hydraHome(), "sessions", ".tombstones"),
100
+ tombstoneAgentDir: (agentId) => path.join(hydraHome(), "sessions", ".tombstones", encodeURIComponent(agentId)),
101
+ tombstoneFile: (agentId, upstreamSessionId) => path.join(
102
+ hydraHome(),
103
+ "sessions",
104
+ ".tombstones",
105
+ encodeURIComponent(agentId),
106
+ encodeURIComponent(upstreamSessionId)
107
+ ),
91
108
  extensionsDir: () => path.join(hydraHome(), "extensions"),
92
109
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
93
110
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
@@ -267,7 +284,27 @@ var DaemonConfig = z.object({
267
284
  });
268
285
  var RegistryConfig = z.object({
269
286
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
270
- ttlHours: z.number().positive().default(24)
287
+ ttlHours: z.number().positive().default(24),
288
+ // When true, the daemon never re-fetches the registry over the network:
289
+ // it serves whatever is in the on-disk cache (~/.hydra-acp/registry.json)
290
+ // indefinitely, ignoring ttlHours. An escape hatch for when a bad registry
291
+ // push breaks an agent — pin to the last-known-good cache until upstream
292
+ // is fixed. `hydra registry refresh` still forces a one-off fetch.
293
+ pinned: z.boolean().default(false)
294
+ });
295
+ var LocalAgentConfig = z.object({
296
+ name: z.string().optional(),
297
+ description: z.string().optional(),
298
+ // Optional: defaults to the agent id (the config.agents key), mirroring
299
+ // how extensions default their command to the extension name. Set it
300
+ // when the executable differs from the id, or to point at an absolute
301
+ // path / wrapper script.
302
+ command: z.string().optional(),
303
+ args: z.array(z.string()).optional(),
304
+ env: z.record(z.string()).optional()
305
+ });
306
+ var AgentOverrideConfig = z.object({
307
+ packageSpec: z.string().optional()
271
308
  });
272
309
  var TuiConfig = z.object({
273
310
  // Minimum interval (ms) between full-screen repaints driven by content
@@ -385,7 +422,18 @@ var TransformerBody = z.object({
385
422
  });
386
423
  var HydraConfig = z.object({
387
424
  daemon: DaemonConfig.default({}),
388
- registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
425
+ registry: RegistryConfig.default({
426
+ url: REGISTRY_URL_DEFAULT,
427
+ ttlHours: 24,
428
+ pinned: false
429
+ }),
430
+ // User-defined agents that bypass the network registry. Keyed by agent
431
+ // id; each is spawned via its `command`/`args` directly. Shadow registry
432
+ // agents of the same id.
433
+ agents: z.record(z.string(), LocalAgentConfig).default({}),
434
+ // Per-agent pin overrides applied to registry agents (e.g. force a
435
+ // specific npm version of opencode). Keyed by agent id.
436
+ agentOverrides: z.record(z.string(), AgentOverrideConfig).default({}),
389
437
  defaultAgent: z.string().default("opencode"),
390
438
  // Optional per-agent default model id. When a brand-new agent process
391
439
  // is spawned (session/new path), hydra issues session/set_model with
@@ -1062,10 +1110,16 @@ var UvxDistribution = z2.object({
1062
1110
  args: z2.array(z2.string()).optional(),
1063
1111
  env: z2.record(z2.string()).optional()
1064
1112
  });
1113
+ var ExecDistribution = z2.object({
1114
+ command: z2.string(),
1115
+ args: z2.array(z2.string()).optional(),
1116
+ env: z2.record(z2.string()).optional()
1117
+ });
1065
1118
  var Distribution = z2.object({
1066
1119
  npx: NpxDistribution.optional(),
1067
1120
  binary: BinaryDistribution.optional(),
1068
- uvx: UvxDistribution.optional()
1121
+ uvx: UvxDistribution.optional(),
1122
+ exec: ExecDistribution.optional()
1069
1123
  });
1070
1124
  var RegistryAgent = z2.object({
1071
1125
  id: z2.string(),
@@ -1093,11 +1147,11 @@ var Registry = class {
1093
1147
  options;
1094
1148
  cache;
1095
1149
  async load() {
1096
- if (this.cache && this.isFresh(this.cache.fetchedAt)) {
1150
+ if (this.cache && (this.isPinned() || this.isFresh(this.cache.fetchedAt))) {
1097
1151
  return this.cache.data;
1098
1152
  }
1099
1153
  const onDisk = await this.readDiskCache();
1100
- if (onDisk && this.isFresh(onDisk.fetchedAt)) {
1154
+ if (onDisk && (this.isPinned() || this.isFresh(onDisk.fetchedAt))) {
1101
1155
  this.cache = onDisk;
1102
1156
  return onDisk.data;
1103
1157
  }
@@ -1128,12 +1182,57 @@ var Registry = class {
1128
1182
  return this.cache?.fetchedAt;
1129
1183
  }
1130
1184
  async getAgent(id) {
1185
+ const local = this.localAgents().find((a) => a.id === id);
1186
+ if (local) {
1187
+ return local;
1188
+ }
1131
1189
  const doc = await this.load();
1132
1190
  const exact = doc.agents.find((a) => a.id === id);
1133
1191
  if (exact) {
1134
- return exact;
1192
+ return this.applyOverride(exact);
1135
1193
  }
1136
- return doc.agents.find((a) => npxPackageBasename(a) === id);
1194
+ const byBasename = doc.agents.find((a) => npxPackageBasename(a) === id);
1195
+ return byBasename ? this.applyOverride(byBasename) : void 0;
1196
+ }
1197
+ // Synthesize RegistryAgent entries from config.agents. These carry an
1198
+ // `exec` distribution and a fixed "local" version key (no install dir).
1199
+ localAgents() {
1200
+ return Object.entries(this.config.agents ?? {}).map(([id, def]) => ({
1201
+ id,
1202
+ name: def.name ?? id,
1203
+ description: def.description,
1204
+ version: "local",
1205
+ distribution: {
1206
+ exec: {
1207
+ // Default the command to the agent id (like extensions default
1208
+ // theirs to the extension name) — resolved off PATH at spawn.
1209
+ command: def.command ?? id,
1210
+ args: def.args,
1211
+ env: def.env
1212
+ }
1213
+ }
1214
+ }));
1215
+ }
1216
+ // Apply a config.agentOverrides[id] pin to a registry agent: swap the
1217
+ // npx package spec and key the install dir on the pinned version so it
1218
+ // never collides with the floating "current" install. No-op when the
1219
+ // agent has no override or isn't npx-distributed.
1220
+ applyOverride(agent) {
1221
+ const override = this.config.agentOverrides?.[agent.id];
1222
+ if (!override?.packageSpec || !agent.distribution.npx) {
1223
+ return agent;
1224
+ }
1225
+ return {
1226
+ ...agent,
1227
+ version: versionKeyFromSpec(override.packageSpec),
1228
+ distribution: {
1229
+ ...agent.distribution,
1230
+ npx: { ...agent.distribution.npx, package: override.packageSpec }
1231
+ }
1232
+ };
1233
+ }
1234
+ isPinned() {
1235
+ return this.config.registry?.pinned === true;
1137
1236
  }
1138
1237
  isFresh(fetchedAt) {
1139
1238
  const ageMs = Date.now() - fetchedAt;
@@ -1175,6 +1274,12 @@ var Registry = class {
1175
1274
  });
1176
1275
  }
1177
1276
  };
1277
+ function versionKeyFromSpec(spec) {
1278
+ const lastAt = spec.lastIndexOf("@");
1279
+ const version = lastAt > 0 ? spec.slice(lastAt + 1) : "";
1280
+ const sanitized = version.replace(/[^a-zA-Z0-9._-]/g, "_");
1281
+ return sanitized.length > 0 ? `pin-${sanitized}` : "pinned";
1282
+ }
1178
1283
  function npxPackageBasename(agent) {
1179
1284
  const pkg = agent.distribution.npx?.package;
1180
1285
  if (!pkg) {
@@ -1185,12 +1290,45 @@ function npxPackageBasename(agent) {
1185
1290
  const atIdx = afterSlash.lastIndexOf("@");
1186
1291
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
1187
1292
  }
1293
+ async function listAgents(registry) {
1294
+ const local = typeof registry.localAgents === "function" ? registry.localAgents() : [];
1295
+ let doc;
1296
+ try {
1297
+ doc = await registry.load();
1298
+ } catch (err) {
1299
+ if (local.length === 0) {
1300
+ throw err;
1301
+ }
1302
+ doc = { version: "local-only", agents: [] };
1303
+ }
1304
+ const localIds = new Set(local.map((a) => a.id));
1305
+ const merged = [...local, ...doc.agents.filter((a) => !localIds.has(a.id))];
1306
+ const agents = await Promise.all(
1307
+ merged.map(async (a) => ({
1308
+ id: a.id,
1309
+ name: a.name,
1310
+ version: a.version,
1311
+ description: a.description,
1312
+ distributions: Object.keys(a.distribution),
1313
+ installed: await agentInstallState(a),
1314
+ source: localIds.has(a.id) ? "local" : "registry"
1315
+ }))
1316
+ );
1317
+ return {
1318
+ version: doc.version,
1319
+ fetchedAt: registry.lastFetchedAt(),
1320
+ agents
1321
+ };
1322
+ }
1188
1323
  async function agentInstallState(agent) {
1189
1324
  const platformKey = currentPlatformKey();
1190
1325
  if (!platformKey) {
1191
1326
  return "no";
1192
1327
  }
1193
1328
  const version = agent.version ?? "current";
1329
+ if (agent.distribution.exec) {
1330
+ return "yes";
1331
+ }
1194
1332
  if (agent.distribution.binary) {
1195
1333
  const target = pickBinaryTarget(agent.distribution.binary, platformKey);
1196
1334
  if (target?.cmd) {
@@ -1287,6 +1425,16 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
1287
1425
  version
1288
1426
  };
1289
1427
  }
1428
+ if (agent.distribution.exec) {
1429
+ const exec = agent.distribution.exec;
1430
+ const tail = callerArgs.length > 0 ? callerArgs : exec.args ?? [];
1431
+ return {
1432
+ command: exec.command,
1433
+ args: tail,
1434
+ env: exec.env ?? {},
1435
+ version
1436
+ };
1437
+ }
1290
1438
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
1291
1439
  }
1292
1440
 
@@ -1344,8 +1492,8 @@ var HistoryPolicy = z3.enum([
1344
1492
  ]);
1345
1493
  var SessionNewParams = z3.object({
1346
1494
  cwd: z3.string(),
1347
- agentId: z3.string().optional(),
1348
- mcpServers: z3.array(z3.unknown()).optional()
1495
+ mcpServers: z3.array(z3.unknown()).optional(),
1496
+ _meta: z3.record(z3.unknown()).optional()
1349
1497
  });
1350
1498
  var SessionResumeHints = z3.object({
1351
1499
  upstreamSessionId: z3.string(),
@@ -1373,23 +1521,10 @@ var SessionAttachParams = z3.object({
1373
1521
  name: z3.string(),
1374
1522
  version: z3.string().optional()
1375
1523
  }).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(),
1524
+ // Hydra-specific attach options (readonly, replayMode, dripSpeed) are
1525
+ // NOT top-level they ride under `_meta["hydra-acp"]` (read via
1526
+ // extractHydraMeta) so session/attach carries only RFD #533's own
1527
+ // fields at the top level.
1393
1528
  _meta: z3.record(z3.unknown()).optional()
1394
1529
  });
1395
1530
  var HYDRA_META_KEY = "hydra-acp";
@@ -1412,8 +1547,23 @@ function extractHydraMeta(meta) {
1412
1547
  if (typeof obj.cwd === "string") {
1413
1548
  out.cwd = obj.cwd;
1414
1549
  }
1415
- if (typeof obj.name === "string") {
1416
- out.name = obj.name;
1550
+ if (typeof obj.clientId === "string") {
1551
+ out.clientId = obj.clientId;
1552
+ }
1553
+ if (typeof obj.readonly === "boolean") {
1554
+ out.readonly = obj.readonly;
1555
+ }
1556
+ if (obj.replayMode === "instant" || obj.replayMode === "drip") {
1557
+ out.replayMode = obj.replayMode;
1558
+ }
1559
+ if (typeof obj.dripSpeed === "number" && obj.dripSpeed > 0) {
1560
+ out.dripSpeed = obj.dripSpeed;
1561
+ }
1562
+ if (obj.detachStatus === "detached") {
1563
+ out.detachStatus = obj.detachStatus;
1564
+ }
1565
+ if (typeof obj.title === "string") {
1566
+ out.title = obj.title;
1417
1567
  }
1418
1568
  if (Array.isArray(obj.agentArgs) && obj.agentArgs.every((a) => typeof a === "string")) {
1419
1569
  out.agentArgs = obj.agentArgs;
@@ -1465,14 +1615,22 @@ function extractHydraMeta(meta) {
1465
1615
  out.availableCommands = cmds;
1466
1616
  }
1467
1617
  }
1468
- if (typeof obj.promptQueueing === "boolean") {
1469
- out.promptQueueing = obj.promptQueueing;
1618
+ if (obj.prompt && typeof obj.prompt === "object" && !Array.isArray(obj.prompt)) {
1619
+ const p = obj.prompt;
1620
+ const caps = {};
1621
+ if (typeof p.queueing === "boolean") caps.queueing = p.queueing;
1622
+ if (typeof p.cancelling === "boolean") caps.cancelling = p.cancelling;
1623
+ if (typeof p.updating === "boolean") caps.updating = p.updating;
1624
+ if (typeof p.amending === "boolean") caps.amending = p.amending;
1625
+ if (typeof p.pipelining === "boolean") caps.pipelining = p.pipelining;
1626
+ out.prompt = caps;
1470
1627
  }
1471
- if (typeof obj.promptCancelling === "boolean") {
1472
- out.promptCancelling = obj.promptCancelling;
1473
- }
1474
- if (typeof obj.promptUpdating === "boolean") {
1475
- out.promptUpdating = obj.promptUpdating;
1628
+ if (obj.agents && typeof obj.agents === "object" && !Array.isArray(obj.agents)) {
1629
+ const a = obj.agents;
1630
+ const caps = {};
1631
+ if (typeof a.list === "boolean") caps.list = a.list;
1632
+ if (typeof a.installProgress === "boolean") caps.installProgress = a.installProgress;
1633
+ out.agents = caps;
1476
1634
  }
1477
1635
  if (typeof obj.mcpStdin === "boolean") {
1478
1636
  out.mcpStdin = obj.mcpStdin;
@@ -1483,12 +1641,6 @@ function extractHydraMeta(meta) {
1483
1641
  if (typeof obj.ancillary === "boolean") {
1484
1642
  out.ancillary = obj.ancillary;
1485
1643
  }
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
1644
  if (Array.isArray(obj.queue)) {
1493
1645
  const entries = [];
1494
1646
  for (const raw of obj.queue) {
@@ -1561,6 +1713,43 @@ function extractHydraMeta(meta) {
1561
1713
  out.availableModels = models;
1562
1714
  }
1563
1715
  }
1716
+ if (obj.status === "live" || obj.status === "cold") {
1717
+ out.status = obj.status;
1718
+ }
1719
+ if (typeof obj.busy === "boolean") {
1720
+ out.busy = obj.busy;
1721
+ }
1722
+ if (typeof obj.awaitingInput === "boolean") {
1723
+ out.awaitingInput = obj.awaitingInput;
1724
+ }
1725
+ if (typeof obj.attachedClients === "number") {
1726
+ out.attachedClients = obj.attachedClients;
1727
+ }
1728
+ if (typeof obj.importedFromMachine === "string") {
1729
+ out.importedFromMachine = obj.importedFromMachine;
1730
+ }
1731
+ if (typeof obj.importedFromUpstreamSessionId === "string") {
1732
+ out.importedFromUpstreamSessionId = obj.importedFromUpstreamSessionId;
1733
+ }
1734
+ if (typeof obj.parentSessionId === "string") {
1735
+ out.parentSessionId = obj.parentSessionId;
1736
+ }
1737
+ if (typeof obj.forkedFromSessionId === "string") {
1738
+ out.forkedFromSessionId = obj.forkedFromSessionId;
1739
+ }
1740
+ if (typeof obj.forkedFromMessageId === "string") {
1741
+ out.forkedFromMessageId = obj.forkedFromMessageId;
1742
+ }
1743
+ if (obj.originatingClient && typeof obj.originatingClient === "object" && !Array.isArray(obj.originatingClient) && typeof obj.originatingClient.name === "string") {
1744
+ const oc = obj.originatingClient;
1745
+ out.originatingClient = {
1746
+ name: oc.name,
1747
+ ...typeof oc.version === "string" ? { version: oc.version } : {}
1748
+ };
1749
+ }
1750
+ if (obj.agentCapabilities !== void 0) {
1751
+ out.agentCapabilities = obj.agentCapabilities;
1752
+ }
1564
1753
  return out;
1565
1754
  }
1566
1755
  function mergeMeta(passthrough, ours) {
@@ -1598,7 +1787,7 @@ var SessionListEntry = z3.object({
1598
1787
  importedFromUpstreamSessionId: z3.string().optional(),
1599
1788
  // Set when this session was spawned as a child by a transformer.
1600
1789
  parentSessionId: z3.string().optional(),
1601
- // Local-fork breadcrumbs set by hydra-acp/fork_session. Distinct from
1790
+ // Local-fork breadcrumbs set by hydra-acp/session/fork. Distinct from
1602
1791
  // the imported* family above: a fork is a local branch off another
1603
1792
  // local session, an import is a cross-machine takeover.
1604
1793
  forkedFromSessionId: z3.string().optional(),
@@ -1636,36 +1825,89 @@ var SessionListResult = z3.object({
1636
1825
  sessions: z3.array(SessionListEntryWire),
1637
1826
  nextCursor: z3.string().optional()
1638
1827
  });
1639
- function sessionListEntryToWire(entry) {
1640
- const hydraMeta = {
1828
+ function buildHydraSessionMeta(entry, extras) {
1829
+ const meta = {
1641
1830
  attachedClients: entry.attachedClients,
1642
1831
  status: entry.status,
1643
1832
  busy: entry.busy,
1644
1833
  awaitingInput: entry.awaitingInput
1645
1834
  };
1835
+ if (entry.cwd !== void 0) {
1836
+ meta.cwd = entry.cwd;
1837
+ }
1838
+ if (entry.title !== void 0) {
1839
+ meta.title = entry.title;
1840
+ }
1646
1841
  if (entry.agentId !== void 0) {
1647
- hydraMeta.agentId = entry.agentId;
1842
+ meta.agentId = entry.agentId;
1648
1843
  }
1649
1844
  if (entry.upstreamSessionId !== void 0) {
1650
- hydraMeta.upstreamSessionId = entry.upstreamSessionId;
1845
+ meta.upstreamSessionId = entry.upstreamSessionId;
1651
1846
  }
1652
1847
  if (entry.currentModel !== void 0) {
1653
- hydraMeta.currentModel = entry.currentModel;
1848
+ meta.currentModel = entry.currentModel;
1654
1849
  }
1655
1850
  if (entry.currentUsage !== void 0) {
1656
- hydraMeta.currentUsage = entry.currentUsage;
1851
+ meta.currentUsage = entry.currentUsage;
1657
1852
  }
1658
1853
  if (entry.importedFromMachine !== void 0) {
1659
- hydraMeta.importedFromMachine = entry.importedFromMachine;
1854
+ meta.importedFromMachine = entry.importedFromMachine;
1660
1855
  }
1661
1856
  if (entry.importedFromUpstreamSessionId !== void 0) {
1662
- hydraMeta.importedFromUpstreamSessionId = entry.importedFromUpstreamSessionId;
1857
+ meta.importedFromUpstreamSessionId = entry.importedFromUpstreamSessionId;
1858
+ }
1859
+ if (entry.parentSessionId !== void 0) {
1860
+ meta.parentSessionId = entry.parentSessionId;
1861
+ }
1862
+ if (entry.forkedFromSessionId !== void 0) {
1863
+ meta.forkedFromSessionId = entry.forkedFromSessionId;
1864
+ }
1865
+ if (entry.forkedFromMessageId !== void 0) {
1866
+ meta.forkedFromMessageId = entry.forkedFromMessageId;
1867
+ }
1868
+ if (entry.originatingClient !== void 0) {
1869
+ meta.originatingClient = entry.originatingClient;
1870
+ }
1871
+ if (entry.interactive !== void 0) {
1872
+ meta.interactive = entry.interactive;
1873
+ }
1874
+ if (extras) {
1875
+ if (extras.clientId !== void 0) {
1876
+ meta.clientId = extras.clientId;
1877
+ }
1878
+ if (extras.currentMode !== void 0) {
1879
+ meta.currentMode = extras.currentMode;
1880
+ }
1881
+ if (extras.agentArgs !== void 0 && extras.agentArgs.length > 0) {
1882
+ meta.agentArgs = extras.agentArgs;
1883
+ }
1884
+ if (extras.availableCommands !== void 0 && extras.availableCommands.length > 0) {
1885
+ meta.availableCommands = extras.availableCommands;
1886
+ }
1887
+ if (extras.availableModes !== void 0 && extras.availableModes.length > 0) {
1888
+ meta.availableModes = extras.availableModes;
1889
+ }
1890
+ if (extras.availableModels !== void 0 && extras.availableModels.length > 0) {
1891
+ meta.availableModels = extras.availableModels;
1892
+ }
1893
+ if (extras.turnStartedAt !== void 0) {
1894
+ meta.turnStartedAt = extras.turnStartedAt;
1895
+ }
1896
+ if (extras.agentCapabilities !== void 0) {
1897
+ meta.agentCapabilities = extras.agentCapabilities;
1898
+ }
1899
+ if (extras.queue !== void 0 && extras.queue.length > 0) {
1900
+ meta.queue = extras.queue;
1901
+ }
1663
1902
  }
1903
+ return meta;
1904
+ }
1905
+ function sessionListEntryToWire(entry) {
1664
1906
  const wire = {
1665
1907
  sessionId: entry.sessionId,
1666
1908
  cwd: entry.cwd,
1667
1909
  updatedAt: entry.updatedAt,
1668
- _meta: mergeMeta(entry._meta, hydraMeta)
1910
+ _meta: mergeMeta(entry._meta, buildHydraSessionMeta(entry))
1669
1911
  };
1670
1912
  if (entry.title !== void 0) {
1671
1913
  wire.title = entry.title;
@@ -1753,65 +1995,6 @@ var PromptAmendedParams = z3.object({
1753
1995
  originator: PromptOriginatorSchema,
1754
1996
  amendedAt: z3.number()
1755
1997
  });
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
1998
  var AgentInstallProgressParams = z3.object({
1816
1999
  agentId: z3.string(),
1817
2000
  version: z3.string(),
@@ -1828,7 +2011,7 @@ var AgentInstallProgressParams = z3.object({
1828
2011
  totalBytes: z3.number().optional(),
1829
2012
  packageSpec: z3.string().optional()
1830
2013
  });
1831
- var AGENT_INSTALL_PROGRESS_METHOD = "hydra-acp/agent_install_progress";
2014
+ var AGENT_INSTALL_PROGRESS_METHOD = "hydra-acp/agents/install_progress";
1832
2015
 
1833
2016
  // src/acp/framing.ts
1834
2017
  function ndjsonStreamFromStdio(stdout, stdin) {
@@ -1931,6 +2114,13 @@ var JsonRpcConnection = class _JsonRpcConnection {
1931
2114
  pending = /* @__PURE__ */ new Map();
1932
2115
  closed = false;
1933
2116
  closeHandlers = [];
2117
+ // Observers for error frames that arrive with no matching pending
2118
+ // request — e.g. an agent replying with an error to a notification
2119
+ // (which carries no id, so it can't be correlated the normal way).
2120
+ // session/cancel is the canonical case: an agent that doesn't support
2121
+ // it (current opencode) emits a MethodNotFound/UnsupportedOperation
2122
+ // error frame we'd otherwise silently drop. See handleResponse.
2123
+ orphanErrorHandlers = [];
1934
2124
  onRequest(method, handler) {
1935
2125
  this.requestHandlers.set(method, handler);
1936
2126
  }
@@ -1966,6 +2156,10 @@ var JsonRpcConnection = class _JsonRpcConnection {
1966
2156
  onClose(handler) {
1967
2157
  this.closeHandlers.push(handler);
1968
2158
  }
2159
+ // Subscribe to error frames that can't be matched to a pending request.
2160
+ onOrphanError(handler) {
2161
+ this.orphanErrorHandlers.push(handler);
2162
+ }
1969
2163
  async request(method, params) {
1970
2164
  return this.requestWithId(method, params).response;
1971
2165
  }
@@ -2022,6 +2216,8 @@ var JsonRpcConnection = class _JsonRpcConnection {
2022
2216
  }
2023
2217
  } else if ("id" in message) {
2024
2218
  this.handleResponse(message);
2219
+ } else if ("error" in message) {
2220
+ this.handleResponse(message);
2025
2221
  }
2026
2222
  }
2027
2223
  async handleRequest(req) {
@@ -2069,6 +2265,18 @@ var JsonRpcConnection = class _JsonRpcConnection {
2069
2265
  handleResponse(res) {
2070
2266
  const pending = this.pending.get(res.id);
2071
2267
  if (!pending) {
2268
+ if (res.error) {
2269
+ for (const handler of this.orphanErrorHandlers) {
2270
+ try {
2271
+ handler({
2272
+ code: res.error.code,
2273
+ message: res.error.message,
2274
+ data: res.error.data
2275
+ });
2276
+ } catch {
2277
+ }
2278
+ }
2279
+ }
2072
2280
  return;
2073
2281
  }
2074
2282
  this.pending.delete(res.id);
@@ -2254,7 +2462,7 @@ function openAgentLog(agentId) {
2254
2462
  }
2255
2463
 
2256
2464
  // src/core/session-manager.ts
2257
- import * as fs13 from "fs/promises";
2465
+ import * as fs14 from "fs/promises";
2258
2466
  import * as os2 from "os";
2259
2467
  import * as path9 from "path";
2260
2468
  import { customAlphabet as customAlphabet3 } from "nanoid";
@@ -2298,8 +2506,8 @@ var SessionStreamBuffer = class {
2298
2506
  fileCapReached = false;
2299
2507
  onFileCapReached;
2300
2508
  logWriteError;
2301
- // Single-flight chain for file appends so concurrent stream_write
2302
- // calls don't interleave their writes.
2509
+ // Single-flight chain for file appends so concurrent stdin writes
2510
+ // don't interleave their file writes.
2303
2511
  fileWriteChain = Promise.resolve();
2304
2512
  constructor(opts = {}) {
2305
2513
  this.maxCapacityBytes = opts.capacityBytes ?? DEFAULT_CAPACITY_BYTES;
@@ -2920,7 +3128,7 @@ function stripHydraSessionPrefix(id) {
2920
3128
  var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
2921
3129
  var TRANSFORMER_CLAIM_TIMEOUT_MS = 5 * 60 * 1e3;
2922
3130
  var RECENTLY_TERMINAL_LIMIT = 64;
2923
- var Session = class {
3131
+ var Session = class _Session {
2924
3132
  sessionId;
2925
3133
  cwd;
2926
3134
  // agent / agentId / upstreamSessionId are mutable so /hydra agent can
@@ -2994,6 +3202,16 @@ var Session = class {
2994
3202
  // endpoint uses this to tail a live session's conversation stream
2995
3203
  // without participating in turns or prompts.
2996
3204
  broadcastHandlers = [];
3205
+ // Epoch ms of the most recent session/cancel we sent to the agent.
3206
+ // Used to attribute an id-less error frame from the agent to a cancel
3207
+ // (see wireAgent's orphan-error observer). Window kept short so an
3208
+ // unrelated later error isn't mislabeled.
3209
+ lastCancelAt = 0;
3210
+ static CANCEL_ERROR_WINDOW_MS = 2e3;
3211
+ // Set by forceCancel() so the in-flight turn's agent-kill rejection is
3212
+ // reported to the originator as a clean "cancelled" stopReason instead of
3213
+ // a raw "connection closed" error.
3214
+ forceCancelling = false;
2997
3215
  // True once we've observed our first session/prompt; gates the
2998
3216
  // first-prompt-seeded title so subsequent prompts don't churn it.
2999
3217
  // Also read by SessionManager's onClose hook to decide whether to
@@ -3069,6 +3287,7 @@ var Session = class {
3069
3287
  agentCommandsHandlers = [];
3070
3288
  agentModesHandlers = [];
3071
3289
  agentModelsHandlers = [];
3290
+ availableAgentsFn;
3072
3291
  modelHandlers = [];
3073
3292
  modeHandlers = [];
3074
3293
  interactiveHandlers = [];
@@ -3141,6 +3360,7 @@ var Session = class {
3141
3360
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
3142
3361
  this.idleEventTimeoutMs = init.idleEventTimeoutMs ?? 3e4;
3143
3362
  this.spawnReplacementAgent = init.spawnReplacementAgent;
3363
+ this.availableAgentsFn = init.availableAgents;
3144
3364
  this.listSessions = init.listSessions;
3145
3365
  this.logger = init.logger;
3146
3366
  this.transformChain = init.transformChain ?? [];
@@ -3218,6 +3438,11 @@ var Session = class {
3218
3438
  agent.connection.onRequest("session/request_permission", async (params) => {
3219
3439
  return this.handlePermissionRequest(params);
3220
3440
  });
3441
+ if (typeof agent.connection.onOrphanError === "function") {
3442
+ agent.connection.onOrphanError((error) => {
3443
+ this.handleOrphanError(error);
3444
+ });
3445
+ }
3221
3446
  agent.onExit(() => {
3222
3447
  if (this.agent !== agent) {
3223
3448
  return;
@@ -3225,6 +3450,31 @@ var Session = class {
3225
3450
  this.markClosed({ deleteRecord: false });
3226
3451
  });
3227
3452
  }
3453
+ // An error frame arrived from the agent that couldn't be matched to a
3454
+ // pending request. The canonical case is a reply to our id-less
3455
+ // session/cancel notification: agents that don't support cancellation
3456
+ // (current opencode) answer with MethodNotFound (-32601) or an
3457
+ // UnsupportedOperation error. If one lands within the cancel window,
3458
+ // surface it to attached clients so the TUI can tell the user the
3459
+ // cancel didn't take rather than silently dropping it.
3460
+ handleOrphanError(error) {
3461
+ const sinceCancel = Date.now() - this.lastCancelAt;
3462
+ if (this.lastCancelAt === 0 || sinceCancel > _Session.CANCEL_ERROR_WINDOW_MS) {
3463
+ this.logger?.warn(
3464
+ `agent ${this.agentId} sent uncorrelated error frame code=${error.code} message=${error.message}`
3465
+ );
3466
+ return;
3467
+ }
3468
+ this.lastCancelAt = 0;
3469
+ this.logger?.warn(
3470
+ `agent ${this.agentId} rejected session/cancel code=${error.code} message=${error.message}`
3471
+ );
3472
+ this.broadcastQueueNotification("hydra-acp/cancel_failed", {
3473
+ sessionId: this.sessionId,
3474
+ code: error.code,
3475
+ message: error.message
3476
+ });
3477
+ }
3228
3478
  // Runs the response-side transformer chain, then the snapshot interceptors,
3229
3479
  // then recordAndBroadcast. All state mutation happens after the chain exits.
3230
3480
  // See forwardRequest for originatedBy / startIdx semantics.
@@ -3242,7 +3492,7 @@ var Session = class {
3242
3492
  const token = `t_${generateChainToken()}`;
3243
3493
  let result;
3244
3494
  try {
3245
- result = await t.connection.request("transformer/message", {
3495
+ result = await t.connection.request("hydra-acp/transformer/message", {
3246
3496
  token,
3247
3497
  phase: "response",
3248
3498
  method: "session/update",
@@ -3268,7 +3518,7 @@ var Session = class {
3268
3518
  const timer = setTimeout(() => {
3269
3519
  if (this.pendingClaims.delete(token)) {
3270
3520
  this.broadcastQueueNotification(
3271
- "hydra-acp/transformer_abandoned_request",
3521
+ "hydra-acp/transformer/abandoned_request",
3272
3522
  { sessionId: this.sessionId, token, transformerName: t.name }
3273
3523
  );
3274
3524
  void this.runResponseChain(
@@ -3315,7 +3565,10 @@ var Session = class {
3315
3565
  return;
3316
3566
  }
3317
3567
  if (this.maybeApplyAgentConfigOption(envelope)) {
3318
- this.recordAndBroadcast("session/update", envelope);
3568
+ this.recordAndBroadcast(
3569
+ "session/update",
3570
+ this.mergeAgentOptionIntoEnvelope(envelope)
3571
+ );
3319
3572
  return;
3320
3573
  }
3321
3574
  if (this.maybeApplyAgentUsage(rawParams)) {
@@ -3650,7 +3903,7 @@ var Session = class {
3650
3903
  // prompt_received a single, useful meaning ("the agent is now taking
3651
3904
  // a turn on this prompt"), which is how attached clients (notably
3652
3905
  // agent-shell) consume it. The accept-time signal that peers can use
3653
- // for queue chip rendering is hydra-acp/prompt_queue_added instead.
3906
+ // for queue chip rendering is hydra-acp/prompt_queue/added instead.
3654
3907
  broadcastPromptReceived(entry) {
3655
3908
  const sentBy = { clientId: entry.originator.clientId };
3656
3909
  if (entry.originator.name) {
@@ -3741,7 +3994,7 @@ var Session = class {
3741
3994
  this.recentlyTerminal.delete(oldest);
3742
3995
  }
3743
3996
  }
3744
- // Fire hydra-acp/prompt_amended for the M1→M2 linkage. The amendment's
3997
+ // Fire hydra-acp/prompt/amended for the M1→M2 linkage. The amendment's
3745
3998
  // current content is read live from the queue entry so any update_prompt
3746
3999
  // calls during the amend window are reflected. Best-effort: if M2 has
3747
4000
  // already been cancelled out of the queue by the time we get here, we
@@ -3761,7 +4014,7 @@ var Session = class {
3761
4014
  amendedAt: Date.now()
3762
4015
  };
3763
4016
  this.broadcastQueueNotification(
3764
- "hydra-acp/prompt_amended",
4017
+ "hydra-acp/prompt/amended",
3765
4018
  params
3766
4019
  );
3767
4020
  }
@@ -3804,17 +4057,17 @@ var Session = class {
3804
4057
  "hydra-acp": { amending: options.amending }
3805
4058
  };
3806
4059
  }
3807
- this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
4060
+ this.broadcastQueueNotification("hydra-acp/prompt_queue/added", params);
3808
4061
  }
3809
4062
  broadcastQueueUpdated(messageId, prompt) {
3810
- this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
4063
+ this.broadcastQueueNotification("hydra-acp/prompt_queue/updated", {
3811
4064
  sessionId: this.sessionId,
3812
4065
  messageId,
3813
4066
  prompt
3814
4067
  });
3815
4068
  }
3816
4069
  broadcastQueueRemoved(messageId, reason) {
3817
- this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
4070
+ this.broadcastQueueNotification("hydra-acp/prompt_queue/removed", {
3818
4071
  sessionId: this.sessionId,
3819
4072
  messageId,
3820
4073
  reason
@@ -4095,6 +4348,7 @@ var Session = class {
4095
4348
  JsonRpcErrorCodes.SessionNotFound
4096
4349
  );
4097
4350
  }
4351
+ this.lastCancelAt = Date.now();
4098
4352
  await this.agent.connection.notify("session/cancel", {
4099
4353
  sessionId: this.upstreamSessionId
4100
4354
  });
@@ -4110,7 +4364,7 @@ var Session = class {
4110
4364
  this.transformChain.push(ref);
4111
4365
  }
4112
4366
  if (ref.intercepts.has("lifecycle:session.opened")) {
4113
- void ref.connection.notify("transformer/session_event", {
4367
+ void ref.connection.notify("hydra-acp/transformer/session_event", {
4114
4368
  event: "session.opened",
4115
4369
  sessionId: this.sessionId
4116
4370
  }).catch(() => void 0);
@@ -4134,7 +4388,7 @@ var Session = class {
4134
4388
  const token = `t_${generateChainToken()}`;
4135
4389
  let result;
4136
4390
  try {
4137
- result = await t.connection.request("transformer/message", {
4391
+ result = await t.connection.request("hydra-acp/transformer/message", {
4138
4392
  token,
4139
4393
  phase: "request",
4140
4394
  method,
@@ -4160,7 +4414,7 @@ var Session = class {
4160
4414
  const timer = setTimeout(() => {
4161
4415
  if (this.pendingClaims.delete(token)) {
4162
4416
  this.broadcastQueueNotification(
4163
- "hydra-acp/transformer_abandoned_request",
4417
+ "hydra-acp/transformer/abandoned_request",
4164
4418
  { sessionId: this.sessionId, token, transformerName: t.name }
4165
4419
  );
4166
4420
  void this.forwardRequest(
@@ -4202,7 +4456,7 @@ var Session = class {
4202
4456
  claim.resolve(result);
4203
4457
  return true;
4204
4458
  }
4205
- // Called by the WS handler on hydra-acp/keep_alive.
4459
+ // Called by the WS handler on hydra-acp/connection/keep_alive.
4206
4460
  // Resets the abandonment timer for an outstanding processing claim.
4207
4461
  keepAliveClaim(token, estimatedRemainingMs) {
4208
4462
  const claim = this.pendingClaims.get(token);
@@ -4214,7 +4468,7 @@ var Session = class {
4214
4468
  const timer = setTimeout(() => {
4215
4469
  if (this.pendingClaims.delete(token)) {
4216
4470
  this.broadcastQueueNotification(
4217
- "hydra-acp/transformer_abandoned_request",
4471
+ "hydra-acp/transformer/abandoned_request",
4218
4472
  { sessionId: this.sessionId, token, transformerName: claim.transformerName }
4219
4473
  );
4220
4474
  if (claim.side === "response") {
@@ -4379,18 +4633,20 @@ var Session = class {
4379
4633
  } catch {
4380
4634
  }
4381
4635
  }
4636
+ this.broadcastConfigOptions();
4382
4637
  return true;
4383
4638
  }
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).
4639
+ // Apply an agent-emitted config_option_update. claude-acp and opencode
4640
+ // emit this (not the spec-shaped current_model_update /
4641
+ // available_modes_update) to carry the current value AND option list for
4642
+ // model and mode. The payload is `configOptions: [{ id, currentValue,
4643
+ // options: [{ value, name }] }, ...]`. We harvest the "model" and "mode"
4644
+ // entries — other ids (e.g. "effort") are agent-internal and ignored.
4645
+ // Harvesting the mode list here is what populates availableModes for
4646
+ // agents that never send available_modes_update (so the TUI's Shift+Tab
4647
+ // cycle has something to cycle through). Returns true when recognized so
4648
+ // the wireAgent loop stops trying further extractors (the original frame
4649
+ // still broadcasts; config-options-aware clients render it directly).
4394
4650
  maybeApplyAgentConfigOption(params) {
4395
4651
  const obj = params ?? {};
4396
4652
  const update = obj.update ?? {};
@@ -4406,24 +4662,37 @@ var Session = class {
4406
4662
  continue;
4407
4663
  }
4408
4664
  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);
4665
+ if (opt.id === "model") {
4666
+ const models = parseModelsList(opt.options);
4667
+ if (models.length > 0) {
4668
+ this.setAgentAdvertisedModels(models);
4669
+ }
4670
+ const cv = opt.currentValue;
4671
+ if (typeof cv === "string") {
4672
+ const trimmed = cv.trim();
4673
+ if (trimmed && trimmed !== this.currentModel) {
4674
+ this.logger?.info(
4675
+ `live config_option_update(model): sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
4676
+ );
4677
+ this.applyModelChange(trimmed);
4678
+ }
4679
+ }
4680
+ } else if (opt.id === "mode") {
4681
+ const modes = parseModesList(opt.options);
4682
+ if (modes.length > 0) {
4683
+ this.setAgentAdvertisedModes(modes);
4684
+ }
4685
+ const cv = opt.currentValue;
4686
+ if (typeof cv === "string") {
4687
+ const trimmed = cv.trim();
4688
+ if (trimmed && trimmed !== this.currentMode) {
4689
+ this.logger?.info(
4690
+ `live config_option_update(mode): sessionId=${this.sessionId} ${JSON.stringify(this.currentMode)} \u2192 ${JSON.stringify(trimmed)}`
4691
+ );
4692
+ this.applyModeChange(trimmed);
4693
+ }
4424
4694
  }
4425
4695
  }
4426
- break;
4427
4696
  }
4428
4697
  return true;
4429
4698
  }
@@ -4451,6 +4720,7 @@ var Session = class {
4451
4720
  } catch {
4452
4721
  }
4453
4722
  }
4723
+ this.broadcastConfigOptions();
4454
4724
  return true;
4455
4725
  }
4456
4726
  // usage_update carries any subset of {used, size, cost.amount,
@@ -4661,6 +4931,7 @@ var Session = class {
4661
4931
  sessionId: this.upstreamSessionId,
4662
4932
  update
4663
4933
  });
4934
+ this.broadcastConfigOptions();
4664
4935
  }
4665
4936
  // Apply a mode change initiated by a client request (session/set_mode)
4666
4937
  // when the agent doesn't emit a current_mode_update notification on its
@@ -4697,6 +4968,115 @@ var Session = class {
4697
4968
  sessionId: this.upstreamSessionId,
4698
4969
  update
4699
4970
  });
4971
+ this.broadcastConfigOptions();
4972
+ }
4973
+ // Assemble the spec-shaped configOptions snapshot for this session.
4974
+ // Order reflects the agent's preferred prominence: model and mode (the
4975
+ // dimensions users toggle constantly) first, then the hydra-native
4976
+ // `agent` selector. model/mode are included only when hydra actually
4977
+ // knows that dimension for the connected agent; `agent` is always
4978
+ // present since hydra owns the swap concept regardless of the backend.
4979
+ buildConfigOptions() {
4980
+ const out = [];
4981
+ if (this.agentAdvertisedModels.length > 0) {
4982
+ const options = this.agentAdvertisedModels.map(
4983
+ (m) => ({
4984
+ value: m.modelId,
4985
+ name: m.name ?? m.modelId,
4986
+ ...m.description !== void 0 ? { description: m.description } : {}
4987
+ })
4988
+ );
4989
+ const currentValue = this.currentModel && options.some((o) => o.value === this.currentModel) ? this.currentModel : options[0].value;
4990
+ out.push({
4991
+ id: "model",
4992
+ name: "Model",
4993
+ category: "model",
4994
+ type: "select",
4995
+ currentValue,
4996
+ options
4997
+ });
4998
+ }
4999
+ if (this.agentAdvertisedModes.length > 0) {
5000
+ const options = this.agentAdvertisedModes.map(
5001
+ (m) => ({
5002
+ value: m.id,
5003
+ name: m.name ?? m.id,
5004
+ ...m.description !== void 0 ? { description: m.description } : {}
5005
+ })
5006
+ );
5007
+ const currentValue = this.currentMode && options.some((o) => o.value === this.currentMode) ? this.currentMode : options[0].value;
5008
+ out.push({
5009
+ id: "mode",
5010
+ name: "Session Mode",
5011
+ category: "mode",
5012
+ type: "select",
5013
+ currentValue,
5014
+ options
5015
+ });
5016
+ }
5017
+ const agents = this.availableAgentsFn?.() ?? [];
5018
+ const agentOptions = agents.map((a) => ({
5019
+ value: a.id,
5020
+ name: a.name ?? a.id,
5021
+ ...a.description !== void 0 ? { description: a.description } : {}
5022
+ }));
5023
+ if (!agentOptions.some((o) => o.value === this.agentId)) {
5024
+ agentOptions.unshift({ value: this.agentId, name: this.agentId });
5025
+ }
5026
+ out.push({
5027
+ id: "agent",
5028
+ name: "Agent",
5029
+ category: "_hydra_agent",
5030
+ type: "select",
5031
+ currentValue: this.agentId,
5032
+ options: agentOptions
5033
+ });
5034
+ return out;
5035
+ }
5036
+ // Broadcast a config_option_update carrying the full snapshot. Fired
5037
+ // alongside the legacy current_mode_update / current_model_update and on
5038
+ // agent swaps so config-options-aware clients stay in sync via the
5039
+ // spec mechanism. config_option_update is a STATE_UPDATE_KIND, so this
5040
+ // broadcasts live but is not recorded to history.
5041
+ broadcastConfigOptions() {
5042
+ this.recordAndBroadcast("session/update", {
5043
+ sessionId: this.upstreamSessionId,
5044
+ update: {
5045
+ sessionUpdate: "config_option_update",
5046
+ configOptions: this.buildConfigOptions()
5047
+ }
5048
+ });
5049
+ }
5050
+ // Return a shallow clone of an agent-emitted config_option_update
5051
+ // envelope with the hydra-native `agent` option appended to its
5052
+ // configOptions (unless the agent already advertised one). Preserves
5053
+ // the agent's own options (mode/model/effort/…) and their order; the
5054
+ // `agent` selector rides last, matching its lower prominence. The
5055
+ // original envelope is not mutated.
5056
+ mergeAgentOptionIntoEnvelope(envelope) {
5057
+ if (!envelope || typeof envelope !== "object") {
5058
+ return envelope;
5059
+ }
5060
+ const env = envelope;
5061
+ if (!env.update || typeof env.update !== "object") {
5062
+ return envelope;
5063
+ }
5064
+ const update = env.update;
5065
+ const list = Array.isArray(update.configOptions) ? [...update.configOptions] : [];
5066
+ const hasAgent = list.some(
5067
+ (o) => o && typeof o === "object" && o.id === "agent"
5068
+ );
5069
+ if (hasAgent) {
5070
+ return envelope;
5071
+ }
5072
+ const agentOption = this.buildConfigOptions().find((o) => o.id === "agent");
5073
+ if (!agentOption) {
5074
+ return envelope;
5075
+ }
5076
+ return {
5077
+ ...env,
5078
+ update: { ...update, configOptions: [...list, agentOption] }
5079
+ };
4700
5080
  }
4701
5081
  onUsageChange(handler) {
4702
5082
  this.usageHandlers.push(handler);
@@ -4832,7 +5212,7 @@ var Session = class {
4832
5212
  // "/hydra <name> <verb> [args]" — name matches a registered extension
4833
5213
  // or transformer. We split the remainder into verb + args, validate the
4834
5214
  // verb against what the process advertised, and forward as a
4835
- // hydra-acp/extension_command request on the process's WS connection.
5215
+ // hydra-acp/commands/invoke request on the process's WS connection.
4836
5216
  // The reply's text (if any) is broadcast as a synthetic
4837
5217
  // agent_message_chunk so it appears in the conversation alongside the
4838
5218
  // user's invocation.
@@ -4861,7 +5241,7 @@ var Session = class {
4861
5241
  }
4862
5242
  let reply;
4863
5243
  try {
4864
- reply = await entry.connection.request("hydra-acp/extension_command", {
5244
+ reply = await entry.connection.request("hydra-acp/commands/invoke", {
4865
5245
  sessionId: this.sessionId,
4866
5246
  verb,
4867
5247
  args
@@ -5074,6 +5454,14 @@ ${body}
5074
5454
  // record. Spawns the new agent first so a failure leaves the old one
5075
5455
  // intact; then injects a synthesized transcript so the new agent has
5076
5456
  // context for the next turn.
5457
+ // Public entry for swapping the underlying agent from a client request
5458
+ // (session/set_config_option with configId "agent"), the protocol twin
5459
+ // of the `/hydra agent` text command. Delegates to the same swap
5460
+ // machinery so both paths share validation, transcript replay, and the
5461
+ // config_option_update broadcast.
5462
+ setAgent(newAgentId) {
5463
+ return this.runAgentCommand(newAgentId);
5464
+ }
5077
5465
  runAgentCommand(newAgentId) {
5078
5466
  if (!newAgentId) {
5079
5467
  throw withCode(
@@ -5112,12 +5500,10 @@ ${body}
5112
5500
  this.agentCapabilities = fresh.agentCapabilities;
5113
5501
  this.agentAdvertisedCommands = [];
5114
5502
  this.broadcastMergedCommands();
5115
- if (this.agentAdvertisedModels.length > 0) {
5116
- this.setAgentAdvertisedModels([]);
5117
- }
5118
- if (this.agentAdvertisedModes.length > 0) {
5119
- this.setAgentAdvertisedModes([]);
5120
- }
5503
+ this.currentModel = fresh.initialModel;
5504
+ this.currentMode = fresh.initialMode;
5505
+ this.setAgentAdvertisedModels(fresh.initialModels ?? []);
5506
+ this.setAgentAdvertisedModes(fresh.initialModes ?? []);
5121
5507
  await oldAgent.kill().catch(() => void 0);
5122
5508
  if (transcript) {
5123
5509
  await this.runInternalPrompt(transcript).catch(() => void 0);
@@ -5141,7 +5527,7 @@ ${body}
5141
5527
  // down any in-flight request as a side effect. The record is kept
5142
5528
  // (deleteRecord:false) so the session goes cold and can be resurrected.
5143
5529
  // Returns end_turn so the prompt() caller's response resolves normally,
5144
- // but every attached client has already received hydra-acp/session_closed
5530
+ // but every attached client has already received hydra-acp/session/closed
5145
5531
  // by the time this returns.
5146
5532
  async runKillCommand() {
5147
5533
  await this.close({ deleteRecord: false });
@@ -5159,45 +5545,71 @@ ${body}
5159
5545
  JsonRpcErrorCodes.InternalError
5160
5546
  );
5161
5547
  }
5162
- const spawnAgent = this.spawnReplacementAgent;
5163
- const agentId = this.agentId;
5164
5548
  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
- }
5549
+ await this.respawnAgent();
5198
5550
  return { stopReason: "end_turn" };
5199
5551
  });
5200
5552
  }
5553
+ // Last-resort cancellation. When an agent ignores session/cancel (current
5554
+ // opencode returns UnsupportedOperation and keeps generating), the only
5555
+ // way to actually stop the turn is to tear the subprocess down. Rather
5556
+ // than respawn + replay the transcript (slow, and renders as an agent
5557
+ // "switch"), we close the session keeping its record: agent.kill() aborts
5558
+ // the in-flight turn, and the next prompt auto-resurrects via session/load
5559
+ // (cheap — the agent restores its own context). The forceCancelling flag
5560
+ // makes runQueueEntry render the aborted turn as "cancelled" rather than a
5561
+ // raw connection error. Runs OUTSIDE the prompt queue so a wedged turn
5562
+ // can't block it.
5563
+ async forceCancel() {
5564
+ if (this.closed || this.closing) {
5565
+ throw withCode(
5566
+ new Error("session is closing"),
5567
+ JsonRpcErrorCodes.SessionClosing
5568
+ );
5569
+ }
5570
+ this.lastCancelAt = 0;
5571
+ this.forceCancelling = true;
5572
+ await this.close({ deleteRecord: false });
5573
+ return { stopReason: "cancelled" };
5574
+ }
5575
+ // Shared kill-and-respawn used by /hydra restart (queued) and forceCancel
5576
+ // (immediate). Spawns a fresh agent, swaps it in, kills the old one, and
5577
+ // re-seeds the conversation transcript so the new process has context.
5578
+ async respawnAgent() {
5579
+ const spawnAgent = this.spawnReplacementAgent;
5580
+ const agentId = this.agentId;
5581
+ const transcript = await this.buildSwitchTranscript(agentId);
5582
+ const fresh = await spawnAgent({
5583
+ agentId,
5584
+ cwd: this.cwd,
5585
+ agentArgs: this.agentArgs
5586
+ });
5587
+ this.accumulateAndResetCost();
5588
+ this.wireAgent(fresh.agent);
5589
+ const oldAgent = this.agent;
5590
+ this.agent = fresh.agent;
5591
+ this.upstreamSessionId = fresh.upstreamSessionId;
5592
+ this.agentMeta = fresh.agentMeta;
5593
+ this.agentCapabilities = fresh.agentCapabilities;
5594
+ this.agentAdvertisedCommands = [];
5595
+ this.broadcastMergedCommands();
5596
+ this.currentModel = fresh.initialModel;
5597
+ this.currentMode = fresh.initialMode;
5598
+ this.setAgentAdvertisedModels(fresh.initialModels ?? []);
5599
+ this.setAgentAdvertisedModes(fresh.initialModes ?? []);
5600
+ await oldAgent.kill().catch(() => void 0);
5601
+ if (transcript) {
5602
+ await this.runInternalPrompt(transcript).catch(() => void 0);
5603
+ }
5604
+ this.broadcastAgentSwitch(agentId, agentId);
5605
+ const info = { agentId, upstreamSessionId: this.upstreamSessionId };
5606
+ for (const handler of this.agentChangeHandlers) {
5607
+ try {
5608
+ handler(info);
5609
+ } catch {
5610
+ }
5611
+ }
5612
+ }
5201
5613
  // Walk the persisted history and produce a labeled transcript suitable
5202
5614
  // for handing to a fresh agent. Includes user prompts, agent replies,
5203
5615
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
@@ -5298,15 +5710,13 @@ ${body}
5298
5710
  return void 0;
5299
5711
  });
5300
5712
  }
5301
- // Tell every attached client (a) the agent identity has changed
5302
- // (session_info_update carrying agentId inside _meta["hydra-acp"] —
5713
+ // Tell every attached client the agent identity has changed via
5714
+ // session_info_update carrying agentId inside _meta["hydra-acp"] —
5303
5715
  // the ACP schema for session_info_update is just title/updatedAt/_meta,
5304
5716
  // 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) {
5717
+ // ones read it and relabel. synthetic=true so a future /hydra agent's
5718
+ // transcript builder filters it out.
5719
+ broadcastAgentSwitch(_oldAgentId, newAgentId) {
5310
5720
  this.recordAndBroadcast("session/update", {
5311
5721
  sessionId: this.sessionId,
5312
5722
  update: {
@@ -5314,19 +5724,7 @@ ${body}
5314
5724
  _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
5315
5725
  }
5316
5726
  });
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
- });
5727
+ this.broadcastConfigOptions();
5330
5728
  }
5331
5729
  // stdin-stream lifecycle. Cat --stream calls openStream() once after
5332
5730
  // session/new, then forwards stdin chunks via streamWrite(). The agent
@@ -5457,7 +5855,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5457
5855
  requireStreamBuffer() {
5458
5856
  if (this.streamBuffer === void 0) {
5459
5857
  const err = new Error(
5460
- `session ${this.sessionId} has no stream buffer; call hydra-acp/stream_open first`
5858
+ `session ${this.sessionId} has no stream buffer; POST /v1/sessions/:id/stdin/open first`
5461
5859
  );
5462
5860
  err.code = JsonRpcErrorCodes.StreamNotEnabled;
5463
5861
  throw err;
@@ -5500,7 +5898,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5500
5898
  const sessionId = this.sessionId;
5501
5899
  this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
5502
5900
  for (const client of this.clients.values()) {
5503
- void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
5901
+ void client.connection.notify("hydra-acp/session/closed", { sessionId: this.sessionId }).catch(() => void 0);
5504
5902
  }
5505
5903
  this.clients.clear();
5506
5904
  if (this.streamBuffer !== void 0) {
@@ -5607,7 +6005,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5607
6005
  if (!t.intercepts.has(intercept)) {
5608
6006
  continue;
5609
6007
  }
5610
- void t.connection.notify("transformer/session_event", {
6008
+ void t.connection.notify("hydra-acp/transformer/session_event", {
5611
6009
  event,
5612
6010
  sessionId: this.sessionId,
5613
6011
  payload
@@ -5887,6 +6285,10 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5887
6285
  }
5888
6286
  );
5889
6287
  } catch (err) {
6288
+ if (this.forceCancelling) {
6289
+ this.clearAmendIfMatches(entry.messageId);
6290
+ return { stopReason: "cancelled" };
6291
+ }
5890
6292
  if (!this.closed) {
5891
6293
  this.broadcastTurnComplete(
5892
6294
  entry.clientId,
@@ -6002,6 +6404,31 @@ function parseModelsList(list) {
6002
6404
  }
6003
6405
  return out;
6004
6406
  }
6407
+ function parseModesList(list) {
6408
+ if (!Array.isArray(list)) {
6409
+ return [];
6410
+ }
6411
+ const out = [];
6412
+ for (const raw of list) {
6413
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
6414
+ continue;
6415
+ }
6416
+ const r = raw;
6417
+ const id = typeof r.value === "string" && r.value.trim() || typeof r.id === "string" && r.id.trim() || void 0;
6418
+ if (!id) {
6419
+ continue;
6420
+ }
6421
+ const mode = { id };
6422
+ if (typeof r.name === "string" && r.name.length > 0) {
6423
+ mode.name = r.name;
6424
+ }
6425
+ if (typeof r.description === "string" && r.description.length > 0) {
6426
+ mode.description = r.description;
6427
+ }
6428
+ out.push(mode);
6429
+ }
6430
+ return out;
6431
+ }
6005
6432
  function extractAdvertisedModes(params) {
6006
6433
  const obj = params ?? {};
6007
6434
  const update = obj.update ?? {};
@@ -6378,9 +6805,9 @@ var SessionRecord = z5.object({
6378
6805
  // memory. Cleared after that first resurrect completes.
6379
6806
  pendingHistorySync: z5.boolean().optional(),
6380
6807
  // 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.
6808
+ // hydra-acp/child_session/spawn. Points to the spawning session's id.
6382
6809
  parentSessionId: z5.string().optional(),
6383
- // Set when this session was created by hydra-acp/fork_session.
6810
+ // Set when this session was created by hydra-acp/session/fork.
6384
6811
  // forkedFromSessionId points to the local source session; forkedFromMessageId
6385
6812
  // is the resolved forkAt — the messageId of the turn_complete the slice
6386
6813
  // ended at. Kept so future UI can show "branched from turn N of session X".
@@ -6521,20 +6948,167 @@ function recordFromMemorySession(args) {
6521
6948
  };
6522
6949
  }
6523
6950
 
6951
+ // src/core/tombstone-store.ts
6952
+ import * as fs9 from "fs/promises";
6953
+ import { z as z6 } from "zod";
6954
+ var Tombstone = z6.object({
6955
+ version: z6.literal(1),
6956
+ agentId: z6.string(),
6957
+ upstreamSessionId: z6.string(),
6958
+ deletedAt: z6.string(),
6959
+ // Agent's last-reported updatedAt for this session at the moment we
6960
+ // deleted, snapshotted from SessionRecord.updatedAt. Compared against
6961
+ // the listing's updatedAt on subsequent syncs to detect that the
6962
+ // conversation has moved on (the agent / user revived it), in which
6963
+ // case the tombstone is dropped and the session re-imports. Absent
6964
+ // when the deleted record never carried a meaningful updatedAt.
6965
+ upstreamUpdatedAt: z6.string().optional(),
6966
+ cwd: z6.string().optional(),
6967
+ title: z6.string().optional(),
6968
+ reason: z6.enum(["user", "expired"]).optional()
6969
+ });
6970
+ var TombstoneStore = class {
6971
+ async add(t) {
6972
+ const full = { version: 1, ...t };
6973
+ await writeJsonAtomic(
6974
+ paths.tombstoneFile(t.agentId, t.upstreamSessionId),
6975
+ full,
6976
+ { mode: 384 }
6977
+ );
6978
+ }
6979
+ async has(agentId, upstreamSessionId) {
6980
+ try {
6981
+ await fs9.access(paths.tombstoneFile(agentId, upstreamSessionId));
6982
+ return true;
6983
+ } catch {
6984
+ return false;
6985
+ }
6986
+ }
6987
+ // Returns the tombstone payload if the file exists. An unreadable or
6988
+ // unparseable file still counts as a tombstone — we synthesize a
6989
+ // bare record so the caller's "is this dead?" check stays correct,
6990
+ // but with no upstreamUpdatedAt the resurrection rule treats any
6991
+ // listed updatedAt as advancement (see SessionManager.syncFromAgent).
6992
+ async read(agentId, upstreamSessionId) {
6993
+ const file = paths.tombstoneFile(agentId, upstreamSessionId);
6994
+ const parsed = await readJsonSafe(file);
6995
+ if (parsed === void 0) {
6996
+ if (await this.has(agentId, upstreamSessionId)) {
6997
+ return {
6998
+ version: 1,
6999
+ agentId,
7000
+ upstreamSessionId,
7001
+ deletedAt: (/* @__PURE__ */ new Date(0)).toISOString()
7002
+ };
7003
+ }
7004
+ return void 0;
7005
+ }
7006
+ try {
7007
+ return Tombstone.parse(parsed);
7008
+ } catch {
7009
+ return {
7010
+ version: 1,
7011
+ agentId,
7012
+ upstreamSessionId,
7013
+ deletedAt: (/* @__PURE__ */ new Date(0)).toISOString()
7014
+ };
7015
+ }
7016
+ }
7017
+ async remove(agentId, upstreamSessionId) {
7018
+ try {
7019
+ await fs9.unlink(paths.tombstoneFile(agentId, upstreamSessionId));
7020
+ } catch (err) {
7021
+ const e = err;
7022
+ if (e.code !== "ENOENT") {
7023
+ throw err;
7024
+ }
7025
+ }
7026
+ try {
7027
+ await fs9.rmdir(paths.tombstoneAgentDir(agentId));
7028
+ } catch (err) {
7029
+ const e = err;
7030
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
7031
+ throw err;
7032
+ }
7033
+ }
7034
+ }
7035
+ async list(agentId) {
7036
+ if (agentId !== void 0) {
7037
+ return this.listForAgent(agentId);
7038
+ }
7039
+ let agents;
7040
+ try {
7041
+ agents = await fs9.readdir(paths.tombstonesDir());
7042
+ } catch (err) {
7043
+ const e = err;
7044
+ if (e.code === "ENOENT") {
7045
+ return [];
7046
+ }
7047
+ throw err;
7048
+ }
7049
+ const out = [];
7050
+ for (const enc of agents) {
7051
+ let decoded;
7052
+ try {
7053
+ decoded = decodeURIComponent(enc);
7054
+ } catch {
7055
+ continue;
7056
+ }
7057
+ out.push(...await this.listForAgent(decoded));
7058
+ }
7059
+ return out;
7060
+ }
7061
+ async listForAgent(agentId) {
7062
+ let files;
7063
+ try {
7064
+ files = await fs9.readdir(paths.tombstoneAgentDir(agentId));
7065
+ } catch (err) {
7066
+ const e = err;
7067
+ if (e.code === "ENOENT") {
7068
+ return [];
7069
+ }
7070
+ throw err;
7071
+ }
7072
+ const out = [];
7073
+ for (const f of files) {
7074
+ let upstreamId;
7075
+ try {
7076
+ upstreamId = decodeURIComponent(f);
7077
+ } catch {
7078
+ continue;
7079
+ }
7080
+ const t = await this.read(agentId, upstreamId);
7081
+ if (t) {
7082
+ out.push(t);
7083
+ }
7084
+ }
7085
+ return out;
7086
+ }
7087
+ };
7088
+ function shouldResurrectFromUpstream(tombstone, listingUpdatedAt) {
7089
+ if (listingUpdatedAt === void 0) {
7090
+ return false;
7091
+ }
7092
+ if (tombstone.upstreamUpdatedAt === void 0) {
7093
+ return true;
7094
+ }
7095
+ return listingUpdatedAt > tombstone.upstreamUpdatedAt;
7096
+ }
7097
+
6524
7098
  // src/core/synopsis-coordinator.ts
6525
- import * as fs10 from "fs/promises";
7099
+ import * as fs11 from "fs/promises";
6526
7100
 
6527
7101
  // src/core/hydra-version.ts
6528
7102
  import { fileURLToPath } from "url";
6529
7103
  import * as path7 from "path";
6530
- import * as fs9 from "fs";
7104
+ import * as fs10 from "fs";
6531
7105
  function resolveVersion() {
6532
7106
  try {
6533
7107
  let dir = path7.dirname(fileURLToPath(import.meta.url));
6534
7108
  for (let i = 0; i < 8; i += 1) {
6535
7109
  const candidate = path7.join(dir, "package.json");
6536
- if (fs9.existsSync(candidate)) {
6537
- const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
7110
+ if (fs10.existsSync(candidate)) {
7111
+ const pkg = JSON.parse(fs10.readFileSync(candidate, "utf8"));
6538
7112
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
6539
7113
  return pkg.version;
6540
7114
  }
@@ -7058,7 +7632,7 @@ var SynopsisCoordinator = class {
7058
7632
  });
7059
7633
  const modelId = this.opts.synopsisModel;
7060
7634
  const synopsisCwd = paths.sessionDir(sessionId);
7061
- await fs10.mkdir(synopsisCwd, { recursive: true }).catch(() => void 0);
7635
+ await fs11.mkdir(synopsisCwd, { recursive: true }).catch(() => void 0);
7062
7636
  this.opts.logger?.info(
7063
7637
  `synopsis: start sessionId=${sessionId} agentId=${synopsisAgentId} historyLen=${history.length} model=${JSON.stringify(modelId ?? "(default)")} cwd=${synopsisCwd}`
7064
7638
  );
@@ -7161,7 +7735,7 @@ function describeFields(s) {
7161
7735
  }
7162
7736
 
7163
7737
  // src/core/history-store.ts
7164
- import * as fs11 from "fs/promises";
7738
+ import * as fs12 from "fs/promises";
7165
7739
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
7166
7740
  var DEFAULT_MAX_ENTRIES = 1e3;
7167
7741
  var HistoryStore = class {
@@ -7178,9 +7752,9 @@ var HistoryStore = class {
7178
7752
  return;
7179
7753
  }
7180
7754
  return this.enqueue(sessionId, async () => {
7181
- await fs11.mkdir(paths.sessionDir(sessionId), { recursive: true });
7755
+ await fs12.mkdir(paths.sessionDir(sessionId), { recursive: true });
7182
7756
  const line = JSON.stringify(entry) + "\n";
7183
- await fs11.appendFile(paths.historyFile(sessionId), line, {
7757
+ await fs12.appendFile(paths.historyFile(sessionId), line, {
7184
7758
  encoding: "utf8",
7185
7759
  mode: 384
7186
7760
  });
@@ -7191,9 +7765,9 @@ var HistoryStore = class {
7191
7765
  return;
7192
7766
  }
7193
7767
  return this.enqueue(sessionId, async () => {
7194
- await fs11.mkdir(paths.sessionDir(sessionId), { recursive: true });
7768
+ await fs12.mkdir(paths.sessionDir(sessionId), { recursive: true });
7195
7769
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
7196
- await fs11.writeFile(paths.historyFile(sessionId), body, {
7770
+ await fs12.writeFile(paths.historyFile(sessionId), body, {
7197
7771
  encoding: "utf8",
7198
7772
  mode: 384
7199
7773
  });
@@ -7210,7 +7784,7 @@ var HistoryStore = class {
7210
7784
  return this.enqueue(sessionId, async () => {
7211
7785
  let raw;
7212
7786
  try {
7213
- raw = await fs11.readFile(paths.historyFile(sessionId), "utf8");
7787
+ raw = await fs12.readFile(paths.historyFile(sessionId), "utf8");
7214
7788
  } catch (err) {
7215
7789
  const e = err;
7216
7790
  if (e.code === "ENOENT") {
@@ -7223,7 +7797,7 @@ var HistoryStore = class {
7223
7797
  return;
7224
7798
  }
7225
7799
  const trimmed = lines.slice(-maxEntries);
7226
- await fs11.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
7800
+ await fs12.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
7227
7801
  encoding: "utf8",
7228
7802
  mode: 384
7229
7803
  });
@@ -7239,7 +7813,7 @@ var HistoryStore = class {
7239
7813
  }
7240
7814
  let raw;
7241
7815
  try {
7242
- raw = await fs11.readFile(paths.historyFile(sessionId), "utf8");
7816
+ raw = await fs12.readFile(paths.historyFile(sessionId), "utf8");
7243
7817
  } catch (err) {
7244
7818
  const e = err;
7245
7819
  if (e.code === "ENOENT") {
@@ -7298,7 +7872,7 @@ var HistoryStore = class {
7298
7872
  }
7299
7873
  return this.enqueue(sessionId, async () => {
7300
7874
  try {
7301
- await fs11.unlink(paths.historyFile(sessionId));
7875
+ await fs12.unlink(paths.historyFile(sessionId));
7302
7876
  } catch (err) {
7303
7877
  const e = err;
7304
7878
  if (e.code !== "ENOENT") {
@@ -7306,7 +7880,7 @@ var HistoryStore = class {
7306
7880
  }
7307
7881
  }
7308
7882
  try {
7309
- await fs11.rmdir(paths.sessionDir(sessionId));
7883
+ await fs12.rmdir(paths.sessionDir(sessionId));
7310
7884
  } catch (err) {
7311
7885
  const e = err;
7312
7886
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -7330,74 +7904,74 @@ var HistoryStore = class {
7330
7904
  };
7331
7905
 
7332
7906
  // src/tui/history.ts
7333
- import { promises as fs12 } from "fs";
7907
+ import { promises as fs13 } from "fs";
7334
7908
  import * as path8 from "path";
7335
7909
  async function saveHistory(file, history) {
7336
- await fs12.mkdir(path8.dirname(file), { recursive: true });
7910
+ await fs13.mkdir(path8.dirname(file), { recursive: true });
7337
7911
  const lines = history.map((entry) => JSON.stringify(entry));
7338
- await fs12.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
7912
+ await fs13.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
7339
7913
  }
7340
7914
 
7341
7915
  // 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()
7916
+ import { z as z7 } from "zod";
7917
+ var HistoryEntrySchema = z7.object({
7918
+ method: z7.string(),
7919
+ params: z7.unknown(),
7920
+ recordedAt: z7.number()
7347
7921
  });
7348
- var BundleSession = z6.object({
7922
+ var BundleSession = z7.object({
7349
7923
  // The exporter's local id. Regenerated fresh on import (sessionId is
7350
7924
  // the local namespace; lineageId is what survives across hops).
7351
- sessionId: z6.string(),
7925
+ sessionId: z7.string(),
7352
7926
  // Required on bundles — the export path backfills if the source
7353
7927
  // record was written before lineageId existed.
7354
- lineageId: z6.string(),
7928
+ lineageId: z7.string(),
7355
7929
  // The exporter's agent-side session id at export time. Carried so
7356
7930
  // importers can persist it as a breadcrumb (and, eventually, as the
7357
7931
  // handle a "connect back to origin" feature would need). Omitted on
7358
7932
  // bundles whose source record never bound to an agent (e.g. a
7359
7933
  // 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(),
7934
+ upstreamSessionId: z7.string().optional(),
7935
+ agentId: z7.string(),
7936
+ cwd: z7.string(),
7937
+ title: z7.string().optional(),
7364
7938
  // Structured snapshot. Carried across export/import so a sync'd
7365
7939
  // session on another machine surfaces the same synopsis in its
7366
7940
  // picker / list / info views without re-asking the agent.
7367
7941
  synopsis: SessionSynopsis.optional(),
7368
- summarizedThroughEntry: z6.number().int().nonnegative().optional(),
7369
- currentModel: z6.string().optional(),
7370
- currentMode: z6.string().optional(),
7942
+ summarizedThroughEntry: z7.number().int().nonnegative().optional(),
7943
+ currentModel: z7.string().optional(),
7944
+ currentMode: z7.string().optional(),
7371
7945
  currentUsage: PersistedUsage.optional(),
7372
- agentCommands: z6.array(PersistedAgentCommand).optional(),
7373
- agentModes: z6.array(PersistedAgentMode).optional(),
7946
+ agentCommands: z7.array(PersistedAgentCommand).optional(),
7947
+ agentModes: z7.array(PersistedAgentMode).optional(),
7374
7948
  // Raw interactive tristate (NOT the resolved effectiveInteractive) so
7375
7949
  // the value stays promotable on the destination: a cat/empty source
7376
7950
  // arrives as undefined and a real turn there can still flip it to
7377
7951
  // true. Carried alongside originatingClient so the importer's
7378
7952
  // effectiveInteractive can re-apply the cat-name hint at read time
7379
7953
  // without freezing a sticky `false` into the record.
7380
- interactive: z6.boolean().optional(),
7954
+ interactive: z7.boolean().optional(),
7381
7955
  originatingClient: PersistedOriginatingClient.optional(),
7382
- createdAt: z6.string(),
7383
- updatedAt: z6.string()
7956
+ createdAt: z7.string(),
7957
+ updatedAt: z7.string()
7384
7958
  });
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(),
7959
+ var Bundle = z7.object({
7960
+ version: z7.literal(1),
7961
+ exportedAt: z7.string(),
7962
+ exportedFrom: z7.object({
7963
+ hydraVersion: z7.string(),
7964
+ machine: z7.string(),
7391
7965
  // Externally-reachable name (and optional ":port") for the exporting
7392
7966
  // daemon, sourced from config.daemon.publicHost (or daemon.host when
7393
7967
  // non-loopback). Carried so an importer can construct a hydra:// URL
7394
7968
  // that dials back to the origin — e.g. over Tailscale. Omitted when
7395
7969
  // the exporter has no routable address; never falls back to loopback.
7396
- hydraHost: z6.string().optional()
7970
+ hydraHost: z7.string().optional()
7397
7971
  }),
7398
7972
  session: BundleSession,
7399
- history: z6.array(HistoryEntrySchema),
7400
- promptHistory: z6.array(z6.string()).optional()
7973
+ history: z7.array(HistoryEntrySchema),
7974
+ promptHistory: z7.array(z7.string()).optional()
7401
7975
  });
7402
7976
  function encodeBundle(params) {
7403
7977
  const bundle = {
@@ -7447,6 +8021,7 @@ var SessionManager = class {
7447
8021
  this.registry = registry;
7448
8022
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
7449
8023
  this.store = store ?? new SessionStore();
8024
+ this.tombstones = options.tombstones ?? new TombstoneStore();
7450
8025
  this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
7451
8026
  this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
7452
8027
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
@@ -7478,12 +8053,14 @@ var SessionManager = class {
7478
8053
  logger: this.logger,
7479
8054
  npmRegistry: this.npmRegistry
7480
8055
  });
8056
+ void this.refreshAgentCatalog();
7481
8057
  }
7482
8058
  registry;
7483
8059
  sessions = /* @__PURE__ */ new Map();
7484
8060
  resurrectionInflight = /* @__PURE__ */ new Map();
7485
8061
  spawner;
7486
8062
  store;
8063
+ tombstones;
7487
8064
  histories;
7488
8065
  idleTimeoutMs;
7489
8066
  defaultModels;
@@ -7505,6 +8082,25 @@ var SessionManager = class {
7505
8082
  // out-of-band so session close is instant; persists synopsis/title
7506
8083
  // via the same enqueueMetaWrite path the in-session handlers used.
7507
8084
  synopsisCoordinator;
8085
+ // Cached agent catalog used to populate the `agent` config option's
8086
+ // value list. Refreshed lazily (fire-and-forget) since the underlying
8087
+ // registry load may hit the network; sessions read whatever snapshot is
8088
+ // current and always inject their own live agent if it's missing.
8089
+ agentCatalog = [];
8090
+ // Refresh the cached agent catalog from the registry. Fire-and-forget;
8091
+ // failures leave the prior snapshot in place. Called at construction and
8092
+ // after each session creation so the list tracks newly-installed agents.
8093
+ async refreshAgentCatalog() {
8094
+ try {
8095
+ const { agents } = await listAgents(this.registry);
8096
+ this.agentCatalog = agents.map((a) => ({
8097
+ id: a.id,
8098
+ name: a.name,
8099
+ ...a.description !== void 0 ? { description: a.description } : {}
8100
+ }));
8101
+ } catch {
8102
+ }
8103
+ }
7508
8104
  async create(params) {
7509
8105
  const fresh = await this.bootstrapAgent({
7510
8106
  agentId: params.agentId,
@@ -7521,7 +8117,7 @@ var SessionManager = class {
7521
8117
  continue;
7522
8118
  }
7523
8119
  try {
7524
- const result = await t.connection.request("transformer/message", {
8120
+ const result = await t.connection.request("hydra-acp/transformer/message", {
7525
8121
  token: `t_${generateRawSessionId()}`,
7526
8122
  phase: "response",
7527
8123
  method: "initialize",
@@ -7551,6 +8147,7 @@ var SessionManager = class {
7551
8147
  logger: this.logger,
7552
8148
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7553
8149
  listSessions: () => this.list(),
8150
+ availableAgents: () => this.agentCatalog,
7554
8151
  historyStore: this.histories,
7555
8152
  historyMaxEntries: this.sessionHistoryMaxEntries,
7556
8153
  currentModel: fresh.initialModel,
@@ -7718,6 +8315,7 @@ var SessionManager = class {
7718
8315
  logger: this.logger,
7719
8316
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7720
8317
  listSessions: () => this.list(),
8318
+ availableAgents: () => this.agentCatalog,
7721
8319
  historyStore: this.histories,
7722
8320
  historyMaxEntries: this.sessionHistoryMaxEntries,
7723
8321
  currentModel: effectiveModel,
@@ -7798,6 +8396,7 @@ var SessionManager = class {
7798
8396
  logger: this.logger,
7799
8397
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7800
8398
  listSessions: () => this.list(),
8399
+ availableAgents: () => this.agentCatalog,
7801
8400
  historyStore: this.histories,
7802
8401
  historyMaxEntries: this.sessionHistoryMaxEntries,
7803
8402
  currentModel: effectiveModel,
@@ -7821,7 +8420,7 @@ var SessionManager = class {
7821
8420
  }
7822
8421
  async dirExists(cwd) {
7823
8422
  try {
7824
- return (await fs13.stat(cwd)).isDirectory();
8423
+ return (await fs14.stat(cwd)).isDirectory();
7825
8424
  } catch {
7826
8425
  return false;
7827
8426
  }
@@ -7927,7 +8526,7 @@ var SessionManager = class {
7927
8526
  for (const rec of stored) {
7928
8527
  existing.add(`${rec.agentId}::${rec.upstreamSessionId}`);
7929
8528
  }
7930
- const hydraHomeDir = paths.home();
8529
+ const synopsisSandboxDir = paths.sessionsDir();
7931
8530
  const synced = [];
7932
8531
  let skipped = 0;
7933
8532
  for (const entry of entries) {
@@ -7936,10 +8535,21 @@ var SessionManager = class {
7936
8535
  skipped += 1;
7937
8536
  continue;
7938
8537
  }
7939
- if (isUnderHydraHome(entry.cwd, hydraHomeDir)) {
8538
+ if (isSynopsisSession(entry.cwd, synopsisSandboxDir)) {
7940
8539
  skipped += 1;
7941
8540
  continue;
7942
8541
  }
8542
+ const tombstone = await this.tombstones.read(agentId, entry.sessionId).catch(() => void 0);
8543
+ if (tombstone) {
8544
+ if (!shouldResurrectFromUpstream(tombstone, entry.updatedAt)) {
8545
+ skipped += 1;
8546
+ continue;
8547
+ }
8548
+ await this.tombstones.remove(agentId, entry.sessionId).catch(() => void 0);
8549
+ this.logger?.info(
8550
+ `syncFromAgent: resurrecting tombstoned ${agentId}/${entry.sessionId} (upstream updatedAt advanced past ${tombstone.upstreamUpdatedAt ?? "<unset>"})`
8551
+ );
8552
+ }
7943
8553
  existing.add(dedupeKey);
7944
8554
  const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
7945
8555
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -8095,6 +8705,17 @@ var SessionManager = class {
8095
8705
  session.onClose(({ deleteRecord }) => {
8096
8706
  this.sessions.delete(session.sessionId);
8097
8707
  if (deleteRecord) {
8708
+ if (session.upstreamSessionId) {
8709
+ void this.tombstones.add({
8710
+ agentId: session.agentId,
8711
+ upstreamSessionId: session.upstreamSessionId,
8712
+ deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
8713
+ upstreamUpdatedAt: new Date(session.updatedAt).toISOString(),
8714
+ cwd: session.cwd,
8715
+ title: session.title,
8716
+ reason: "user"
8717
+ }).catch(() => void 0);
8718
+ }
8098
8719
  void this.store.delete(session.sessionId).catch(() => void 0);
8099
8720
  void this.histories.delete(session.sessionId).catch(() => void 0);
8100
8721
  return;
@@ -8304,6 +8925,35 @@ var SessionManager = class {
8304
8925
  }
8305
8926
  return session;
8306
8927
  }
8928
+ // Synchronous SessionListEntry for a resident session. Mirrors the
8929
+ // live-session branch of list() but skips the async history probe:
8930
+ // callers on the attach/new hot path already hold the Session and
8931
+ // don't need the history-derived `interactive` inference (they pass
8932
+ // through the session's own tristate) or the history mtime (the
8933
+ // session's updatedAt is current). Used to build the reconciled
8934
+ // session/new + session/attach response `_meta["hydra-acp"]` from the
8935
+ // same shape session/list emits.
8936
+ liveListEntry(session) {
8937
+ return {
8938
+ sessionId: session.sessionId,
8939
+ upstreamSessionId: session.upstreamSessionId,
8940
+ cwd: session.cwd,
8941
+ title: session.title,
8942
+ agentId: session.agentId,
8943
+ currentModel: session.currentModel,
8944
+ currentUsage: session.currentUsage,
8945
+ parentSessionId: session.parentSessionId,
8946
+ forkedFromSessionId: session.forkedFromSessionId,
8947
+ forkedFromMessageId: session.forkedFromMessageId,
8948
+ originatingClient: session.originatingClient,
8949
+ interactive: session.interactive,
8950
+ updatedAt: new Date(session.updatedAt).toISOString(),
8951
+ attachedClients: session.attachedCount,
8952
+ status: "live",
8953
+ busy: session.turnStartedAt !== void 0,
8954
+ awaitingInput: session.awaitingInput
8955
+ };
8956
+ }
8307
8957
  async list(filter = {}) {
8308
8958
  const entries = [];
8309
8959
  const liveIds = /* @__PURE__ */ new Set();
@@ -8582,7 +9232,7 @@ var SessionManager = class {
8582
9232
  );
8583
9233
  const sourceMtime = new Date(args.bundle.session.updatedAt);
8584
9234
  if (!Number.isNaN(sourceMtime.getTime())) {
8585
- await fs13.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
9235
+ await fs14.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
8586
9236
  }
8587
9237
  if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
8588
9238
  await saveHistory(
@@ -8637,6 +9287,17 @@ var SessionManager = class {
8637
9287
  if (!record) {
8638
9288
  return false;
8639
9289
  }
9290
+ if (record.upstreamSessionId) {
9291
+ await this.tombstones.add({
9292
+ agentId: record.agentId,
9293
+ upstreamSessionId: record.upstreamSessionId,
9294
+ deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
9295
+ upstreamUpdatedAt: record.updatedAt,
9296
+ cwd: record.cwd,
9297
+ title: record.title,
9298
+ reason: "user"
9299
+ }).catch(() => void 0);
9300
+ }
8640
9301
  await this.store.delete(sessionId).catch(() => void 0);
8641
9302
  return true;
8642
9303
  }
@@ -8846,13 +9507,13 @@ var SessionManager = class {
8846
9507
  }
8847
9508
  }
8848
9509
  };
8849
- function isUnderHydraHome(cwd, hydraHomeDir) {
9510
+ function isSynopsisSession(cwd, sandboxDir) {
8850
9511
  if (typeof cwd !== "string" || cwd.length === 0) {
8851
9512
  return false;
8852
9513
  }
8853
9514
  const resolved = path9.resolve(cwd);
8854
- const home = path9.resolve(hydraHomeDir);
8855
- return resolved === home || resolved.startsWith(home + path9.sep);
9515
+ const base = path9.resolve(sandboxDir);
9516
+ return resolved === base || resolved.startsWith(base + path9.sep);
8856
9517
  }
8857
9518
  function mergeForPersistence(session, existing) {
8858
9519
  const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
@@ -8977,6 +9638,13 @@ function extractInitialModel(result) {
8977
9638
  }
8978
9639
  }
8979
9640
  }
9641
+ const fromConfig = findConfigOptionEntry(result, "model");
9642
+ if (fromConfig) {
9643
+ const cv = asString(fromConfig.currentValue);
9644
+ if (cv) {
9645
+ return cv;
9646
+ }
9647
+ }
8980
9648
  return void 0;
8981
9649
  }
8982
9650
  function asString(value) {
@@ -8986,6 +9654,22 @@ function asString(value) {
8986
9654
  const trimmed = value.trim();
8987
9655
  return trimmed.length > 0 ? trimmed : void 0;
8988
9656
  }
9657
+ function findConfigOptionEntry(result, id) {
9658
+ const list = result.configOptions;
9659
+ if (!Array.isArray(list)) {
9660
+ return void 0;
9661
+ }
9662
+ for (const raw of list) {
9663
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
9664
+ continue;
9665
+ }
9666
+ const entry = raw;
9667
+ if (entry.id === id) {
9668
+ return entry;
9669
+ }
9670
+ }
9671
+ return void 0;
9672
+ }
8989
9673
  function nonEmptyOrUndefined(arr) {
8990
9674
  return arr.length > 0 ? arr : void 0;
8991
9675
  }
@@ -9021,6 +9705,13 @@ function extractInitialModels(result) {
9021
9705
  }
9022
9706
  }
9023
9707
  }
9708
+ const fromConfig = findConfigOptionEntry(result, "model");
9709
+ if (fromConfig) {
9710
+ const parsed = parseModelsList(fromConfig.options);
9711
+ if (parsed.length > 0) {
9712
+ return parsed;
9713
+ }
9714
+ }
9024
9715
  return [];
9025
9716
  }
9026
9717
  function extractInitialModes(result) {
@@ -9055,6 +9746,13 @@ function extractInitialModes(result) {
9055
9746
  }
9056
9747
  }
9057
9748
  }
9749
+ const fromConfig = findConfigOptionEntry(result, "mode");
9750
+ if (fromConfig) {
9751
+ const parsed = parseModesList(fromConfig.options);
9752
+ if (parsed.length > 0) {
9753
+ return parsed;
9754
+ }
9755
+ }
9058
9756
  return [];
9059
9757
  }
9060
9758
  function extractInitialCurrentMode(result) {
@@ -9085,6 +9783,13 @@ function extractInitialCurrentMode(result) {
9085
9783
  }
9086
9784
  }
9087
9785
  }
9786
+ const fromConfig = findConfigOptionEntry(result, "mode");
9787
+ if (fromConfig) {
9788
+ const cv = asString(fromConfig.currentValue);
9789
+ if (cv) {
9790
+ return cv;
9791
+ }
9792
+ }
9088
9793
  return void 0;
9089
9794
  }
9090
9795
  async function restoreCurrentMode(opts) {
@@ -9155,33 +9860,6 @@ async function restoreCurrentModel(opts) {
9155
9860
  return agentReportedModel;
9156
9861
  }
9157
9862
  }
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);
9182
- }
9183
- return out;
9184
- }
9185
9863
  function findLastTurnComplete(history) {
9186
9864
  for (let i = history.length - 1; i >= 0; i--) {
9187
9865
  const entry = history[i];
@@ -9201,7 +9879,7 @@ function findLastTurnComplete(history) {
9201
9879
  }
9202
9880
  async function loadPromptHistorySafely(sessionId) {
9203
9881
  try {
9204
- const raw = await fs13.readFile(paths.tuiHistoryFile(sessionId), "utf8");
9882
+ const raw = await fs14.readFile(paths.tuiHistoryFile(sessionId), "utf8");
9205
9883
  const out = [];
9206
9884
  for (const line of raw.split("\n")) {
9207
9885
  if (line.length === 0) {
@@ -9222,7 +9900,7 @@ async function loadPromptHistorySafely(sessionId) {
9222
9900
  }
9223
9901
  async function historyStatus(sessionId) {
9224
9902
  try {
9225
- const st = await fs13.stat(paths.historyFile(sessionId));
9903
+ const st = await fs14.stat(paths.historyFile(sessionId));
9226
9904
  return {
9227
9905
  mtime: new Date(st.mtimeMs).toISOString(),
9228
9906
  hasContent: st.size > 0
@@ -9243,7 +9921,7 @@ function effectiveInteractive(record, hasContent) {
9243
9921
 
9244
9922
  // src/core/child-supervisor.ts
9245
9923
  import { spawn as spawn4 } from "child_process";
9246
- import * as fs14 from "fs";
9924
+ import * as fs15 from "fs";
9247
9925
  import * as fsp5 from "fs/promises";
9248
9926
  import * as path10 from "path";
9249
9927
 
@@ -9616,7 +10294,7 @@ var ChildSupervisor = class {
9616
10294
  }
9617
10295
  const cfg = entry.config;
9618
10296
  const command = cfg.command.length > 0 ? cfg.command : [cfg.name];
9619
- const logStream = fs14.createWriteStream(
10297
+ const logStream = fs15.createWriteStream(
9620
10298
  this.adapter.paths.logFile(cfg.name),
9621
10299
  { flags: "a" }
9622
10300
  );
@@ -9672,7 +10350,7 @@ var ChildSupervisor = class {
9672
10350
  }
9673
10351
  if (typeof child.pid === "number") {
9674
10352
  try {
9675
- fs14.writeFileSync(
10353
+ fs15.writeFileSync(
9676
10354
  this.adapter.paths.pidFile(cfg.name),
9677
10355
  `${child.pid}
9678
10356
  `,
@@ -9698,7 +10376,7 @@ var ChildSupervisor = class {
9698
10376
  });
9699
10377
  child.on("exit", (code, signal) => {
9700
10378
  try {
9701
- fs14.unlinkSync(this.adapter.paths.pidFile(cfg.name));
10379
+ fs15.unlinkSync(this.adapter.paths.pidFile(cfg.name));
9702
10380
  } catch {
9703
10381
  }
9704
10382
  logStream.write(
@@ -9808,13 +10486,13 @@ var TRANSFORMER_ADAPTER = {
9808
10486
  }
9809
10487
  };
9810
10488
  var TransformerManager = class extends ChildSupervisor {
9811
- // Transformers that have completed transformer/initialize and are ready to
10489
+ // Transformers that have completed hydra-acp/transformer/initialize and are ready to
9812
10490
  // participate in chains. Keyed by transformer name.
9813
10491
  connected = /* @__PURE__ */ new Map();
9814
10492
  constructor(transformers, context, options = {}) {
9815
10493
  super(transformers, TRANSFORMER_ADAPTER, context, options);
9816
10494
  }
9817
- // Called by the WS handler after transformer/initialize completes. The
10495
+ // Called by the WS handler after hydra-acp/transformer/initialize completes. The
9818
10496
  // transformer is now eligible to participate in session chains.
9819
10497
  registerConnection(name, connection, intercepts) {
9820
10498
  this.connected.set(name, {
@@ -10064,20 +10742,27 @@ var SessionTokenStore = class _SessionTokenStore {
10064
10742
  // keyed by hash
10065
10743
  writeTimer = null;
10066
10744
  writeInflight = null;
10067
- constructor(records) {
10745
+ // Bound at construction so a debounced write that fires after the
10746
+ // process's HYDRA_ACP_HOME has changed (e.g. between tests) targets the
10747
+ // path this store was loaded from rather than re-resolving paths.home()
10748
+ // and clobbering an unrelated home's token file.
10749
+ filePath;
10750
+ constructor(records, filePath) {
10751
+ this.filePath = filePath;
10068
10752
  for (const r of records) {
10069
10753
  this.records.set(r.hash, r);
10070
10754
  }
10071
10755
  }
10072
10756
  static async load() {
10073
10757
  let records = [];
10758
+ const filePath = tokensFilePath();
10074
10759
  const parsed = await readJsonSafe(
10075
- tokensFilePath()
10760
+ filePath
10076
10761
  );
10077
10762
  if (parsed && Array.isArray(parsed.records)) {
10078
10763
  records = parsed.records.filter(isRecord);
10079
10764
  }
10080
- const store = new _SessionTokenStore(records);
10765
+ const store = new _SessionTokenStore(records, filePath);
10081
10766
  const removed = store.sweepExpired(/* @__PURE__ */ new Date());
10082
10767
  if (removed > 0) {
10083
10768
  await store.flush();
@@ -10187,21 +10872,29 @@ var SessionTokenStore = class _SessionTokenStore {
10187
10872
  });
10188
10873
  }, WRITE_DEBOUNCE_MS);
10189
10874
  }
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 }
10875
+ // Serialize writes by chaining onto whatever write is in flight. Two
10876
+ // concurrent persists (e.g. a debounced scheduleWrite timer firing while
10877
+ // flush() awaits) would otherwise both write a temp file and race their
10878
+ // renames onto the same path — the older snapshot could win. Chaining
10879
+ // guarantees writes run one after another, each snapshotting records at
10880
+ // the moment it actually runs, so the final on-disk state reflects the
10881
+ // latest records. The returned promise resolves once THIS persist's
10882
+ // write has completed.
10883
+ persist() {
10884
+ const run2 = (this.writeInflight ?? Promise.resolve()).catch(() => void 0).then(
10885
+ () => writeJsonAtomic(
10886
+ this.filePath,
10887
+ { records: Array.from(this.records.values()) },
10888
+ { mode: 384 }
10889
+ )
10199
10890
  );
10200
- try {
10201
- await this.writeInflight;
10202
- } finally {
10203
- this.writeInflight = null;
10204
- }
10891
+ this.writeInflight = run2;
10892
+ run2.catch(() => void 0).finally(() => {
10893
+ if (this.writeInflight === run2) {
10894
+ this.writeInflight = null;
10895
+ }
10896
+ });
10897
+ return run2;
10205
10898
  }
10206
10899
  };
10207
10900
  function isRecord(value) {
@@ -10363,6 +11056,7 @@ var AuthRateLimiter = class {
10363
11056
 
10364
11057
  // src/daemon/routes/sessions.ts
10365
11058
  import * as os3 from "os";
11059
+ import * as path13 from "path";
10366
11060
 
10367
11061
  // src/core/render-update.ts
10368
11062
  import stripAnsi from "strip-ansi";
@@ -10412,10 +11106,52 @@ function mapUpdate(update) {
10412
11106
  return mapAvailableModes(u);
10413
11107
  case "session_info_update":
10414
11108
  return mapSessionInfo(u);
11109
+ case "config_option_update":
11110
+ return mapConfigOptions(u);
10415
11111
  default:
10416
11112
  return { kind: "unknown", sessionUpdate: tag, raw: update };
10417
11113
  }
10418
11114
  }
11115
+ function mapConfigOptions(u) {
11116
+ const list = u.configOptions;
11117
+ if (!Array.isArray(list)) {
11118
+ return null;
11119
+ }
11120
+ const options = [];
11121
+ for (const raw of list) {
11122
+ if (!raw || typeof raw !== "object") {
11123
+ continue;
11124
+ }
11125
+ const o = raw;
11126
+ if (typeof o.id !== "string" || typeof o.currentValue !== "string" || !Array.isArray(o.options)) {
11127
+ continue;
11128
+ }
11129
+ const values = [];
11130
+ for (const v of o.options) {
11131
+ if (!v || typeof v !== "object") {
11132
+ continue;
11133
+ }
11134
+ const vv = v;
11135
+ if (typeof vv.value !== "string") {
11136
+ continue;
11137
+ }
11138
+ values.push({
11139
+ value: vv.value,
11140
+ name: typeof vv.name === "string" ? vv.name : vv.value,
11141
+ ...typeof vv.description === "string" ? { description: vv.description } : {}
11142
+ });
11143
+ }
11144
+ options.push({
11145
+ id: o.id,
11146
+ name: typeof o.name === "string" ? o.name : o.id,
11147
+ type: "select",
11148
+ currentValue: o.currentValue,
11149
+ options: values,
11150
+ ...typeof o.category === "string" ? { category: o.category } : {}
11151
+ });
11152
+ }
11153
+ return { kind: "config-options", options };
11154
+ }
10419
11155
  function mapSessionInfo(u) {
10420
11156
  const rawTitle = readString(u, "title");
10421
11157
  const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
@@ -11518,6 +12254,53 @@ function registerSessionRoutes(app, manager, defaults) {
11518
12254
  }
11519
12255
  reply.code(204).send();
11520
12256
  });
12257
+ app.post("/v1/sessions/:id/stdin/open", async (request, reply) => {
12258
+ const raw = request.params.id;
12259
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
12260
+ const session = manager.get(id);
12261
+ if (!session) {
12262
+ reply.code(404).send({ error: "session not found" });
12263
+ return reply;
12264
+ }
12265
+ const body = request.body ?? {};
12266
+ const openOpts = {};
12267
+ if (body.mode === "memory" || body.mode === "file") {
12268
+ openOpts.mode = body.mode;
12269
+ }
12270
+ if (typeof body.capacityBytes === "number") {
12271
+ openOpts.capacityBytes = body.capacityBytes;
12272
+ }
12273
+ if (typeof body.fileCapBytes === "number") {
12274
+ openOpts.fileCapBytes = body.fileCapBytes;
12275
+ }
12276
+ if ((openOpts.mode ?? "memory") === "file") {
12277
+ openOpts.filePathFor = (sid) => path13.join(os3.tmpdir(), `hydra-acp-stdin-${sid}.log`);
12278
+ }
12279
+ try {
12280
+ return session.openStream(openOpts);
12281
+ } catch (err) {
12282
+ reply.code(409).send({ error: err.message });
12283
+ return reply;
12284
+ }
12285
+ });
12286
+ app.post("/v1/sessions/:id/stdin", async (request, reply) => {
12287
+ const raw = request.params.id;
12288
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
12289
+ const session = manager.get(id);
12290
+ if (!session) {
12291
+ reply.code(404).send({ error: "session not found" });
12292
+ return reply;
12293
+ }
12294
+ const body = request.body ?? {};
12295
+ const chunk = typeof body.chunk === "string" ? body.chunk : "";
12296
+ const eof = body.eof === true;
12297
+ try {
12298
+ return session.streamWrite(chunk, eof);
12299
+ } catch (err) {
12300
+ reply.code(409).send({ error: err.message });
12301
+ return reply;
12302
+ }
12303
+ });
11521
12304
  app.patch("/v1/sessions/:id", async (request, reply) => {
11522
12305
  const raw = request.params.id;
11523
12306
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -11754,22 +12537,7 @@ function registerSessionRoutes(app, manager, defaults) {
11754
12537
  // src/daemon/routes/agents.ts
11755
12538
  function registerAgentRoutes(app, registry, manager, opts = {}) {
11756
12539
  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
- };
12540
+ return listAgents(registry);
11773
12541
  });
11774
12542
  app.get("/v1/registry", async () => {
11775
12543
  return registry.load();
@@ -12078,22 +12846,22 @@ function registerConfigRoutes(app, snapshot) {
12078
12846
  }
12079
12847
 
12080
12848
  // src/daemon/routes/auth.ts
12081
- import { z as z7 } from "zod";
12849
+ import { z as z8 } from "zod";
12082
12850
 
12083
12851
  // src/core/password.ts
12084
- import * as fs15 from "fs/promises";
12085
- import * as path13 from "path";
12852
+ import * as fs16 from "fs/promises";
12853
+ import * as path14 from "path";
12086
12854
  import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
12087
12855
  import { promisify } from "util";
12088
12856
  var scryptAsync = promisify(scrypt);
12089
12857
  function passwordHashPath() {
12090
- return path13.join(paths.home(), "password-hash");
12858
+ return path14.join(paths.home(), "password-hash");
12091
12859
  }
12092
12860
  var DEFAULT_N = 1 << 15;
12093
12861
  var MAX_MEM = 128 * 1024 * 1024;
12094
12862
  async function hasPassword() {
12095
12863
  try {
12096
- const text = await fs15.readFile(passwordHashPath(), "utf8");
12864
+ const text = await fs16.readFile(passwordHashPath(), "utf8");
12097
12865
  return text.trim().length > 0;
12098
12866
  } catch (err) {
12099
12867
  const e = err;
@@ -12109,7 +12877,7 @@ async function verifyPassword(plaintext) {
12109
12877
  }
12110
12878
  let line;
12111
12879
  try {
12112
- line = (await fs15.readFile(passwordHashPath(), "utf8")).trim();
12880
+ line = (await fs16.readFile(passwordHashPath(), "utf8")).trim();
12113
12881
  } catch (err) {
12114
12882
  const e = err;
12115
12883
  if (e.code === "ENOENT") {
@@ -12145,13 +12913,13 @@ async function verifyPassword(plaintext) {
12145
12913
  }
12146
12914
 
12147
12915
  // 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()
12916
+ var LoginBody = z8.object({
12917
+ password: z8.string().min(1),
12918
+ label: z8.string().min(1).max(256).optional(),
12919
+ ttlSec: z8.number().int().positive().optional()
12152
12920
  });
12153
- var LogoutBody = z7.object({
12154
- id: z7.string().optional()
12921
+ var LogoutBody = z8.object({
12922
+ id: z8.string().optional()
12155
12923
  }).optional();
12156
12924
  function registerAuthRoutes(app, deps) {
12157
12925
  app.post(
@@ -12303,8 +13071,6 @@ function wsToMessageStream(ws) {
12303
13071
  }
12304
13072
 
12305
13073
  // src/daemon/acp-ws.ts
12306
- import * as os4 from "os";
12307
- import * as path14 from "path";
12308
13074
  import { randomBytes as randomBytes4 } from "crypto";
12309
13075
  function registerAcpWsEndpoint(app, deps) {
12310
13076
  app.get("/acp", { websocket: true }, async (socket, request) => {
@@ -12361,7 +13127,7 @@ function registerAcpWsEndpoint(app, deps) {
12361
13127
  });
12362
13128
  if (processIdentity && deps.extensionCommands) {
12363
13129
  const registry = deps.extensionCommands;
12364
- connection.onRequest("hydra-acp/register_commands", async (raw) => {
13130
+ connection.onRequest("hydra-acp/commands/register", async (raw) => {
12365
13131
  const params = raw ?? {};
12366
13132
  const commands = Array.isArray(params.commands) ? params.commands.map((c) => {
12367
13133
  if (!c || typeof c !== "object") {
@@ -12389,7 +13155,7 @@ function registerAcpWsEndpoint(app, deps) {
12389
13155
  }
12390
13156
  if (processIdentity && deps.extensionMcp) {
12391
13157
  const mcpRegistry = deps.extensionMcp;
12392
- connection.onRequest("hydra-acp/register_mcp_tools", async (raw) => {
13158
+ connection.onRequest("hydra-acp/mcp_tools/register", async (raw) => {
12393
13159
  const params = raw ?? {};
12394
13160
  const instructions = typeof params.instructions === "string" ? params.instructions : void 0;
12395
13161
  const tools = Array.isArray(params.tools) ? params.tools.map((t) => {
@@ -12432,7 +13198,7 @@ function registerAcpWsEndpoint(app, deps) {
12432
13198
  });
12433
13199
  }
12434
13200
  if (processIdentity?.kind === "transformer") {
12435
- connection.onRequest("transformer/initialize", async (raw) => {
13201
+ connection.onRequest("hydra-acp/transformer/initialize", async (raw) => {
12436
13202
  const params = raw ?? {};
12437
13203
  const intercepts = Array.isArray(params.intercepts) ? params.intercepts.filter(
12438
13204
  (v) => typeof v === "string"
@@ -12457,7 +13223,7 @@ function registerAcpWsEndpoint(app, deps) {
12457
13223
  connection.onClose(() => {
12458
13224
  deps.transformers?.deregisterConnection(processIdentity.name);
12459
13225
  });
12460
- connection.onRequest("hydra-acp/emit_message", async (raw) => {
13226
+ connection.onRequest("hydra-acp/message/emit", async (raw) => {
12461
13227
  const params = raw ?? {};
12462
13228
  const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
12463
13229
  const method = typeof params.method === "string" ? params.method : void 0;
@@ -12485,7 +13251,7 @@ function registerAcpWsEndpoint(app, deps) {
12485
13251
  }
12486
13252
  throw Object.assign(new Error(`unsupported route: ${JSON.stringify(route)}`), { code: -32602 });
12487
13253
  });
12488
- connection.onRequest("hydra-acp/spawn_child_session", async (raw) => {
13254
+ connection.onRequest("hydra-acp/child_session/spawn", async (raw) => {
12489
13255
  const params = raw ?? {};
12490
13256
  const agentId = typeof params.agentId === "string" ? params.agentId : deps.defaultAgent;
12491
13257
  const cwd = typeof params.cwd === "string" ? params.cwd : void 0;
@@ -12502,7 +13268,7 @@ function registerAcpWsEndpoint(app, deps) {
12502
13268
  });
12503
13269
  return { childSessionId: child.sessionId };
12504
13270
  });
12505
- connection.onRequest("hydra-acp/fork_session", async (raw) => {
13271
+ connection.onRequest("hydra-acp/session/fork", async (raw) => {
12506
13272
  const params = raw ?? {};
12507
13273
  if (typeof params.sessionId !== "string") {
12508
13274
  throw Object.assign(
@@ -12519,7 +13285,30 @@ function registerAcpWsEndpoint(app, deps) {
12519
13285
  ...agentId !== void 0 ? { agentId } : {}
12520
13286
  });
12521
13287
  });
12522
- connection.onRequest("hydra-acp/await_child", async (raw) => {
13288
+ connection.onRequest("hydra-acp/session/delete", async (raw) => {
13289
+ const params = raw ?? {};
13290
+ if (typeof params.sessionId !== "string") {
13291
+ throw Object.assign(
13292
+ new Error("hydra-acp/session/delete requires sessionId"),
13293
+ { code: JsonRpcErrorCodes.InvalidParams }
13294
+ );
13295
+ }
13296
+ const id = await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
13297
+ const live = deps.manager.get(id);
13298
+ if (live) {
13299
+ await live.close({ deleteRecord: true });
13300
+ return { deleted: true, sessionId: id };
13301
+ }
13302
+ const removed = await deps.manager.deleteRecord(id);
13303
+ if (!removed) {
13304
+ throw Object.assign(
13305
+ new Error(`session ${id} not found`),
13306
+ { code: JsonRpcErrorCodes.SessionNotFound }
13307
+ );
13308
+ }
13309
+ return { deleted: true, sessionId: id };
13310
+ });
13311
+ connection.onRequest("hydra-acp/child_session/await", async (raw) => {
12523
13312
  const params = raw ?? {};
12524
13313
  const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
12525
13314
  const until = params.until === "idle" ? "idle" : "turn_complete";
@@ -12558,7 +13347,7 @@ function registerAcpWsEndpoint(app, deps) {
12558
13347
  child.onClose(() => finish());
12559
13348
  });
12560
13349
  });
12561
- connection.onRequest("hydra-acp/close_child_session", async (raw) => {
13350
+ connection.onRequest("hydra-acp/child_session/close", async (raw) => {
12562
13351
  const params = raw ?? {};
12563
13352
  const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
12564
13353
  if (!childSessionId) {
@@ -12570,7 +13359,7 @@ function registerAcpWsEndpoint(app, deps) {
12570
13359
  }
12571
13360
  return { ok: true };
12572
13361
  });
12573
- connection.onRequest("hydra-acp/keep_alive", async (raw) => {
13362
+ connection.onRequest("hydra-acp/connection/keep_alive", async (raw) => {
12574
13363
  const params = raw ?? {};
12575
13364
  const token2 = typeof params.token === "string" ? params.token : void 0;
12576
13365
  const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
@@ -12632,9 +13421,9 @@ function registerAcpWsEndpoint(app, deps) {
12632
13421
  try {
12633
13422
  session = await deps.manager.create({
12634
13423
  cwd: params.cwd,
12635
- agentId: params.agentId ?? deps.defaultAgent,
13424
+ agentId: hydraMeta.agentId ?? deps.defaultAgent,
12636
13425
  mcpServers: augmentedMcpServers,
12637
- title: hydraMeta.name,
13426
+ title: hydraMeta.title,
12638
13427
  agentArgs: hydraMeta.agentArgs,
12639
13428
  model: hydraMeta.model,
12640
13429
  onInstallProgress: makeInstallProgressForwarder(connection),
@@ -12685,14 +13474,16 @@ function registerAcpWsEndpoint(app, deps) {
12685
13474
  const modelsPayload = buildModelsPayload(session);
12686
13475
  return {
12687
13476
  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
13477
  ...modesPayload ? { modes: modesPayload } : {},
12694
13478
  ...modelsPayload ? { models: modelsPayload } : {},
12695
- _meta: buildResponseMeta(session)
13479
+ configOptions: session.buildConfigOptions(),
13480
+ // session/new is a core ACP spec method, so the per-attachment
13481
+ // clientId rides under _meta["hydra-acp"] rather than top-level.
13482
+ // Deferred-echo clients (TUI's queue work) read it from there to
13483
+ // recognize their own prompt_queue_added events.
13484
+ _meta: buildResponseMeta(deps.manager, session, {
13485
+ clientId: client.clientId
13486
+ })
12696
13487
  };
12697
13488
  });
12698
13489
  connection.onRequest("session/attach", async (raw) => {
@@ -12705,8 +13496,9 @@ function registerAcpWsEndpoint(app, deps) {
12705
13496
  deps.onTransformerVersion?.(processIdentity.name, attachVersion);
12706
13497
  }
12707
13498
  }
12708
- const hydraHints = extractHydraMeta(params._meta).resume;
12709
- const readonly = params.readonly === true;
13499
+ const hydraAttach = extractHydraMeta(params._meta);
13500
+ const hydraHints = hydraAttach.resume;
13501
+ const readonly = hydraAttach.readonly === true;
12710
13502
  app.log.info(
12711
13503
  `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints} readonly=${readonly}`
12712
13504
  );
@@ -12781,7 +13573,7 @@ function registerAcpWsEndpoint(app, deps) {
12781
13573
  params.clientInfo,
12782
13574
  params.clientId
12783
13575
  );
12784
- const drip = params.replayMode === "drip";
13576
+ const drip = hydraAttach.replayMode === "drip";
12785
13577
  const { entries: replay, appliedPolicy } = await session.attach(
12786
13578
  client,
12787
13579
  params.historyPolicy,
@@ -12796,7 +13588,7 @@ function registerAcpWsEndpoint(app, deps) {
12796
13588
  `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
13589
  );
12798
13590
  if (drip) {
12799
- const speed = params.dripSpeed && params.dripSpeed > 0 ? params.dripSpeed : 1;
13591
+ const speed = hydraAttach.dripSpeed && hydraAttach.dripSpeed > 0 ? hydraAttach.dripSpeed : 1;
12800
13592
  const MAX_GAP_MS = 750;
12801
13593
  void (async () => {
12802
13594
  let prev = null;
@@ -12838,7 +13630,8 @@ function registerAcpWsEndpoint(app, deps) {
12838
13630
  replayed: replay.length,
12839
13631
  ...modesPayload ? { modes: modesPayload } : {},
12840
13632
  ...modelsPayload ? { models: modelsPayload } : {},
12841
- _meta: buildResponseMeta(session)
13633
+ configOptions: session.buildConfigOptions(),
13634
+ _meta: buildResponseMeta(deps.manager, session)
12842
13635
  };
12843
13636
  });
12844
13637
  connection.onRequest("session/detach", async (raw) => {
@@ -12855,7 +13648,10 @@ function registerAcpWsEndpoint(app, deps) {
12855
13648
  if (session) {
12856
13649
  void deps.manager.reapIfOrphanedNonInteractive(params.sessionId);
12857
13650
  }
12858
- return { sessionId: params.sessionId, status: "detached" };
13651
+ return {
13652
+ sessionId: params.sessionId,
13653
+ _meta: { [HYDRA_META_KEY]: { detachStatus: "detached" } }
13654
+ };
12859
13655
  });
12860
13656
  connection.onRequest("session/list", async (raw) => {
12861
13657
  const params = SessionListParams.parse(raw ?? {});
@@ -12868,6 +13664,14 @@ function registerAcpWsEndpoint(app, deps) {
12868
13664
  };
12869
13665
  return result;
12870
13666
  });
13667
+ connection.onRequest("hydra-acp/agents/list", async () => {
13668
+ if (!deps.registry) {
13669
+ const err = new Error("agent registry unavailable");
13670
+ err.code = JsonRpcErrorCodes.InternalError;
13671
+ throw err;
13672
+ }
13673
+ return listAgents(deps.registry);
13674
+ });
12871
13675
  connection.onRequest("session/prompt", async (raw) => {
12872
13676
  const params = SessionPromptParams.parse(raw);
12873
13677
  denyIfReadonly(params.sessionId, "session/prompt");
@@ -12943,9 +13747,9 @@ function registerAcpWsEndpoint(app, deps) {
12943
13747
  handleCancelParams(raw);
12944
13748
  return null;
12945
13749
  });
12946
- connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
13750
+ connection.onRequest("hydra-acp/prompt/cancel", async (raw) => {
12947
13751
  const params = CancelPromptParams.parse(raw);
12948
- denyIfReadonly(params.sessionId, "hydra-acp/cancel_prompt");
13752
+ denyIfReadonly(params.sessionId, "hydra-acp/prompt/cancel");
12949
13753
  const session = deps.manager.get(params.sessionId);
12950
13754
  if (!session) {
12951
13755
  const err = new Error(`session ${params.sessionId} not found`);
@@ -12954,78 +13758,44 @@ function registerAcpWsEndpoint(app, deps) {
12954
13758
  }
12955
13759
  return session.cancelQueuedPrompt(params.messageId);
12956
13760
  });
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
- }
13761
+ connection.onRequest("hydra-acp/session/force_cancel", async (raw) => {
13762
+ const params = SessionCancelParams.parse(raw);
13763
+ denyIfReadonly(params.sessionId, "hydra-acp/session/force_cancel");
12977
13764
  const session = deps.manager.get(params.sessionId);
12978
13765
  if (!session) {
12979
13766
  const err = new Error(`session ${params.sessionId} not found`);
12980
13767
  err.code = JsonRpcErrorCodes.SessionNotFound;
12981
13768
  throw err;
12982
13769
  }
12983
- return session.amendPrompt(att.clientId, params);
13770
+ return session.forceCancel();
12984
13771
  });
12985
- connection.onRequest("hydra-acp/stream_open", async (raw) => {
12986
- const params = StreamOpenParams.parse(raw);
12987
- denyIfReadonly(params.sessionId, "hydra-acp/stream_open");
13772
+ connection.onRequest("hydra-acp/prompt/update", async (raw) => {
13773
+ const params = UpdatePromptParams.parse(raw);
13774
+ denyIfReadonly(params.sessionId, "hydra-acp/prompt/update");
12988
13775
  const session = deps.manager.get(params.sessionId);
12989
13776
  if (!session) {
12990
13777
  const err = new Error(`session ${params.sessionId} not found`);
12991
13778
  err.code = JsonRpcErrorCodes.SessionNotFound;
12992
13779
  throw err;
12993
13780
  }
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);
13781
+ return session.updateQueuedPrompt(params.messageId, params.prompt);
13008
13782
  });
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`);
13783
+ connection.onRequest("hydra-acp/prompt/amend", async (raw) => {
13784
+ const params = AmendPromptParams.parse(raw);
13785
+ denyIfReadonly(params.sessionId, "hydra-acp/prompt/amend");
13786
+ const att = state.attached.get(params.sessionId);
13787
+ if (!att) {
13788
+ const err = new Error("not attached to session");
13015
13789
  err.code = JsonRpcErrorCodes.SessionNotFound;
13016
13790
  throw err;
13017
13791
  }
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
13792
  const session = deps.manager.get(params.sessionId);
13023
13793
  if (!session) {
13024
13794
  const err = new Error(`session ${params.sessionId} not found`);
13025
13795
  err.code = JsonRpcErrorCodes.SessionNotFound;
13026
13796
  throw err;
13027
13797
  }
13028
- return session.streamRead(params.cursor, params.maxBytes, params.waitMs);
13798
+ return session.amendPrompt(att.clientId, params);
13029
13799
  });
13030
13800
  connection.onRequest("session/load", async (raw) => {
13031
13801
  const rawObj = raw ?? {};
@@ -13064,12 +13834,15 @@ function registerAcpWsEndpoint(app, deps) {
13064
13834
  const modelsPayload = buildModelsPayload(session);
13065
13835
  return {
13066
13836
  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
13837
  ...modesPayload ? { modes: modesPayload } : {},
13071
13838
  ...modelsPayload ? { models: modelsPayload } : {},
13072
- _meta: buildResponseMeta(session)
13839
+ configOptions: session.buildConfigOptions(),
13840
+ // session/load is a core ACP spec method: clientId rides under
13841
+ // _meta["hydra-acp"] (not top-level), same as session/new. Lets
13842
+ // deferred-echo clients recognize their own broadcasts.
13843
+ _meta: buildResponseMeta(deps.manager, session, {
13844
+ clientId: client.clientId
13845
+ })
13073
13846
  };
13074
13847
  });
13075
13848
  connection.onRequest("session/set_model", async (rawParams) => {
@@ -13127,6 +13900,79 @@ function registerAcpWsEndpoint(app, deps) {
13127
13900
  session.applyModeChange(params.modeId);
13128
13901
  return result;
13129
13902
  });
13903
+ connection.onRequest("session/set_config_option", async (rawParams) => {
13904
+ const params = rawParams;
13905
+ const invalid = (msg) => {
13906
+ const err = new Error(msg);
13907
+ err.code = JsonRpcErrorCodes.InvalidParams;
13908
+ return err;
13909
+ };
13910
+ const sessionIdField = params?.sessionId;
13911
+ if (typeof sessionIdField === "string") {
13912
+ denyIfReadonly(sessionIdField, "session/set_config_option");
13913
+ }
13914
+ if (!params || typeof params.sessionId !== "string") {
13915
+ throw invalid("session/set_config_option requires string sessionId");
13916
+ }
13917
+ if (typeof params.configId !== "string") {
13918
+ throw invalid("session/set_config_option requires string configId");
13919
+ }
13920
+ if (typeof params.value !== "string") {
13921
+ throw invalid("session/set_config_option requires string value");
13922
+ }
13923
+ const session = deps.manager.get(params.sessionId);
13924
+ if (!session) {
13925
+ const err = new Error(
13926
+ `session ${params.sessionId} not found`
13927
+ );
13928
+ err.code = JsonRpcErrorCodes.SessionNotFound;
13929
+ throw err;
13930
+ }
13931
+ const option = session.buildConfigOptions().find((o) => o.id === params.configId);
13932
+ if (!option) {
13933
+ throw invalid(
13934
+ `unknown configId ${JSON.stringify(params.configId)} for this session`
13935
+ );
13936
+ }
13937
+ if (!option.options.some((v) => v.value === params.value)) {
13938
+ throw invalid(
13939
+ `value ${JSON.stringify(params.value)} is not valid for configId ${JSON.stringify(params.configId)}`
13940
+ );
13941
+ }
13942
+ switch (params.configId) {
13943
+ case "model": {
13944
+ if (params.value !== session.currentModel) {
13945
+ await session.forwardRequest("session/set_model", {
13946
+ sessionId: params.sessionId,
13947
+ modelId: params.value
13948
+ });
13949
+ }
13950
+ session.applyModelChange(params.value);
13951
+ break;
13952
+ }
13953
+ case "mode": {
13954
+ if (params.value !== session.currentMode) {
13955
+ await session.forwardRequest("session/set_mode", {
13956
+ sessionId: params.sessionId,
13957
+ modeId: params.value
13958
+ });
13959
+ }
13960
+ session.applyModeChange(params.value);
13961
+ break;
13962
+ }
13963
+ case "agent": {
13964
+ if (params.value !== session.agentId) {
13965
+ await session.setAgent(params.value);
13966
+ }
13967
+ break;
13968
+ }
13969
+ default:
13970
+ throw invalid(
13971
+ `configId ${JSON.stringify(params.configId)} is not settable`
13972
+ );
13973
+ }
13974
+ return { configOptions: session.buildConfigOptions() };
13975
+ });
13130
13976
  connection.setDefaultHandler(async (rawParams, method) => {
13131
13977
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
13132
13978
  const err = new Error(`Method not found: ${method}`);
@@ -13279,81 +14125,57 @@ function decideSetModel(rawParams, manager) {
13279
14125
  };
13280
14126
  }
13281
14127
  function buildViewerResponseMeta(fromDisk) {
13282
- const ours = {
14128
+ const entry = {
14129
+ sessionId: fromDisk.hydraSessionId,
13283
14130
  upstreamSessionId: fromDisk.upstreamSessionId,
14131
+ cwd: fromDisk.cwd,
14132
+ title: fromDisk.title,
13284
14133
  agentId: fromDisk.agentId,
13285
- cwd: fromDisk.cwd
14134
+ currentModel: fromDisk.currentModel,
14135
+ currentUsage: fromDisk.currentUsage,
14136
+ forkedFromSessionId: fromDisk.forkedFromSessionId,
14137
+ forkedFromMessageId: fromDisk.forkedFromMessageId,
14138
+ originatingClient: fromDisk.originatingClient,
14139
+ interactive: fromDisk.interactive,
14140
+ updatedAt: fromDisk.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
14141
+ attachedClients: 0,
14142
+ status: "cold",
14143
+ busy: false,
14144
+ awaitingInput: false
13286
14145
  };
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 };
14146
+ const extras = {
14147
+ currentMode: fromDisk.currentMode,
14148
+ agentArgs: fromDisk.agentArgs,
14149
+ availableCommands: fromDisk.agentCommands,
14150
+ availableModes: fromDisk.agentModes,
14151
+ availableModels: fromDisk.agentModels
14152
+ };
14153
+ return { [HYDRA_META_KEY]: buildHydraSessionMeta(entry, extras) };
13312
14154
  }
13313
- function buildResponseMeta(session) {
13314
- const ours = {
13315
- upstreamSessionId: session.upstreamSessionId,
13316
- agentId: session.agentId,
13317
- cwd: session.cwd
14155
+ function buildResponseMeta(manager, session, opts = {}) {
14156
+ const entry = manager.liveListEntry(session);
14157
+ const extras = {
14158
+ clientId: opts.clientId,
14159
+ currentMode: session.currentMode,
14160
+ agentArgs: session.agentArgs,
14161
+ availableCommands: session.mergedAvailableCommands(),
14162
+ availableModes: session.availableModes(),
14163
+ availableModels: session.availableModels(),
14164
+ // Mid-turn at attach time: hand the client the original prompt's
14165
+ // recordedAt so it can boot directly into "busy · Ns" instead of
14166
+ // sitting on "ready" until the next live notification.
14167
+ turnStartedAt: session.turnStartedAt,
14168
+ // The underlying agent's own initialize-time capability claim, captured
14169
+ // verbatim. Lets capability-aware clients (cat --stream) pick the right
14170
+ // consumption surface without re-probing the agent.
14171
+ agentCapabilities: session.agentCapabilities,
14172
+ // Snapshot of the daemon-owned prompt queue. Lets a late attacher
14173
+ // paint queue chips for entries that landed before it joined without
14174
+ // waiting for new prompt_queue_added notifications. Omitted entirely
14175
+ // when the queue is empty (the common case).
14176
+ queue: session.queueSnapshot()
13318
14177
  };
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);
14178
+ return mergeMeta(session.agentMeta, buildHydraSessionMeta(entry, extras));
13357
14179
  }
13358
14180
  function buildInitializeResult() {
13359
14181
  return {
@@ -13384,18 +14206,26 @@ function buildInitializeResult() {
13384
14206
  description: "Bearer token presented at WS upgrade"
13385
14207
  }
13386
14208
  ],
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.
14209
+ // Advertise hydra-only capabilities via _meta["hydra-acp"], grouped by
14210
+ // resource to mirror the hydra-acp/<resource>/<action> method
14211
+ // namespaces. Generic ACP clients ignore the field; capability-aware
14212
+ // clients probe here to gate UI before calling a method. `pipelining`
14213
+ // is false until the streaming-input probe lands; the rest are
14214
+ // unconditional method-availability flags. (Named `prompt`/`agents`,
14215
+ // not `promptCapabilities`/`agentCapabilities` — those are ACP spec
14216
+ // names with different meanings.)
13393
14217
  _meta: mergeMeta(void 0, {
13394
- promptQueueing: true,
13395
- promptCancelling: true,
13396
- promptUpdating: true,
13397
- promptAmending: true,
13398
- promptPipelining: false
14218
+ prompt: {
14219
+ queueing: true,
14220
+ cancelling: true,
14221
+ updating: true,
14222
+ amending: true,
14223
+ pipelining: false
14224
+ },
14225
+ agents: {
14226
+ list: true,
14227
+ installProgress: true
14228
+ }
13399
14229
  })
13400
14230
  };
13401
14231
  }
@@ -13493,7 +14323,7 @@ var McpTokenRegistry = class {
13493
14323
  import { randomUUID } from "crypto";
13494
14324
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13495
14325
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13496
- import { z as z8 } from "zod";
14326
+ import { z as z9 } from "zod";
13497
14327
 
13498
14328
  // src/daemon/mcp/bearer.ts
13499
14329
  var BEARER_PREFIX2 = "Bearer ";
@@ -13522,7 +14352,7 @@ function buildMcpServer(session) {
13522
14352
  {
13523
14353
  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
14354
  inputSchema: {
13525
- bytes: z8.number().int().min(1).describe("How many trailing bytes to return.")
14355
+ bytes: z9.number().int().min(1).describe("How many trailing bytes to return.")
13526
14356
  }
13527
14357
  },
13528
14358
  async ({ bytes }) => {
@@ -13543,7 +14373,7 @@ function buildMcpServer(session) {
13543
14373
  {
13544
14374
  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
14375
  inputSchema: {
13546
- bytes: z8.number().int().min(1).describe("How many leading bytes to return.")
14376
+ bytes: z9.number().int().min(1).describe("How many leading bytes to return.")
13547
14377
  }
13548
14378
  },
13549
14379
  async ({ bytes }) => {
@@ -13564,13 +14394,13 @@ function buildMcpServer(session) {
13564
14394
  {
13565
14395
  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
14396
  inputSchema: {
13567
- cursor: z8.number().int().min(0).describe(
14397
+ cursor: z9.number().int().min(0).describe(
13568
14398
  "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
14399
  ),
13570
- max_bytes: z8.number().int().min(1).optional().describe(
14400
+ max_bytes: z9.number().int().min(1).optional().describe(
13571
14401
  "Optional cap on how many bytes to return. Server caps at 64 KiB regardless."
13572
14402
  ),
13573
- wait_ms: z8.number().int().min(0).optional().describe(
14403
+ wait_ms: z9.number().int().min(0).optional().describe(
13574
14404
  "If no bytes are available, block up to this many ms for more (capped server-side at 60_000)."
13575
14405
  )
13576
14406
  }
@@ -13593,8 +14423,8 @@ function buildMcpServer(session) {
13593
14423
  {
13594
14424
  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
14425
  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).")
14426
+ cursor: z9.number().int().min(0).describe("The cursor you've already consumed up to."),
14427
+ timeout_ms: z9.number().int().min(0).describe("Maximum ms to block (server caps at 60_000).")
13598
14428
  }
13599
14429
  },
13600
14430
  async ({ cursor, timeout_ms }) => {
@@ -13617,17 +14447,17 @@ function buildMcpServer(session) {
13617
14447
  {
13618
14448
  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
14449
  inputSchema: {
13620
- pattern: z8.string().min(1).describe(
14450
+ pattern: z9.string().min(1).describe(
13621
14451
  "Search pattern. Treated as a JavaScript regular expression by default (set `regex:false` for a literal substring match)."
13622
14452
  ),
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(
14453
+ regex: z9.boolean().optional().describe("Default true. Pass false to treat `pattern` as a literal substring."),
14454
+ case_insensitive: z9.boolean().optional().describe("Default false. Pass true for case-insensitive matching."),
14455
+ invert: z9.boolean().optional().describe("Default false. Pass true to return lines that do NOT match the pattern."),
14456
+ max_matches: z9.number().int().min(1).optional().describe("Default 100. Capped server-side at 1000."),
14457
+ max_bytes: z9.number().int().min(1).optional().describe("Default 64 KiB output. Capped server-side at 256 KiB."),
14458
+ context_before: z9.number().int().min(0).optional().describe("Default 0. Number of lines before each match to include (capped at 20)."),
14459
+ context_after: z9.number().int().min(0).optional().describe("Default 0. Number of lines after each match to include (capped at 20)."),
14460
+ cursor: z9.number().int().min(0).optional().describe(
13631
14461
  "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
14462
  )
13633
14463
  }
@@ -13884,7 +14714,7 @@ async function invokeWithTimeout(connection, server, tool, args, timeoutMs) {
13884
14714
  });
13885
14715
  try {
13886
14716
  return await Promise.race([
13887
- connection.request("hydra-acp/invoke_mcp_tool", {
14717
+ connection.request("hydra-acp/mcp_tools/invoke", {
13888
14718
  server,
13889
14719
  tool,
13890
14720
  args
@@ -14161,7 +14991,8 @@ async function startDaemon(config, serviceToken) {
14161
14991
  extensionCommands,
14162
14992
  mcpTokenRegistry,
14163
14993
  extensionMcp,
14164
- getDaemonOrigin
14994
+ getDaemonOrigin,
14995
+ registry
14165
14996
  });
14166
14997
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
14167
14998
  const address = app.server.address();
@@ -14221,7 +15052,7 @@ async function startDaemon(config, serviceToken) {
14221
15052
  setAgentPruneLogger(null);
14222
15053
  await app.close();
14223
15054
  try {
14224
- fs16.unlinkSync(paths.pidFile());
15055
+ fs17.unlinkSync(paths.pidFile());
14225
15056
  } catch {
14226
15057
  }
14227
15058
  try {