@hydra-acp/cli 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/daemon/server.ts
2
- import * as fs14 from "fs";
3
- import * as fsp5 from "fs/promises";
2
+ import * as fs15 from "fs";
3
+ import * as fsp8 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
6
6
 
@@ -86,6 +86,13 @@ var paths = {
86
86
  extensionsDir: () => path.join(hydraHome(), "extensions"),
87
87
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
88
88
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
89
+ transformersDir: () => path.join(hydraHome(), "transformers"),
90
+ transformerLogFile: (name) => path.join(hydraHome(), "transformers", `${name}.log`),
91
+ transformerPidFile: (name) => path.join(hydraHome(), "transformers", `${name}.pid`),
92
+ // Per-session scratch directory for transformer state. Each transformer
93
+ // gets an isolated directory keyed by session + transformer name so
94
+ // multiple transformers on the same session don't collide.
95
+ transformerState: (sessionId, transformerName) => path.join(hydraHome(), "sessions", sessionId, "transformer-state", transformerName),
89
96
  tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
90
97
  // Cross-session prompt history. Up-arrow / ^R fall through to this
91
98
  // after the per-session list is exhausted. JSONL, one entry per
@@ -166,7 +173,7 @@ var DaemonConfig = z.object({
166
173
  // Compaction trims to this many on a periodic basis; reads also slice
167
174
  // to the tail at this length as a defensive measure against older
168
175
  // daemons that may have written unbounded files.
169
- sessionHistoryMaxEntries: z.number().int().positive().default(1e3),
176
+ sessionHistoryMaxEntries: z.number().int().positive().default(1e4),
170
177
  // Bytes of trailing agent stderr buffered per AgentInstance so the
171
178
  // daemon can include it in the diagnostic message when a spawn fails.
172
179
  // Bump if your agents emit large tracebacks you want surfaced.
@@ -226,7 +233,13 @@ var TuiConfig = z.object({
226
233
  // streaming lines beneath the live thinking block. Set false to
227
234
  // suppress them — the TUI hotkey ^T toggles this at runtime without
228
235
  // persisting back to config.
229
- showThoughts: z.boolean().default(true)
236
+ showThoughts: z.boolean().default(true),
237
+ // Cap on entries kept in the cross-session global prompt-history file
238
+ // (~/.hydra-acp/prompt-history). This is the ^P / ^R recall list
239
+ // shared across all sessions; it's append-only on disk, so long-lived
240
+ // installs can grow past this — it's enforced at load time and per
241
+ // append in memory.
242
+ promptHistoryMaxEntries: z.number().int().positive().default(2e3)
230
243
  });
231
244
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
232
245
  var ExtensionBody = z.object({
@@ -238,6 +251,12 @@ var ExtensionBody = z.object({
238
251
  env: z.record(z.string()).default({}),
239
252
  enabled: z.boolean().default(true)
240
253
  });
254
+ var TransformerBody = z.object({
255
+ command: z.array(z.string()).default([]),
256
+ args: z.array(z.string()).default([]),
257
+ env: z.record(z.string()).default({}),
258
+ enabled: z.boolean().default(true)
259
+ });
241
260
  var HydraConfig = z.object({
242
261
  daemon: DaemonConfig.default({}),
243
262
  registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
@@ -259,6 +278,8 @@ var HydraConfig = z.object({
259
278
  // recency and truncated to this count. `--all` overrides in the CLI.
260
279
  sessionListColdLimit: z.number().int().nonnegative().default(20),
261
280
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
281
+ transformers: z.record(ExtensionName, TransformerBody).default({}),
282
+ defaultTransformers: z.array(z.string()).default([]),
262
283
  // npm registry URL used when installing npm-distributed agents into
263
284
  // ~/.hydra-acp/agents. Overrides the global ~/.npmrc registry so a
264
285
  // corporate .npmrc pointing at an internal registry doesn't break
@@ -272,7 +293,8 @@ var HydraConfig = z.object({
272
293
  cwdColumnMaxWidth: 24,
273
294
  progressIndicator: true,
274
295
  defaultEnterAction: "amend",
275
- showThoughts: true
296
+ showThoughts: true,
297
+ promptHistoryMaxEntries: 2e3
276
298
  })
277
299
  });
278
300
  function extensionList(config) {
@@ -281,6 +303,12 @@ function extensionList(config) {
281
303
  ...body
282
304
  }));
283
305
  }
306
+ function transformerList(config) {
307
+ return Object.entries(config.transformers).map(([name, body]) => ({
308
+ name,
309
+ ...body
310
+ }));
311
+ }
284
312
  async function readConfigFile() {
285
313
  let raw;
286
314
  try {
@@ -1138,7 +1166,8 @@ var JsonRpcErrorCodes = {
1138
1166
  // can't collide with future spec assignments.
1139
1167
  BundleAlreadyImported: -32010,
1140
1168
  PermissionDenied: -32011,
1141
- AlreadyAttached: -32012
1169
+ AlreadyAttached: -32012,
1170
+ StreamNotEnabled: -32013
1142
1171
  };
1143
1172
  var InitializeParams = z3.object({
1144
1173
  protocolVersion: z3.number().optional(),
@@ -1219,6 +1248,9 @@ function extractHydraMeta(meta) {
1219
1248
  if (Array.isArray(obj.agentArgs) && obj.agentArgs.every((a) => typeof a === "string")) {
1220
1249
  out.agentArgs = obj.agentArgs;
1221
1250
  }
1251
+ if (Array.isArray(obj.transformers) && obj.transformers.every((t) => typeof t === "string")) {
1252
+ out.transformers = obj.transformers;
1253
+ }
1222
1254
  if (obj.resume) {
1223
1255
  const parsed = SessionResumeHints.safeParse(obj.resume);
1224
1256
  if (parsed.success) {
@@ -1385,6 +1417,8 @@ var SessionListEntry = z3.object({
1385
1417
  // future "connect back to origin" callers would dial both.
1386
1418
  importedFromMachine: z3.string().optional(),
1387
1419
  importedFromUpstreamSessionId: z3.string().optional(),
1420
+ // Set when this session was spawned as a child by a transformer.
1421
+ parentSessionId: z3.string().optional(),
1388
1422
  updatedAt: z3.string(),
1389
1423
  attachedClients: z3.number().int().nonnegative(),
1390
1424
  status: z3.enum(["live", "cold"]).default("live"),
@@ -1518,6 +1552,65 @@ var PromptAmendedParams = z3.object({
1518
1552
  originator: PromptOriginatorSchema,
1519
1553
  amendedAt: z3.number()
1520
1554
  });
1555
+ var StreamOpenParams = z3.object({
1556
+ sessionId: z3.string(),
1557
+ // 'memory' keeps the ring in RAM only — needed for the eventual MCP
1558
+ // tool surface. 'file' adds a temp file projection that the agent can
1559
+ // consume with shell tools (tail -f / head / grep) when MCP isn't
1560
+ // available. The temp file's path is returned in the response.
1561
+ mode: z3.enum(["memory", "file"]).optional(),
1562
+ // Ring capacity in bytes. Server clamps to a reasonable minimum and
1563
+ // its configured max; omitted falls back to the daemon default.
1564
+ capacityBytes: z3.number().int().positive().optional(),
1565
+ // File mode only. Soft cap in bytes; after this many bytes are
1566
+ // written to the file, further appends still land in the ring but
1567
+ // stop being mirrored to disk. The daemon emits one stream_truncated
1568
+ // session/update notification when the cap is first hit.
1569
+ fileCapBytes: z3.number().int().positive().optional()
1570
+ });
1571
+ var StreamOpenResult = z3.object({
1572
+ // Only present when mode === "file".
1573
+ filePath: z3.string().optional(),
1574
+ capacityBytes: z3.number().int().positive(),
1575
+ fileCapBytes: z3.number().int().positive().optional()
1576
+ });
1577
+ var StreamWriteParams = z3.object({
1578
+ sessionId: z3.string(),
1579
+ // Base64-encoded bytes. UTF-8 stdin gets re-encoded on the wire; the
1580
+ // ring is byte-exact so binary streams (audio, framed protocols) work
1581
+ // identically.
1582
+ chunk: z3.string(),
1583
+ // True on the final write. Pending long-poll reads / waits return with
1584
+ // eof:true once this is observed.
1585
+ eof: z3.boolean().optional()
1586
+ });
1587
+ var StreamWriteResult = z3.object({
1588
+ // Absolute writeCursor after this append landed.
1589
+ writeCursor: z3.number().int().nonnegative()
1590
+ });
1591
+ var StreamReadParams = z3.object({
1592
+ sessionId: z3.string(),
1593
+ cursor: z3.number().int().nonnegative(),
1594
+ // Cap on bytes returned. Server enforces a hard ceiling (STREAM_READ_MAX_BYTES,
1595
+ // currently 64 KiB) even when the caller asks for more.
1596
+ maxBytes: z3.number().int().positive().optional(),
1597
+ // Long-poll timeout in ms. 0 / omitted returns immediately with
1598
+ // whatever's available (possibly empty). Server cap 60s.
1599
+ waitMs: z3.number().int().nonnegative().optional()
1600
+ });
1601
+ var StreamReadResult = z3.object({
1602
+ // Base64-encoded bytes. Empty string when nothing new is available
1603
+ // and either waitMs was 0 or the long-poll expired without data.
1604
+ bytes: z3.string(),
1605
+ nextCursor: z3.number().int().nonnegative(),
1606
+ // Set when `cursor` pointed before the oldest still-resident byte —
1607
+ // value is the count of bytes that were evicted between the caller's
1608
+ // cursor and what we still have.
1609
+ gap: z3.number().int().nonnegative().optional(),
1610
+ // True when the producer has closed AND there are no more bytes
1611
+ // after nextCursor.
1612
+ eof: z3.boolean().optional()
1613
+ });
1521
1614
  var AgentInstallProgressParams = z3.object({
1522
1615
  agentId: z3.string(),
1523
1616
  version: z3.string(),
@@ -1923,6 +2016,264 @@ import { customAlphabet as customAlphabet3 } from "nanoid";
1923
2016
  // src/core/session.ts
1924
2017
  import { customAlphabet } from "nanoid";
1925
2018
 
2019
+ // src/core/stream-buffer.ts
2020
+ import * as fsp3 from "fs/promises";
2021
+ var DEFAULT_CAPACITY_BYTES = 16 * 1024 * 1024;
2022
+ var STREAM_READ_MAX_BYTES = 64 * 1024;
2023
+ var STREAM_WAIT_MAX_MS = 6e4;
2024
+ var SessionStreamBuffer = class {
2025
+ storage;
2026
+ capacityBytes;
2027
+ // Absolute monotonic byte offset of the next byte to be written. Also
2028
+ // the count of bytes ever appended. `writeCursor - capacityBytes`
2029
+ // (clamped at 0) is the oldest still-resident byte's cursor.
2030
+ writeCursor = 0;
2031
+ closed = false;
2032
+ waiters = [];
2033
+ filePath;
2034
+ fileCapBytes;
2035
+ fileBytesWritten = 0;
2036
+ fileCapReached = false;
2037
+ onFileCapReached;
2038
+ logWriteError;
2039
+ // Single-flight chain for file appends so concurrent stream_write
2040
+ // calls don't interleave their writes.
2041
+ fileWriteChain = Promise.resolve();
2042
+ constructor(opts = {}) {
2043
+ this.capacityBytes = opts.capacityBytes ?? DEFAULT_CAPACITY_BYTES;
2044
+ if (this.capacityBytes <= 0) {
2045
+ throw new Error("capacityBytes must be > 0");
2046
+ }
2047
+ this.storage = Buffer.alloc(this.capacityBytes);
2048
+ this.filePath = opts.filePath;
2049
+ this.fileCapBytes = opts.fileCapBytes ?? Number.POSITIVE_INFINITY;
2050
+ this.onFileCapReached = opts.onFileCapReached;
2051
+ this.logWriteError = opts.logWriteError;
2052
+ }
2053
+ get capacity() {
2054
+ return this.capacityBytes;
2055
+ }
2056
+ get writeCursorPos() {
2057
+ return this.writeCursor;
2058
+ }
2059
+ get oldestAvailable() {
2060
+ return Math.max(0, this.writeCursor - this.capacityBytes);
2061
+ }
2062
+ get isClosed() {
2063
+ return this.closed;
2064
+ }
2065
+ // Append-or-noop. Calls after close() are silently dropped (the
2066
+ // producer ought not to keep writing, but it's not worth throwing if
2067
+ // a chunk arrives late).
2068
+ append(chunk) {
2069
+ if (this.closed || chunk.length === 0) {
2070
+ return;
2071
+ }
2072
+ this.writeRing(chunk);
2073
+ this.writeCursor += chunk.length;
2074
+ if (this.filePath !== void 0) {
2075
+ this.scheduleFileWrite(chunk);
2076
+ }
2077
+ this.wakeWaiters("data");
2078
+ }
2079
+ close() {
2080
+ if (this.closed) {
2081
+ return;
2082
+ }
2083
+ this.closed = true;
2084
+ this.wakeWaiters("eof");
2085
+ }
2086
+ // Read up to `maxBytes` bytes starting at `cursor`. If `cursor` is
2087
+ // behind the oldest still-resident byte, gap-skip to the oldest and
2088
+ // report how many bytes were dropped. If `cursor` is at writeCursor
2089
+ // and the buffer is closed, return eof:true.
2090
+ read(cursor, maxBytes) {
2091
+ const cap = Math.max(0, Math.min(maxBytes, STREAM_READ_MAX_BYTES));
2092
+ if (cap === 0) {
2093
+ const tail = {
2094
+ bytes: Buffer.alloc(0),
2095
+ nextCursor: cursor
2096
+ };
2097
+ if (this.closed && cursor >= this.writeCursor) {
2098
+ tail.eof = true;
2099
+ }
2100
+ return tail;
2101
+ }
2102
+ let from = cursor;
2103
+ let gap = 0;
2104
+ const oldest = this.oldestAvailable;
2105
+ if (from < oldest) {
2106
+ gap = oldest - from;
2107
+ from = oldest;
2108
+ }
2109
+ const available = this.writeCursor - from;
2110
+ if (available <= 0) {
2111
+ const result2 = {
2112
+ bytes: Buffer.alloc(0),
2113
+ nextCursor: from
2114
+ };
2115
+ if (gap > 0) {
2116
+ result2.gap = gap;
2117
+ }
2118
+ if (this.closed) {
2119
+ result2.eof = true;
2120
+ }
2121
+ return result2;
2122
+ }
2123
+ const take = Math.min(available, cap);
2124
+ const bytes = this.sliceFromRing(from, take);
2125
+ const result = {
2126
+ bytes,
2127
+ nextCursor: from + take
2128
+ };
2129
+ if (gap > 0) {
2130
+ result.gap = gap;
2131
+ }
2132
+ if (this.closed && from + take >= this.writeCursor) {
2133
+ result.eof = true;
2134
+ }
2135
+ return result;
2136
+ }
2137
+ // Latest N bytes from the tail, capped at capacity / STREAM_READ_MAX_BYTES.
2138
+ // truncated:true when the requested span extends past the oldest
2139
+ // still-resident byte (i.e. there was more upstream that we don't have
2140
+ // anymore).
2141
+ tail(bytes) {
2142
+ const want = Math.max(0, Math.min(bytes, STREAM_READ_MAX_BYTES));
2143
+ const oldest = this.oldestAvailable;
2144
+ const startWant = this.writeCursor - want;
2145
+ const start = Math.max(oldest, startWant);
2146
+ const truncated = startWant < oldest;
2147
+ const slice = this.sliceFromRing(start, this.writeCursor - start);
2148
+ return {
2149
+ bytes: slice,
2150
+ startCursor: start,
2151
+ endCursor: this.writeCursor,
2152
+ truncated
2153
+ };
2154
+ }
2155
+ // First N bytes since the stream began. Returns truncated:true when
2156
+ // the head has already been evicted (cursor 0 is no longer resident).
2157
+ head(bytes) {
2158
+ const want = Math.max(0, Math.min(bytes, STREAM_READ_MAX_BYTES));
2159
+ const oldest = this.oldestAvailable;
2160
+ const truncated = oldest > 0;
2161
+ const start = oldest;
2162
+ const end = Math.min(this.writeCursor, start + want);
2163
+ const slice = this.sliceFromRing(start, end - start);
2164
+ return {
2165
+ bytes: slice,
2166
+ startCursor: start,
2167
+ endCursor: end,
2168
+ truncated
2169
+ };
2170
+ }
2171
+ // Long-poll until new bytes arrive past `cursor`, the buffer closes, or
2172
+ // the timeout expires. Resolves with "data" / "eof" / "timeout".
2173
+ waitForData(cursor, timeoutMs) {
2174
+ if (cursor < this.writeCursor) {
2175
+ return Promise.resolve("data");
2176
+ }
2177
+ if (this.closed) {
2178
+ return Promise.resolve("eof");
2179
+ }
2180
+ const cap = Math.max(0, Math.min(timeoutMs, STREAM_WAIT_MAX_MS));
2181
+ if (cap === 0) {
2182
+ return Promise.resolve("timeout");
2183
+ }
2184
+ return new Promise((resolve3) => {
2185
+ const waiter = {
2186
+ resolve: (outcome) => {
2187
+ if (waiter.timer !== void 0) {
2188
+ clearTimeout(waiter.timer);
2189
+ waiter.timer = void 0;
2190
+ }
2191
+ resolve3(outcome);
2192
+ },
2193
+ timer: setTimeout(() => {
2194
+ const idx = this.waiters.indexOf(waiter);
2195
+ if (idx >= 0) {
2196
+ this.waiters.splice(idx, 1);
2197
+ }
2198
+ waiter.timer = void 0;
2199
+ resolve3("timeout");
2200
+ }, cap)
2201
+ };
2202
+ this.waiters.push(waiter);
2203
+ });
2204
+ }
2205
+ wakeWaiters(outcome) {
2206
+ if (this.waiters.length === 0) {
2207
+ return;
2208
+ }
2209
+ const wake = this.waiters;
2210
+ this.waiters = [];
2211
+ for (const w of wake) {
2212
+ w.resolve(outcome);
2213
+ }
2214
+ }
2215
+ writeRing(chunk) {
2216
+ const len = chunk.length;
2217
+ if (len >= this.capacityBytes) {
2218
+ const tailStart = len - this.capacityBytes;
2219
+ chunk.copy(this.storage, 0, tailStart, len);
2220
+ return;
2221
+ }
2222
+ const offset = this.writeCursor % this.capacityBytes;
2223
+ const tailRoom = this.capacityBytes - offset;
2224
+ if (len <= tailRoom) {
2225
+ chunk.copy(this.storage, offset, 0, len);
2226
+ } else {
2227
+ chunk.copy(this.storage, offset, 0, tailRoom);
2228
+ chunk.copy(this.storage, 0, tailRoom, len);
2229
+ }
2230
+ }
2231
+ sliceFromRing(fromCursor, length) {
2232
+ if (length <= 0) {
2233
+ return Buffer.alloc(0);
2234
+ }
2235
+ const out = Buffer.alloc(length);
2236
+ const offset = fromCursor % this.capacityBytes;
2237
+ const tailLen = Math.min(length, this.capacityBytes - offset);
2238
+ this.storage.copy(out, 0, offset, offset + tailLen);
2239
+ if (tailLen < length) {
2240
+ this.storage.copy(out, tailLen, 0, length - tailLen);
2241
+ }
2242
+ return out;
2243
+ }
2244
+ scheduleFileWrite(chunk) {
2245
+ const path13 = this.filePath;
2246
+ if (path13 === void 0) {
2247
+ return;
2248
+ }
2249
+ if (this.fileCapReached) {
2250
+ return;
2251
+ }
2252
+ const remaining = this.fileCapBytes - this.fileBytesWritten;
2253
+ if (remaining <= 0) {
2254
+ this.fileCapReached = true;
2255
+ this.onFileCapReached?.();
2256
+ return;
2257
+ }
2258
+ const slice = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
2259
+ this.fileBytesWritten += slice.length;
2260
+ const willHitCap = this.fileBytesWritten >= this.fileCapBytes;
2261
+ this.fileWriteChain = this.fileWriteChain.then(() => fsp3.appendFile(path13, slice)).catch((err) => {
2262
+ this.logWriteError?.(err);
2263
+ });
2264
+ if (willHitCap && !this.fileCapReached) {
2265
+ this.fileCapReached = true;
2266
+ this.onFileCapReached?.();
2267
+ }
2268
+ }
2269
+ // Wait for any pending file appends to flush. Used by tests and by
2270
+ // session close handlers that want to ensure the file is durable
2271
+ // before unlinking.
2272
+ async drainFileWrites() {
2273
+ await this.fileWriteChain.catch(() => void 0);
2274
+ }
2275
+ };
2276
+
1926
2277
  // src/core/hydra-commands.ts
1927
2278
  var HYDRA_COMMANDS = [
1928
2279
  {
@@ -1940,6 +2291,11 @@ var HYDRA_COMMANDS = [
1940
2291
  verb: "kill",
1941
2292
  name: "hydra kill",
1942
2293
  description: "Close this session (kills the agent; record is kept so it can be resumed later)"
2294
+ },
2295
+ {
2296
+ verb: "restart",
2297
+ name: "hydra restart",
2298
+ description: "Restart the agent with a fresh session/new while preserving conversation history (useful when the proxy has changed available models)"
1943
2299
  }
1944
2300
  ];
1945
2301
  var VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
@@ -1992,8 +2348,10 @@ async function deleteQueue(sessionId) {
1992
2348
  }
1993
2349
 
1994
2350
  // src/core/session.ts
2351
+ import * as fsp4 from "fs/promises";
1995
2352
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1996
2353
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
2354
+ var generateChainToken = customAlphabet(HYDRA_ID_ALPHABET, 16);
1997
2355
  var HYDRA_SESSION_PREFIX = "hydra_session_";
1998
2356
  function generateMessageId() {
1999
2357
  return `m_${generateHydraId()}`;
@@ -2002,6 +2360,7 @@ function stripHydraSessionPrefix(id) {
2002
2360
  return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
2003
2361
  }
2004
2362
  var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
2363
+ var TRANSFORMER_CLAIM_TIMEOUT_MS = 5 * 60 * 1e3;
2005
2364
  var RECENTLY_TERMINAL_LIMIT = 64;
2006
2365
  var Session = class {
2007
2366
  sessionId;
@@ -2014,14 +2373,18 @@ var Session = class {
2014
2373
  agent;
2015
2374
  upstreamSessionId;
2016
2375
  agentMeta;
2376
+ agentCapabilities;
2017
2377
  agentArgs;
2378
+ parentSessionId;
2018
2379
  title;
2019
2380
  // Snapshot state delivered to attaching clients via the attach
2020
2381
  // response _meta rather than via history replay (which would be
2021
2382
  // stale-prone for snapshot-shaped events).
2022
2383
  currentModel;
2023
2384
  currentMode;
2024
- currentUsage;
2385
+ // Raw per-agent-life usage. Never read directly outside this class —
2386
+ // always access via the currentUsage getter which adds cumulativeCost.
2387
+ _currentUsage;
2025
2388
  updatedAt;
2026
2389
  createdAt;
2027
2390
  clients = /* @__PURE__ */ new Map();
@@ -2076,6 +2439,10 @@ var Session = class {
2076
2439
  internalPromptCapture;
2077
2440
  idleTimeoutMs;
2078
2441
  idleTimer;
2442
+ // Separate timer that fires session.idle to the transformer chain after
2443
+ // a quiet period. Distinct from idleTimer, which drives session close.
2444
+ idleEventTimer;
2445
+ idleEventTimeoutMs;
2079
2446
  // Time of the last recordable broadcast (or session creation, if
2080
2447
  // none yet). Drives the inactivity-based idle close; deliberately
2081
2448
  // does NOT include snapshot state pings (model/mode/title/commands)
@@ -2083,7 +2450,11 @@ var Session = class {
2083
2450
  // and noisy state churn keep a quiet session alive forever.
2084
2451
  lastRecordedAt;
2085
2452
  spawnReplacementAgent;
2453
+ listSessions;
2086
2454
  logger;
2455
+ transformChain;
2456
+ // Outstanding "processing" claims: token → claim waiting for respondsTo discharge.
2457
+ pendingClaims = /* @__PURE__ */ new Map();
2087
2458
  agentChangeHandlers = [];
2088
2459
  // Last available_commands_update we observed from the agent. Stored
2089
2460
  // so we can re-broadcast a merged (hydra ∪ agent) list whenever
@@ -2110,6 +2481,24 @@ var Session = class {
2110
2481
  modelHandlers = [];
2111
2482
  modeHandlers = [];
2112
2483
  usageHandlers = [];
2484
+ cumulativeCost = 0;
2485
+ // Total cost across all agent lives. costAmount in the returned snapshot
2486
+ // is cumulativeCost + the current agent's raw amount so every consumer
2487
+ // gets the right figure without knowing about the internal split.
2488
+ // cumulativeCost is stripped from the return value so it never leaks
2489
+ // into persistence paths via session.currentUsage.
2490
+ get currentUsage() {
2491
+ if (!this._currentUsage && !this.cumulativeCost) {
2492
+ return void 0;
2493
+ }
2494
+ const base = this._currentUsage ?? {};
2495
+ const total = this.cumulativeCost + (base.costAmount ?? 0);
2496
+ return {
2497
+ ...base,
2498
+ costAmount: total || void 0,
2499
+ cumulativeCost: void 0
2500
+ };
2501
+ }
2113
2502
  // Set by amendPrompt at the start of a cancel-and-resubmit dance.
2114
2503
  // broadcastTurnComplete reads it to attach the _meta.amended marker
2115
2504
  // to the cancelled turn's turn_complete notification, and to fire the
@@ -2124,6 +2513,11 @@ var Session = class {
2124
2513
  // older entries fall out and resolve to target_not_found, which is
2125
2514
  // the correct behavior.
2126
2515
  recentlyTerminal = /* @__PURE__ */ new Map();
2516
+ // Optional ring buffer for piped stdin, populated by openStream() when
2517
+ // a cat --stream session attaches. Lifecycle follows the session — the
2518
+ // markClosed path closes the buffer and unlinks any file projection.
2519
+ streamBuffer;
2520
+ streamFilePath;
2127
2521
  constructor(init) {
2128
2522
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
2129
2523
  this.cwd = init.cwd;
@@ -2131,11 +2525,14 @@ var Session = class {
2131
2525
  this.agent = init.agent;
2132
2526
  this.upstreamSessionId = init.upstreamSessionId;
2133
2527
  this.agentMeta = init.agentMeta;
2528
+ this.agentCapabilities = init.agentCapabilities;
2134
2529
  this.agentArgs = init.agentArgs;
2530
+ this.parentSessionId = init.parentSessionId;
2135
2531
  this.title = init.title;
2136
2532
  this.currentModel = init.currentModel;
2137
2533
  this.currentMode = init.currentMode;
2138
- this.currentUsage = init.currentUsage;
2534
+ this._currentUsage = init.currentUsage;
2535
+ this.cumulativeCost = init.currentUsage?.cumulativeCost ?? 0;
2139
2536
  if (init.agentCommands && init.agentCommands.length > 0) {
2140
2537
  this.agentAdvertisedCommands = [...init.agentCommands];
2141
2538
  }
@@ -2146,8 +2543,11 @@ var Session = class {
2146
2543
  this.agentAdvertisedModels = [...init.agentModels];
2147
2544
  }
2148
2545
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
2546
+ this.idleEventTimeoutMs = init.idleEventTimeoutMs ?? 3e4;
2149
2547
  this.spawnReplacementAgent = init.spawnReplacementAgent;
2548
+ this.listSessions = init.listSessions;
2150
2549
  this.logger = init.logger;
2550
+ this.transformChain = init.transformChain ?? [];
2151
2551
  if (init.firstPromptSeeded) {
2152
2552
  this.firstPromptSeeded = true;
2153
2553
  }
@@ -2159,10 +2559,14 @@ var Session = class {
2159
2559
  this.lastRecordedAt = this.updatedAt;
2160
2560
  this.wireAgent(this.agent);
2161
2561
  this.scheduleIdleCheck();
2562
+ this.notifyChain("session.opened", {});
2162
2563
  }
2163
2564
  broadcastMergedCommands() {
2164
2565
  const merged = [
2165
2566
  ...hydraCommandsAsAdvertised(),
2567
+ { name: "model <model-id>", description: "Switch model; omit arg to list available models" },
2568
+ { name: "sessions", description: "List all sessions" },
2569
+ { name: "help", description: "Show available commands" },
2166
2570
  ...this.agentAdvertisedCommands
2167
2571
  ];
2168
2572
  this.recordAndBroadcast("session/update", {
@@ -2211,34 +2615,7 @@ var Session = class {
2211
2615
  captureInternalChunk(this.internalPromptCapture, params);
2212
2616
  return;
2213
2617
  }
2214
- const agentCmds = extractAdvertisedCommands(params);
2215
- if (agentCmds !== null) {
2216
- this.setAgentAdvertisedCommands(agentCmds);
2217
- return;
2218
- }
2219
- const agentModes = extractAdvertisedModes(params);
2220
- if (agentModes !== null) {
2221
- this.setAgentAdvertisedModes(agentModes);
2222
- return;
2223
- }
2224
- if (this.maybeApplyAgentModel(params)) {
2225
- this.recordAndBroadcast("session/update", params);
2226
- return;
2227
- }
2228
- if (this.maybeApplyAgentMode(params)) {
2229
- this.recordAndBroadcast("session/update", params);
2230
- return;
2231
- }
2232
- if (this.maybeApplyAgentConfigOption(params)) {
2233
- this.recordAndBroadcast("session/update", params);
2234
- return;
2235
- }
2236
- if (this.maybeApplyAgentUsage(params)) {
2237
- this.recordAndBroadcast("session/update", params);
2238
- return;
2239
- }
2240
- this.maybeApplyAgentSessionInfo(params);
2241
- this.recordAndBroadcast("session/update", params);
2618
+ void this.runResponseChain(params);
2242
2619
  });
2243
2620
  agent.connection.onRequest("session/request_permission", async (params) => {
2244
2621
  return this.handlePermissionRequest(params);
@@ -2250,6 +2627,106 @@ var Session = class {
2250
2627
  this.markClosed({ deleteRecord: false });
2251
2628
  });
2252
2629
  }
2630
+ // Runs the response-side transformer chain, then the snapshot interceptors,
2631
+ // then recordAndBroadcast. All state mutation happens after the chain exits.
2632
+ // See forwardRequest for originatedBy / startIdx semantics.
2633
+ async runResponseChain(params, originatedBy = /* @__PURE__ */ new Set(), startIdx = 0) {
2634
+ const rawParams = params;
2635
+ let envelope = this.injectCumulativeCost(params);
2636
+ for (let i = startIdx; i < this.transformChain.length; i++) {
2637
+ const t = this.transformChain[i];
2638
+ if (originatedBy.has(t.name)) {
2639
+ continue;
2640
+ }
2641
+ if (!t.intercepts.has("response:session/update")) {
2642
+ continue;
2643
+ }
2644
+ const token = `t_${generateChainToken()}`;
2645
+ let result;
2646
+ try {
2647
+ result = await t.connection.request("transformer/message", {
2648
+ token,
2649
+ phase: "response",
2650
+ method: "session/update",
2651
+ direction: "agent\u2192client",
2652
+ sessionId: this.sessionId,
2653
+ envelope
2654
+ });
2655
+ } catch (err) {
2656
+ this.logger?.warn(
2657
+ `transformer ${t.name} error on response:session/update: ${err.message}`
2658
+ );
2659
+ continue;
2660
+ }
2661
+ const action = result?.action ?? "continue";
2662
+ if (action === "stop") {
2663
+ return;
2664
+ }
2665
+ if (action === "processing") {
2666
+ const claimIdx = i;
2667
+ const claimEnvelope = envelope;
2668
+ const claimOriginatedBy = new Set(originatedBy);
2669
+ await new Promise((resolve3) => {
2670
+ const timer = setTimeout(() => {
2671
+ if (this.pendingClaims.delete(token)) {
2672
+ this.broadcastQueueNotification(
2673
+ "hydra-acp/transformer_abandoned_request",
2674
+ { sessionId: this.sessionId, token, transformerName: t.name }
2675
+ );
2676
+ void this.runResponseChain(
2677
+ claimEnvelope,
2678
+ /* @__PURE__ */ new Set([...claimOriginatedBy, t.name]),
2679
+ claimIdx + 1
2680
+ ).then(resolve3);
2681
+ }
2682
+ }, TRANSFORMER_CLAIM_TIMEOUT_MS);
2683
+ if (typeof timer.unref === "function") {
2684
+ timer.unref();
2685
+ }
2686
+ this.pendingClaims.set(token, {
2687
+ resolve: () => resolve3(),
2688
+ timer,
2689
+ transformerName: t.name,
2690
+ method: "session/update",
2691
+ envelope: claimEnvelope,
2692
+ chainIdx: claimIdx,
2693
+ originatedBy: claimOriginatedBy,
2694
+ side: "response"
2695
+ });
2696
+ });
2697
+ return;
2698
+ }
2699
+ originatedBy.add(t.name);
2700
+ }
2701
+ const agentCmds = extractAdvertisedCommands(envelope);
2702
+ if (agentCmds !== null) {
2703
+ this.setAgentAdvertisedCommands(agentCmds);
2704
+ return;
2705
+ }
2706
+ const agentModes = extractAdvertisedModes(envelope);
2707
+ if (agentModes !== null) {
2708
+ this.setAgentAdvertisedModes(agentModes);
2709
+ return;
2710
+ }
2711
+ if (this.maybeApplyAgentModel(envelope)) {
2712
+ this.recordAndBroadcast("session/update", envelope);
2713
+ return;
2714
+ }
2715
+ if (this.maybeApplyAgentMode(envelope)) {
2716
+ this.recordAndBroadcast("session/update", envelope);
2717
+ return;
2718
+ }
2719
+ if (this.maybeApplyAgentConfigOption(envelope)) {
2720
+ this.recordAndBroadcast("session/update", envelope);
2721
+ return;
2722
+ }
2723
+ if (this.maybeApplyAgentUsage(rawParams)) {
2724
+ this.recordAndBroadcast("session/update", envelope);
2725
+ return;
2726
+ }
2727
+ this.maybeApplyAgentSessionInfo(envelope);
2728
+ this.recordAndBroadcast("session/update", envelope);
2729
+ }
2253
2730
  onAgentChange(handler) {
2254
2731
  this.agentChangeHandlers.push(handler);
2255
2732
  }
@@ -2990,8 +3467,159 @@ var Session = class {
2990
3467
  sessionId: this.upstreamSessionId
2991
3468
  });
2992
3469
  }
2993
- async forwardRequest(method, params) {
2994
- return this.agent.connection.request(method, this.rewriteForAgent(params));
3470
+ // Add a transformer to this session's chain retroactively. No-ops if it's
3471
+ // already present. Fires session.opened on the new transformer so it gets
3472
+ // the same lifecycle signal it would have received at session creation.
3473
+ addTransformer(ref) {
3474
+ const existing = this.transformChain.findIndex((t) => t.name === ref.name);
3475
+ if (existing >= 0) {
3476
+ this.transformChain[existing] = ref;
3477
+ } else {
3478
+ this.transformChain.push(ref);
3479
+ }
3480
+ if (ref.intercepts.has("lifecycle:session.opened")) {
3481
+ void ref.connection.notify("transformer/session_event", {
3482
+ event: "session.opened",
3483
+ sessionId: this.sessionId
3484
+ }).catch(() => void 0);
3485
+ }
3486
+ }
3487
+ // Walk the request-side chain then forward to the agent.
3488
+ // originatedBy: transformer names already in the lineage — skipped for loop
3489
+ // prevention and to implement resume-routing on re-entry from emit_message.
3490
+ // startIdx: chain position to start from (0 for normal, emitterIdx+1 for re-entry).
3491
+ async forwardRequest(method, params, originatedBy = /* @__PURE__ */ new Set(), startIdx = 0) {
3492
+ let envelope = this.rewriteForAgent(params);
3493
+ for (let i = startIdx; i < this.transformChain.length; i++) {
3494
+ const t = this.transformChain[i];
3495
+ if (originatedBy.has(t.name)) {
3496
+ continue;
3497
+ }
3498
+ const intercept = `request:${method}`;
3499
+ if (!t.intercepts.has(intercept)) {
3500
+ continue;
3501
+ }
3502
+ const token = `t_${generateChainToken()}`;
3503
+ let result;
3504
+ try {
3505
+ result = await t.connection.request("transformer/message", {
3506
+ token,
3507
+ phase: "request",
3508
+ method,
3509
+ direction: "client\u2192agent",
3510
+ sessionId: this.sessionId,
3511
+ envelope
3512
+ });
3513
+ } catch (err) {
3514
+ this.logger?.warn(
3515
+ `transformer ${t.name} error on ${intercept}: ${err.message}`
3516
+ );
3517
+ continue;
3518
+ }
3519
+ const action = result?.action ?? "continue";
3520
+ if (action === "stop") {
3521
+ return result?.payload ?? defaultStopPayload(method);
3522
+ }
3523
+ if (action === "processing") {
3524
+ const claimIdx = i;
3525
+ const claimEnvelope = envelope;
3526
+ const claimOriginatedBy = new Set(originatedBy);
3527
+ return new Promise((resolve3) => {
3528
+ const timer = setTimeout(() => {
3529
+ if (this.pendingClaims.delete(token)) {
3530
+ this.broadcastQueueNotification(
3531
+ "hydra-acp/transformer_abandoned_request",
3532
+ { sessionId: this.sessionId, token, transformerName: t.name }
3533
+ );
3534
+ void this.forwardRequest(
3535
+ method,
3536
+ claimEnvelope,
3537
+ /* @__PURE__ */ new Set([...claimOriginatedBy, t.name]),
3538
+ claimIdx + 1
3539
+ ).then(resolve3).catch(() => resolve3(defaultStopPayload(method)));
3540
+ }
3541
+ }, TRANSFORMER_CLAIM_TIMEOUT_MS);
3542
+ if (typeof timer.unref === "function") {
3543
+ timer.unref();
3544
+ }
3545
+ this.pendingClaims.set(token, {
3546
+ resolve: resolve3,
3547
+ timer,
3548
+ transformerName: t.name,
3549
+ method,
3550
+ envelope: claimEnvelope,
3551
+ chainIdx: claimIdx,
3552
+ originatedBy: claimOriginatedBy,
3553
+ side: "request"
3554
+ });
3555
+ });
3556
+ }
3557
+ originatedBy.add(t.name);
3558
+ }
3559
+ return this.agent.connection.request(method, envelope);
3560
+ }
3561
+ // Called by the WS handler when emit_message carries respondsTo.
3562
+ // Discharges the outstanding claim so the original requester unblocks.
3563
+ dischargeClaim(token, result) {
3564
+ const claim = this.pendingClaims.get(token);
3565
+ if (!claim) {
3566
+ return false;
3567
+ }
3568
+ clearTimeout(claim.timer);
3569
+ this.pendingClaims.delete(token);
3570
+ claim.resolve(result);
3571
+ return true;
3572
+ }
3573
+ // Called by the WS handler on hydra-acp/keep_alive.
3574
+ // Resets the abandonment timer for an outstanding processing claim.
3575
+ keepAliveClaim(token, estimatedRemainingMs) {
3576
+ const claim = this.pendingClaims.get(token);
3577
+ if (!claim) {
3578
+ return false;
3579
+ }
3580
+ clearTimeout(claim.timer);
3581
+ const timeout = typeof estimatedRemainingMs === "number" && estimatedRemainingMs > 0 ? Math.min(estimatedRemainingMs * 1.5, 30 * 60 * 1e3) : TRANSFORMER_CLAIM_TIMEOUT_MS;
3582
+ const timer = setTimeout(() => {
3583
+ if (this.pendingClaims.delete(token)) {
3584
+ this.broadcastQueueNotification(
3585
+ "hydra-acp/transformer_abandoned_request",
3586
+ { sessionId: this.sessionId, token, transformerName: claim.transformerName }
3587
+ );
3588
+ if (claim.side === "response") {
3589
+ void this.runResponseChain(
3590
+ claim.envelope,
3591
+ /* @__PURE__ */ new Set([...claim.originatedBy, claim.transformerName]),
3592
+ claim.chainIdx + 1
3593
+ ).then(() => claim.resolve(void 0));
3594
+ } else {
3595
+ void this.forwardRequest(
3596
+ claim.method,
3597
+ claim.envelope,
3598
+ /* @__PURE__ */ new Set([...claim.originatedBy, claim.transformerName]),
3599
+ claim.chainIdx + 1
3600
+ ).then(claim.resolve).catch(() => claim.resolve(defaultStopPayload(claim.method)));
3601
+ }
3602
+ }
3603
+ }, timeout);
3604
+ if (typeof timer.unref === "function") {
3605
+ timer.unref();
3606
+ }
3607
+ claim.timer = timer;
3608
+ return true;
3609
+ }
3610
+ // Called by the WS handler when a transformer emits via route:"chain".
3611
+ // Finds the emitter's position and re-enters the appropriate chain walk
3612
+ // from the next slot, with the emitter in originatedBy so it cannot see
3613
+ // its own re-emission.
3614
+ async emitToChain(emitterName, method, envelope) {
3615
+ const emitterIdx = this.transformChain.findIndex((t) => t.name === emitterName);
3616
+ const startIdx = emitterIdx >= 0 ? emitterIdx + 1 : 0;
3617
+ const originatedBy = /* @__PURE__ */ new Set([emitterName]);
3618
+ if (method === "session/update") {
3619
+ await this.runResponseChain(envelope, originatedBy, startIdx);
3620
+ return;
3621
+ }
3622
+ await this.forwardRequest(method, envelope, originatedBy, startIdx);
2995
3623
  }
2996
3624
  rewriteForAgent(params) {
2997
3625
  if (params && typeof params === "object" && !Array.isArray(params)) {
@@ -3180,6 +3808,9 @@ var Session = class {
3180
3808
  if (!trimmed || trimmed === this.currentMode) {
3181
3809
  return true;
3182
3810
  }
3811
+ this.logger?.info(
3812
+ `current_mode_update: sessionId=${this.sessionId} ${JSON.stringify(this.currentMode)} \u2192 ${JSON.stringify(trimmed)}`
3813
+ );
3183
3814
  this.currentMode = trimmed;
3184
3815
  for (const handler of this.modeHandlers) {
3185
3816
  try {
@@ -3199,7 +3830,7 @@ var Session = class {
3199
3830
  if (update.sessionUpdate !== "usage_update") {
3200
3831
  return false;
3201
3832
  }
3202
- const next = { ...this.currentUsage ?? {} };
3833
+ const next = { ...this._currentUsage ?? {} };
3203
3834
  let changed = false;
3204
3835
  if (typeof update.used === "number" && next.used !== update.used) {
3205
3836
  next.used = update.used;
@@ -3223,15 +3854,68 @@ var Session = class {
3223
3854
  if (!changed) {
3224
3855
  return true;
3225
3856
  }
3226
- this.currentUsage = next;
3857
+ this._currentUsage = next;
3858
+ const total = this.currentUsage ?? next;
3227
3859
  for (const handler of this.usageHandlers) {
3228
3860
  try {
3229
- handler(next);
3861
+ handler(total);
3230
3862
  } catch {
3231
3863
  }
3232
3864
  }
3233
3865
  return true;
3234
3866
  }
3867
+ // Move currentUsage.costAmount into cumulativeCost and clear it so the
3868
+ // next agent life starts accumulating from $0. Fires usageHandlers so
3869
+ // meta.json is updated before the new agent starts emitting.
3870
+ accumulateAndResetCost() {
3871
+ const amount = this._currentUsage?.costAmount;
3872
+ if (!amount)
3873
+ return;
3874
+ this.cumulativeCost += amount;
3875
+ const next = {
3876
+ ...this._currentUsage ?? {},
3877
+ cumulativeCost: this.cumulativeCost,
3878
+ costAmount: void 0
3879
+ };
3880
+ this._currentUsage = next;
3881
+ for (const handler of this.usageHandlers) {
3882
+ try {
3883
+ handler(next);
3884
+ } catch {
3885
+ }
3886
+ }
3887
+ }
3888
+ // Returns a modified envelope with cost.amount replaced by the running
3889
+ // total (cumulativeCost + raw agent amount). No-op if the envelope
3890
+ // doesn't carry a numeric cost.amount.
3891
+ injectCumulativeCost(envelope) {
3892
+ if (!this.cumulativeCost)
3893
+ return envelope;
3894
+ if (!envelope || typeof envelope !== "object")
3895
+ return envelope;
3896
+ const obj = envelope;
3897
+ if (!obj.update || typeof obj.update !== "object")
3898
+ return envelope;
3899
+ const update = obj.update;
3900
+ if (update.sessionUpdate !== "usage_update")
3901
+ return envelope;
3902
+ if (!update.cost || typeof update.cost !== "object")
3903
+ return envelope;
3904
+ const cost = update.cost;
3905
+ if (typeof cost.amount !== "number")
3906
+ return envelope;
3907
+ return {
3908
+ ...obj,
3909
+ update: {
3910
+ ...update,
3911
+ cost: { ...cost, amount: this.cumulativeCost + cost.amount }
3912
+ }
3913
+ };
3914
+ }
3915
+ // Deprecated alias — currentUsage already returns the total.
3916
+ get totalUsage() {
3917
+ return this.currentUsage;
3918
+ }
3235
3919
  // Update the cached agent command list, fire persist handlers, and
3236
3920
  // broadcast the merged list to attached clients. Idempotent on a
3237
3921
  // structurally identical list so we don't churn meta.json on noisy
@@ -3296,6 +3980,26 @@ var Session = class {
3296
3980
  onModeChange(handler) {
3297
3981
  this.modeHandlers.push(handler);
3298
3982
  }
3983
+ // Apply a mode change initiated by a client request (session/set_mode)
3984
+ // when the agent doesn't emit a current_mode_update notification on its
3985
+ // own. Fires modeHandlers so the persistence hook and any other listeners
3986
+ // see the change, identical to the agent-notification path.
3987
+ applyModeChange(modeId) {
3988
+ const trimmed = modeId.trim();
3989
+ if (!trimmed || trimmed === this.currentMode) {
3990
+ return;
3991
+ }
3992
+ this.logger?.info(
3993
+ `applyModeChange: sessionId=${this.sessionId} ${JSON.stringify(this.currentMode)} \u2192 ${JSON.stringify(trimmed)}`
3994
+ );
3995
+ this.currentMode = trimmed;
3996
+ for (const handler of this.modeHandlers) {
3997
+ try {
3998
+ handler(trimmed);
3999
+ } catch {
4000
+ }
4001
+ }
4002
+ }
3299
4003
  onUsageChange(handler) {
3300
4004
  this.usageHandlers.push(handler);
3301
4005
  }
@@ -3378,6 +4082,8 @@ var Session = class {
3378
4082
  return this.runAgentCommand(arg);
3379
4083
  case "kill":
3380
4084
  return this.runKillCommand();
4085
+ case "restart":
4086
+ return this.runRestartCommand();
3381
4087
  default: {
3382
4088
  const err = new Error(
3383
4089
  `no dispatcher for /hydra verb ${verb}`
@@ -3387,6 +4093,92 @@ var Session = class {
3387
4093
  }
3388
4094
  }
3389
4095
  }
4096
+ async handleSessionsCommand() {
4097
+ let text;
4098
+ if (!this.listSessions) {
4099
+ text = "_(session listing not available)_";
4100
+ } else {
4101
+ const sessions = await this.listSessions();
4102
+ if (sessions.length === 0) {
4103
+ text = "_(no sessions)_";
4104
+ } else {
4105
+ const lines = sessions.map((s) => {
4106
+ const id = s.sessionId.replace(/^hydra_session_/, "").slice(0, 12);
4107
+ const model = s.currentModel ? ` \xB7 ${s.currentModel}` : "";
4108
+ const marker = s.sessionId === this.sessionId ? " \u25C0" : "";
4109
+ const title = s.title ? ` ${s.title}` : "";
4110
+ return `\`${id}\` ${s.cwd}${model}${marker}${title}`;
4111
+ });
4112
+ text = `Sessions (${sessions.length}):
4113
+ ${lines.join("\n")}`;
4114
+ }
4115
+ }
4116
+ this.recordAndBroadcast("session/update", {
4117
+ sessionId: this.upstreamSessionId,
4118
+ update: {
4119
+ sessionUpdate: "agent_message_chunk",
4120
+ content: { type: "text", text: `
4121
+ ${text}
4122
+ ` },
4123
+ _meta: { "hydra-acp": { synthetic: true } }
4124
+ }
4125
+ });
4126
+ return { stopReason: "end_turn" };
4127
+ }
4128
+ handleHelpCommand() {
4129
+ const commands = this.mergedAvailableCommands();
4130
+ const lines = commands.map((c) => {
4131
+ const desc = c.description ? ` ${c.description}` : "";
4132
+ return `\`/${c.name}\`${desc}`;
4133
+ });
4134
+ const text = lines.length > 0 ? `Available commands:
4135
+ ${lines.join("\n")}` : "_(no commands advertised)_";
4136
+ this.recordAndBroadcast("session/update", {
4137
+ sessionId: this.upstreamSessionId,
4138
+ update: {
4139
+ sessionUpdate: "agent_message_chunk",
4140
+ content: { type: "text", text: `
4141
+ ${text}
4142
+ ` },
4143
+ _meta: { "hydra-acp": { synthetic: true } }
4144
+ }
4145
+ });
4146
+ return Promise.resolve({ stopReason: "end_turn" });
4147
+ }
4148
+ async handleModelCommand(text) {
4149
+ const arg = text.slice("/model".length).trim();
4150
+ if (arg === "") {
4151
+ const models = this.agentAdvertisedModels;
4152
+ const current = this.currentModel;
4153
+ let body;
4154
+ if (models.length === 0) {
4155
+ body = current ? `Current model: ${current}` : "_(no models advertised yet)_";
4156
+ } else {
4157
+ const lines = models.map((m) => {
4158
+ const marker = m.modelId === current ? " \u25C0" : "";
4159
+ const desc = m.name && m.name !== m.modelId ? ` ${m.name}` : "";
4160
+ return `${m.modelId}${marker}${desc}`;
4161
+ });
4162
+ body = lines.join("\n");
4163
+ }
4164
+ this.recordAndBroadcast("session/update", {
4165
+ sessionId: this.upstreamSessionId,
4166
+ update: {
4167
+ sessionUpdate: "agent_message_chunk",
4168
+ content: { type: "text", text: `
4169
+ ${body}
4170
+ ` },
4171
+ _meta: { "hydra-acp": { synthetic: true } }
4172
+ }
4173
+ });
4174
+ return { stopReason: "end_turn" };
4175
+ }
4176
+ await this.forwardRequest("session/set_model", {
4177
+ sessionId: this.sessionId,
4178
+ modelId: arg
4179
+ });
4180
+ return { stopReason: "end_turn" };
4181
+ }
3390
4182
  // Runs as a normal queued prompt (so it serializes after any in-flight
3391
4183
  // turn). With an arg, sets the title directly. Without one, runs a
3392
4184
  // suppressed sub-prompt to the agent and uses its reply as the title.
@@ -3463,12 +4255,14 @@ var Session = class {
3463
4255
  cwd: this.cwd,
3464
4256
  agentArgs: this.agentArgs
3465
4257
  });
4258
+ this.accumulateAndResetCost();
3466
4259
  this.wireAgent(fresh.agent);
3467
4260
  const oldAgent = this.agent;
3468
4261
  this.agent = fresh.agent;
3469
4262
  this.agentId = newAgentId;
3470
4263
  this.upstreamSessionId = fresh.upstreamSessionId;
3471
4264
  this.agentMeta = fresh.agentMeta;
4265
+ this.agentCapabilities = fresh.agentCapabilities;
3472
4266
  this.agentAdvertisedCommands = [];
3473
4267
  this.broadcastMergedCommands();
3474
4268
  if (this.agentAdvertisedModels.length > 0) {
@@ -3506,21 +4300,72 @@ var Session = class {
3506
4300
  await this.close({ deleteRecord: false });
3507
4301
  return { stopReason: "end_turn" };
3508
4302
  }
3509
- // Walk the persisted history and produce a labeled transcript suitable
3510
- // for handing to a fresh agent. Includes user prompts, agent replies,
3511
- // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
3512
- // switches don't accumulate banners) and other update kinds we don't
3513
- // think the next agent benefits from re-seeing (plans, thoughts,
3514
- // mode/model/usage).
3515
- //
3516
- // The header text defaults to the agent-swap framing; callers like
3517
- // seedFromImport pass a custom header when the new agent is taking
3518
- // over an imported session rather than swapping mid-conversation.
3519
- async buildSwitchTranscript(prevAgentId, headerOverride) {
3520
- const lines = [];
3521
- const history = await this.getHistorySnapshot();
3522
- for (const note of history) {
3523
- if (note.method !== "session/update") {
4303
+ // Restart the underlying agent with a fresh session/new, preserving the
4304
+ // conversation history. Unlike /hydra agent, this allows the same agentId
4305
+ // useful when the proxy has changed available models (e.g. opus became
4306
+ // available after a quota reset) and the resumed session is locked to a
4307
+ // stale model list.
4308
+ runRestartCommand() {
4309
+ if (!this.spawnReplacementAgent) {
4310
+ throw withCode(
4311
+ new Error("agent restart not configured for this session"),
4312
+ JsonRpcErrorCodes.InternalError
4313
+ );
4314
+ }
4315
+ const spawnAgent = this.spawnReplacementAgent;
4316
+ const agentId = this.agentId;
4317
+ return this.enqueuePrompt(async () => {
4318
+ const transcript = await this.buildSwitchTranscript(agentId);
4319
+ const fresh = await spawnAgent({
4320
+ agentId,
4321
+ cwd: this.cwd,
4322
+ agentArgs: this.agentArgs
4323
+ });
4324
+ this.accumulateAndResetCost();
4325
+ this.wireAgent(fresh.agent);
4326
+ const oldAgent = this.agent;
4327
+ this.agent = fresh.agent;
4328
+ this.upstreamSessionId = fresh.upstreamSessionId;
4329
+ this.agentMeta = fresh.agentMeta;
4330
+ this.agentCapabilities = fresh.agentCapabilities;
4331
+ this.agentAdvertisedCommands = [];
4332
+ this.broadcastMergedCommands();
4333
+ if (this.agentAdvertisedModels.length > 0) {
4334
+ this.setAgentAdvertisedModels([]);
4335
+ }
4336
+ if (this.agentAdvertisedModes.length > 0) {
4337
+ this.setAgentAdvertisedModes([]);
4338
+ }
4339
+ await oldAgent.kill().catch(() => void 0);
4340
+ if (transcript) {
4341
+ await this.runInternalPrompt(transcript).catch(() => void 0);
4342
+ }
4343
+ this.broadcastAgentSwitch(agentId, agentId);
4344
+ const info = { agentId, upstreamSessionId: this.upstreamSessionId };
4345
+ for (const handler of this.agentChangeHandlers) {
4346
+ try {
4347
+ handler(info);
4348
+ } catch {
4349
+ }
4350
+ }
4351
+ return { stopReason: "end_turn" };
4352
+ });
4353
+ }
4354
+ // Walk the persisted history and produce a labeled transcript suitable
4355
+ // for handing to a fresh agent. Includes user prompts, agent replies,
4356
+ // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
4357
+ // switches don't accumulate banners) and other update kinds we don't
4358
+ // think the next agent benefits from re-seeing (plans, thoughts,
4359
+ // mode/model/usage).
4360
+ //
4361
+ // The header text defaults to the agent-swap framing; callers like
4362
+ // seedFromImport pass a custom header when the new agent is taking
4363
+ // over an imported session rather than swapping mid-conversation.
4364
+ async buildSwitchTranscript(prevAgentId, headerOverride) {
4365
+ const lines = [];
4366
+ const history = await this.getHistorySnapshot();
4367
+ for (const note of history) {
4368
+ if (note.method !== "session/update") {
3524
4369
  continue;
3525
4370
  }
3526
4371
  const params = note.params ?? {};
@@ -3636,12 +4481,120 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3636
4481
  }
3637
4482
  });
3638
4483
  }
4484
+ // stdin-stream lifecycle. Cat --stream calls openStream() once after
4485
+ // session/new, then forwards stdin chunks via streamWrite(). The agent
4486
+ // either consumes via the file path (when shell tools are available)
4487
+ // or — once the MCP surface lands — via tool calls that route through
4488
+ // streamRead() / streamTail() / streamHead() / streamWaitFor().
4489
+ hasStreamBuffer() {
4490
+ return this.streamBuffer !== void 0;
4491
+ }
4492
+ get streamPath() {
4493
+ return this.streamFilePath;
4494
+ }
4495
+ openStream(opts) {
4496
+ if (this.streamBuffer !== void 0) {
4497
+ throw new Error(
4498
+ `stream buffer already open for session ${this.sessionId}`
4499
+ );
4500
+ }
4501
+ const mode = opts.mode ?? "memory";
4502
+ const filePath = mode === "file" && opts.filePathFor !== void 0 ? opts.filePathFor(this.sessionId) : void 0;
4503
+ const bufferOpts = {};
4504
+ if (opts.capacityBytes !== void 0) {
4505
+ bufferOpts.capacityBytes = opts.capacityBytes;
4506
+ }
4507
+ if (filePath !== void 0) {
4508
+ bufferOpts.filePath = filePath;
4509
+ }
4510
+ if (opts.fileCapBytes !== void 0) {
4511
+ bufferOpts.fileCapBytes = opts.fileCapBytes;
4512
+ }
4513
+ bufferOpts.onFileCapReached = () => {
4514
+ this.recordAndBroadcast("session/update", {
4515
+ sessionId: this.upstreamSessionId,
4516
+ update: {
4517
+ sessionUpdate: "stream_truncated",
4518
+ ...filePath !== void 0 ? { filePath } : {},
4519
+ fileCapBytes: opts.fileCapBytes
4520
+ }
4521
+ });
4522
+ };
4523
+ const buf = new SessionStreamBuffer(bufferOpts);
4524
+ this.streamBuffer = buf;
4525
+ this.streamFilePath = filePath;
4526
+ const result = {
4527
+ capacityBytes: buf.capacity
4528
+ };
4529
+ if (filePath !== void 0) {
4530
+ result.filePath = filePath;
4531
+ }
4532
+ if (opts.fileCapBytes !== void 0) {
4533
+ result.fileCapBytes = opts.fileCapBytes;
4534
+ }
4535
+ return result;
4536
+ }
4537
+ streamWrite(chunkB64, eof) {
4538
+ const buf = this.requireStreamBuffer();
4539
+ if (chunkB64.length > 0) {
4540
+ const chunk = Buffer.from(chunkB64, "base64");
4541
+ buf.append(chunk);
4542
+ }
4543
+ if (eof === true) {
4544
+ buf.close();
4545
+ }
4546
+ return { writeCursor: buf.writeCursorPos };
4547
+ }
4548
+ async streamRead(cursor, maxBytes, waitMs) {
4549
+ const buf = this.requireStreamBuffer();
4550
+ const cap = Math.max(
4551
+ 0,
4552
+ Math.min(maxBytes ?? STREAM_READ_MAX_BYTES, STREAM_READ_MAX_BYTES)
4553
+ );
4554
+ let r = buf.read(cursor, cap);
4555
+ if (r.bytes.length === 0 && r.eof !== true && waitMs !== void 0 && waitMs > 0) {
4556
+ const outcome = await buf.waitForData(r.nextCursor, waitMs);
4557
+ if (outcome === "data" || outcome === "eof") {
4558
+ r = buf.read(r.nextCursor, cap);
4559
+ }
4560
+ }
4561
+ const out = {
4562
+ bytes: r.bytes.toString("base64"),
4563
+ nextCursor: r.nextCursor
4564
+ };
4565
+ if (r.gap !== void 0) {
4566
+ out.gap = r.gap;
4567
+ }
4568
+ if (r.eof === true) {
4569
+ out.eof = true;
4570
+ }
4571
+ return out;
4572
+ }
4573
+ requireStreamBuffer() {
4574
+ if (this.streamBuffer === void 0) {
4575
+ const err = new Error(
4576
+ `session ${this.sessionId} has no stream buffer; call hydra-acp/stream_open first`
4577
+ );
4578
+ err.code = JsonRpcErrorCodes.StreamNotEnabled;
4579
+ throw err;
4580
+ }
4581
+ return this.streamBuffer;
4582
+ }
3639
4583
  markClosed(opts) {
3640
4584
  if (this.closed) {
3641
4585
  return;
3642
4586
  }
3643
4587
  this.closed = true;
3644
4588
  this.cancelIdleTimer();
4589
+ if (this.currentEntry?.kind === "user") {
4590
+ this.broadcastTurnComplete(
4591
+ this.currentEntry.clientId,
4592
+ { stopReason: "interrupted" },
4593
+ this.currentEntry.messageId,
4594
+ this.currentEntry.wasAmend
4595
+ );
4596
+ this.currentEntry = void 0;
4597
+ }
3645
4598
  const stranded = this.promptQueue;
3646
4599
  this.promptQueue = [];
3647
4600
  for (const entry of stranded) {
@@ -3654,12 +4607,23 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3654
4607
  } catch {
3655
4608
  }
3656
4609
  }
4610
+ this.notifyChain("session.closed", {});
3657
4611
  const sessionId = this.sessionId;
3658
4612
  this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
3659
4613
  for (const client of this.clients.values()) {
3660
4614
  void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
3661
4615
  }
3662
4616
  this.clients.clear();
4617
+ if (this.streamBuffer !== void 0) {
4618
+ const buf = this.streamBuffer;
4619
+ const path13 = this.streamFilePath;
4620
+ this.streamBuffer = void 0;
4621
+ this.streamFilePath = void 0;
4622
+ buf.close();
4623
+ if (path13 !== void 0) {
4624
+ void buf.drainFileWrites().then(() => fsp4.unlink(path13).catch(() => void 0));
4625
+ }
4626
+ }
3663
4627
  for (const handler of this.closeHandlers) {
3664
4628
  handler(opts);
3665
4629
  }
@@ -3722,6 +4686,44 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3722
4686
  clearTimeout(this.idleTimer);
3723
4687
  this.idleTimer = void 0;
3724
4688
  }
4689
+ this.cancelIdleEventTimer();
4690
+ }
4691
+ // ── Lifecycle event timer ────────────────────────────────────────────────
4692
+ scheduleIdleEvent() {
4693
+ if (this.closed || this.idleEventTimeoutMs <= 0 || this.transformChain.length === 0) {
4694
+ return;
4695
+ }
4696
+ if (this.idleEventTimer) {
4697
+ clearTimeout(this.idleEventTimer);
4698
+ }
4699
+ this.idleEventTimer = setTimeout(() => {
4700
+ this.idleEventTimer = void 0;
4701
+ this.notifyChain("session.idle", {});
4702
+ }, this.idleEventTimeoutMs);
4703
+ if (typeof this.idleEventTimer.unref === "function") {
4704
+ this.idleEventTimer.unref();
4705
+ }
4706
+ }
4707
+ cancelIdleEventTimer() {
4708
+ if (this.idleEventTimer) {
4709
+ clearTimeout(this.idleEventTimer);
4710
+ this.idleEventTimer = void 0;
4711
+ }
4712
+ }
4713
+ // Send a lifecycle notification to every transformer in the chain that
4714
+ // declared the matching intercept. Fire-and-forget — no response expected.
4715
+ notifyChain(event, payload) {
4716
+ const intercept = `lifecycle:${event}`;
4717
+ for (const t of this.transformChain) {
4718
+ if (!t.intercepts.has(intercept)) {
4719
+ continue;
4720
+ }
4721
+ void t.connection.notify("transformer/session_event", {
4722
+ event,
4723
+ sessionId: this.sessionId,
4724
+ payload
4725
+ }).catch(() => void 0);
4726
+ }
3725
4727
  }
3726
4728
  rewriteForClient(params) {
3727
4729
  if (params && typeof params === "object" && !Array.isArray(params)) {
@@ -3761,6 +4763,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3761
4763
  }
3762
4764
  }
3763
4765
  this.scheduleIdleCheck();
4766
+ this.scheduleIdleEvent();
3764
4767
  }
3765
4768
  this.updatedAt = Date.now();
3766
4769
  for (const client of this.clients.values()) {
@@ -3917,6 +4920,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3917
4920
  return;
3918
4921
  }
3919
4922
  this.promptInFlight = true;
4923
+ await new Promise((r) => setImmediate(r));
3920
4924
  try {
3921
4925
  while (this.promptQueue.length > 0) {
3922
4926
  const next = this.promptQueue.shift();
@@ -3936,7 +4940,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3936
4940
  try {
3937
4941
  const result = await this.runQueueEntry(next);
3938
4942
  next.resolve(result);
3939
- await Promise.resolve();
4943
+ await new Promise((r) => setImmediate(r));
3940
4944
  } catch (err) {
3941
4945
  next.reject(err);
3942
4946
  } finally {
@@ -3963,6 +4967,22 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3963
4967
  return entry.task();
3964
4968
  }
3965
4969
  this.broadcastPromptReceived(entry);
4970
+ const promptText = extractPromptText(entry.prompt).trim();
4971
+ if (promptText === "/model" || promptText.startsWith("/model ") || promptText === "/sessions" || promptText === "/help") {
4972
+ let result;
4973
+ if (promptText === "/sessions") {
4974
+ result = await this.handleSessionsCommand();
4975
+ } else if (promptText === "/help") {
4976
+ result = await this.handleHelpCommand();
4977
+ } else {
4978
+ result = await this.handleModelCommand(promptText);
4979
+ }
4980
+ if (!this.closed) {
4981
+ this.broadcastTurnComplete(entry.clientId, result, entry.messageId, entry.wasAmend);
4982
+ }
4983
+ this.clearAmendIfMatches(entry.messageId);
4984
+ return result;
4985
+ }
3966
4986
  let response;
3967
4987
  try {
3968
4988
  response = await this.agent.connection.request(
@@ -3973,21 +4993,25 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3973
4993
  }
3974
4994
  );
3975
4995
  } catch (err) {
4996
+ if (!this.closed) {
4997
+ this.broadcastTurnComplete(
4998
+ entry.clientId,
4999
+ { stopReason: "error" },
5000
+ entry.messageId,
5001
+ entry.wasAmend
5002
+ );
5003
+ }
5004
+ this.clearAmendIfMatches(entry.messageId);
5005
+ throw err;
5006
+ }
5007
+ if (!this.closed) {
3976
5008
  this.broadcastTurnComplete(
3977
5009
  entry.clientId,
3978
- { stopReason: "error" },
5010
+ response,
3979
5011
  entry.messageId,
3980
5012
  entry.wasAmend
3981
5013
  );
3982
- this.clearAmendIfMatches(entry.messageId);
3983
- throw err;
3984
5014
  }
3985
- this.broadcastTurnComplete(
3986
- entry.clientId,
3987
- response,
3988
- entry.messageId,
3989
- entry.wasAmend
3990
- );
3991
5015
  this.clearAmendIfMatches(entry.messageId);
3992
5016
  return response;
3993
5017
  }
@@ -4252,6 +5276,12 @@ function extractPromptText(prompt) {
4252
5276
  return "";
4253
5277
  }).join("");
4254
5278
  }
5279
+ function defaultStopPayload(method) {
5280
+ if (method === "session/prompt") {
5281
+ return { stopReason: "stopped" };
5282
+ }
5283
+ return {};
5284
+ }
4255
5285
  function firstLine(text, max) {
4256
5286
  for (const raw of text.split(/\r?\n/)) {
4257
5287
  const line = raw.trim();
@@ -4292,7 +5322,8 @@ var PersistedUsage = z4.object({
4292
5322
  used: z4.number().optional(),
4293
5323
  size: z4.number().optional(),
4294
5324
  costAmount: z4.number().optional(),
4295
- costCurrency: z4.string().optional()
5325
+ costCurrency: z4.string().optional(),
5326
+ cumulativeCost: z4.number().optional()
4296
5327
  });
4297
5328
  var SessionRecord = z4.object({
4298
5329
  version: z4.literal(1),
@@ -4341,6 +5372,9 @@ var SessionRecord = z4.object({
4341
5372
  // it) so the local history.jsonl gets populated from the agent's
4342
5373
  // memory. Cleared after that first resurrect completes.
4343
5374
  pendingHistorySync: z4.boolean().optional(),
5375
+ // Set when this session was spawned as a child by a transformer via
5376
+ // hydra-acp/spawn_child_session. Points to the spawning session's id.
5377
+ parentSessionId: z4.string().optional(),
4344
5378
  createdAt: z4.string(),
4345
5379
  updatedAt: z4.string()
4346
5380
  });
@@ -4462,6 +5496,7 @@ function recordFromMemorySession(args) {
4462
5496
  agentModes: args.agentModes,
4463
5497
  agentModels: args.agentModels,
4464
5498
  pendingHistorySync: args.pendingHistorySync,
5499
+ parentSessionId: args.parentSessionId,
4465
5500
  createdAt: args.createdAt ?? now,
4466
5501
  updatedAt: args.updatedAt ?? now
4467
5502
  };
@@ -4671,7 +5706,9 @@ var SessionManager = class {
4671
5706
  this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
4672
5707
  this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
4673
5708
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
5709
+ this.idleEventTimeoutMs = options.idleEventTimeoutMs ?? 3e4;
4674
5710
  this.defaultModels = options.defaultModels ?? {};
5711
+ this.defaultTransformers = options.defaultTransformers ?? [];
4675
5712
  this.logger = options.logger;
4676
5713
  this.npmRegistry = options.npmRegistry;
4677
5714
  }
@@ -4683,6 +5720,8 @@ var SessionManager = class {
4683
5720
  histories;
4684
5721
  idleTimeoutMs;
4685
5722
  defaultModels;
5723
+ defaultTransformers;
5724
+ idleEventTimeoutMs;
4686
5725
  sessionHistoryMaxEntries;
4687
5726
  // Serialize meta.json read-modify-write operations per session id so
4688
5727
  // concurrent snapshot updates (e.g. an agent emitting model + mode
@@ -4699,23 +5738,51 @@ var SessionManager = class {
4699
5738
  model: params.model,
4700
5739
  onInstallProgress: params.onInstallProgress
4701
5740
  });
5741
+ if (params.transformChain && params.transformChain.length > 0) {
5742
+ let caps = { ...fresh.agentCapabilities ?? {} };
5743
+ for (const t of params.transformChain) {
5744
+ if (!t.intercepts.has("agent:initialize")) {
5745
+ continue;
5746
+ }
5747
+ try {
5748
+ const result = await t.connection.request("transformer/message", {
5749
+ token: `t_${generateRawSessionId()}`,
5750
+ phase: "response",
5751
+ method: "initialize",
5752
+ direction: "agent\u2192daemon",
5753
+ sessionId: "(pre-session)",
5754
+ envelope: caps
5755
+ });
5756
+ if (result.action === "stop" && result.payload) {
5757
+ caps = result.payload;
5758
+ }
5759
+ } catch {
5760
+ }
5761
+ }
5762
+ fresh.agentCapabilities = caps;
5763
+ }
4702
5764
  const session = new Session({
4703
5765
  cwd: params.cwd,
4704
5766
  agentId: params.agentId,
4705
5767
  agent: fresh.agent,
4706
5768
  upstreamSessionId: fresh.upstreamSessionId,
4707
5769
  agentMeta: fresh.agentMeta,
5770
+ agentCapabilities: fresh.agentCapabilities,
4708
5771
  title: params.title,
4709
5772
  agentArgs: params.agentArgs,
4710
5773
  idleTimeoutMs: this.idleTimeoutMs,
5774
+ idleEventTimeoutMs: this.idleEventTimeoutMs,
4711
5775
  logger: this.logger,
4712
5776
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
5777
+ listSessions: () => this.list(),
4713
5778
  historyStore: this.histories,
4714
5779
  historyMaxEntries: this.sessionHistoryMaxEntries,
4715
5780
  currentModel: fresh.initialModel,
4716
5781
  currentMode: fresh.initialMode,
4717
5782
  agentModes: fresh.initialModes,
4718
- agentModels: fresh.initialModels
5783
+ agentModels: fresh.initialModels,
5784
+ transformChain: params.transformChain,
5785
+ parentSessionId: params.parentSessionId
4719
5786
  });
4720
5787
  await this.attachManagerHooks(session);
4721
5788
  return session;
@@ -4769,12 +5836,17 @@ var SessionManager = class {
4769
5836
  cwd: params.cwd,
4770
5837
  plan
4771
5838
  });
5839
+ let agentCapabilities;
4772
5840
  try {
4773
- await agent.connection.request("initialize", {
4774
- protocolVersion: ACP_PROTOCOL_VERSION,
4775
- clientCapabilities: {},
4776
- clientInfo: { name: "hydra", version: HYDRA_VERSION }
4777
- });
5841
+ const initResult = await agent.connection.request(
5842
+ "initialize",
5843
+ {
5844
+ protocolVersion: ACP_PROTOCOL_VERSION,
5845
+ clientCapabilities: {},
5846
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
5847
+ }
5848
+ );
5849
+ agentCapabilities = initResult.agentCapabilities;
4778
5850
  } catch (err) {
4779
5851
  await agent.kill().catch(() => void 0);
4780
5852
  throw err;
@@ -4804,6 +5876,22 @@ var SessionManager = class {
4804
5876
  } else {
4805
5877
  agent.connection.drainBuffered("session/update");
4806
5878
  }
5879
+ const agentReportedMode = extractInitialCurrentMode(loadResult ?? {});
5880
+ const advertisedModes = params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {}));
5881
+ this.logger?.info(
5882
+ `resurrect: sessionId=${params.hydraSessionId} persistedMode=${JSON.stringify(params.currentMode)} agentReportedMode=${JSON.stringify(agentReportedMode)} advertisedModes=${JSON.stringify(advertisedModes?.map((m) => m.id))}`
5883
+ );
5884
+ const effectiveMode = await restoreCurrentMode({
5885
+ agent,
5886
+ upstreamSessionId: params.upstreamSessionId,
5887
+ persistedMode: params.currentMode,
5888
+ agentReportedMode,
5889
+ advertisedModes,
5890
+ logger: this.logger
5891
+ });
5892
+ this.logger?.info(
5893
+ `resurrect: effectiveMode=${JSON.stringify(effectiveMode)} for sessionId=${params.hydraSessionId}`
5894
+ );
4807
5895
  const session = new Session({
4808
5896
  sessionId: params.hydraSessionId,
4809
5897
  cwd: params.cwd,
@@ -4811,11 +5899,13 @@ var SessionManager = class {
4811
5899
  agent,
4812
5900
  upstreamSessionId: params.upstreamSessionId,
4813
5901
  agentMeta: loadResult?._meta,
5902
+ agentCapabilities,
4814
5903
  title: params.title,
4815
5904
  agentArgs: params.agentArgs,
4816
5905
  idleTimeoutMs: this.idleTimeoutMs,
4817
5906
  logger: this.logger,
4818
5907
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
5908
+ listSessions: () => this.list(),
4819
5909
  historyStore: this.histories,
4820
5910
  historyMaxEntries: this.sessionHistoryMaxEntries,
4821
5911
  // Prefer what we previously stored from a current_model_update; if
@@ -4823,11 +5913,15 @@ var SessionManager = class {
4823
5913
  // this fix), fall back to the model the agent ships in its
4824
5914
  // session/load response body.
4825
5915
  currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
4826
- currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
5916
+ currentMode: effectiveMode,
4827
5917
  currentUsage: params.currentUsage,
4828
5918
  agentCommands: params.agentCommands,
4829
- agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
4830
- agentModels: params.agentModels ?? nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})),
5919
+ agentModes: advertisedModes,
5920
+ // Always prefer the fresh list from session/load over the persisted
5921
+ // snapshot — the proxy's available models can change between daemon
5922
+ // restarts (quota resets, rollouts), so meta.json is intentionally
5923
+ // treated as a cold fallback here, not the authoritative source.
5924
+ agentModels: nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})) ?? params.agentModels,
4831
5925
  // Only gate the first-prompt title heuristic when we actually have
4832
5926
  // a title to preserve. A title-less session (lost to a write race
4833
5927
  // or never seeded) should re-derive from the next prompt rather
@@ -4854,6 +5948,15 @@ var SessionManager = class {
4854
5948
  mcpServers: [],
4855
5949
  onInstallProgress: params.onInstallProgress
4856
5950
  });
5951
+ const advertisedModes = params.agentModes ?? fresh.initialModes;
5952
+ const effectiveMode = await restoreCurrentMode({
5953
+ agent: fresh.agent,
5954
+ upstreamSessionId: fresh.upstreamSessionId,
5955
+ persistedMode: params.currentMode,
5956
+ agentReportedMode: fresh.initialMode,
5957
+ advertisedModes,
5958
+ logger: this.logger
5959
+ });
4857
5960
  const session = new Session({
4858
5961
  sessionId: params.hydraSessionId,
4859
5962
  cwd,
@@ -4861,20 +5964,22 @@ var SessionManager = class {
4861
5964
  agent: fresh.agent,
4862
5965
  upstreamSessionId: fresh.upstreamSessionId,
4863
5966
  agentMeta: fresh.agentMeta,
5967
+ agentCapabilities: fresh.agentCapabilities,
4864
5968
  title: params.title,
4865
5969
  agentArgs: params.agentArgs,
4866
5970
  idleTimeoutMs: this.idleTimeoutMs,
4867
5971
  logger: this.logger,
4868
5972
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
5973
+ listSessions: () => this.list(),
4869
5974
  historyStore: this.histories,
4870
5975
  historyMaxEntries: this.sessionHistoryMaxEntries,
4871
5976
  // Prefer the stored value (set by a previous current_model_update);
4872
5977
  // fall back to whatever the agent ships in its session/new response.
4873
5978
  currentModel: params.currentModel ?? fresh.initialModel,
4874
- currentMode: params.currentMode ?? fresh.initialMode,
5979
+ currentMode: effectiveMode,
4875
5980
  currentUsage: params.currentUsage,
4876
5981
  agentCommands: params.agentCommands,
4877
- agentModes: params.agentModes ?? fresh.initialModes,
5982
+ agentModes: advertisedModes,
4878
5983
  agentModels: params.agentModels ?? fresh.initialModels,
4879
5984
  firstPromptSeeded: !!params.title,
4880
5985
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
@@ -5042,11 +6147,15 @@ var SessionManager = class {
5042
6147
  plan
5043
6148
  });
5044
6149
  try {
5045
- await agent.connection.request("initialize", {
5046
- protocolVersion: ACP_PROTOCOL_VERSION,
5047
- clientCapabilities: {},
5048
- clientInfo: { name: "hydra", version: HYDRA_VERSION }
5049
- });
6150
+ const initResult = await agent.connection.request(
6151
+ "initialize",
6152
+ {
6153
+ protocolVersion: ACP_PROTOCOL_VERSION,
6154
+ clientCapabilities: {},
6155
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
6156
+ }
6157
+ );
6158
+ const agentCapabilities = initResult.agentCapabilities;
5050
6159
  const newResult = await agent.connection.request(
5051
6160
  "session/new",
5052
6161
  {
@@ -5072,13 +6181,15 @@ var SessionManager = class {
5072
6181
  modelId: desired
5073
6182
  });
5074
6183
  initialModel = desired;
5075
- } catch {
6184
+ } catch (err) {
6185
+ this.logger?.warn(
6186
+ `defaultModels[${params.agentId}]=${JSON.stringify(desired)} rejected by agent (${err.message}); session will use ${JSON.stringify(initialModel)}`
6187
+ );
5076
6188
  }
5077
6189
  } else {
5078
6190
  const known = initialModels.map((m) => m.modelId).join(", ");
5079
- process.stderr.write(
5080
- `hydra-acp: defaultModels[${params.agentId}]=${JSON.stringify(desired)} is not in the agent's availableModels (${known}); skipping session/set_model
5081
- `
6191
+ this.logger?.warn(
6192
+ `defaultModels[${params.agentId}]=${JSON.stringify(desired)} not in agent's availableModels ([${known}]); skipping session/set_model, session will use ${JSON.stringify(initialModel)}`
5082
6193
  );
5083
6194
  }
5084
6195
  }
@@ -5088,6 +6199,7 @@ var SessionManager = class {
5088
6199
  agent,
5089
6200
  upstreamSessionId: sessionIdRaw,
5090
6201
  agentMeta: newResult._meta,
6202
+ agentCapabilities,
5091
6203
  initialModel,
5092
6204
  initialModels: initialModels.length > 0 ? initialModels : void 0,
5093
6205
  initialModes: initialModes.length > 0 ? initialModes : void 0,
@@ -5208,7 +6320,13 @@ var SessionManager = class {
5208
6320
  agentArgs: record.agentArgs,
5209
6321
  currentModel: record.currentModel,
5210
6322
  currentMode: record.currentMode,
5211
- currentUsage: persistedUsageToSnapshot(record.currentUsage),
6323
+ currentUsage: persistedUsageToSnapshot(
6324
+ record.currentUsage ? {
6325
+ ...record.currentUsage,
6326
+ cumulativeCost: (record.currentUsage.cumulativeCost ?? 0) + (record.currentUsage.costAmount ?? 0),
6327
+ costAmount: void 0
6328
+ } : void 0
6329
+ ),
5212
6330
  agentCommands: record.agentCommands,
5213
6331
  agentModes: record.agentModes,
5214
6332
  agentModels: record.agentModels,
@@ -5248,6 +6366,9 @@ var SessionManager = class {
5248
6366
  get(sessionId) {
5249
6367
  return this.sessions.get(sessionId);
5250
6368
  }
6369
+ liveSessions() {
6370
+ return this.sessions.values();
6371
+ }
5251
6372
  // Snapshot of which agent versions are currently in use by live
5252
6373
  // sessions, keyed by agentId. Read by the registry-fetch prune sweep
5253
6374
  // so it can skip install dirs that still back a running process.
@@ -5310,6 +6431,7 @@ var SessionManager = class {
5310
6431
  agentId: session.agentId,
5311
6432
  currentModel: session.currentModel,
5312
6433
  currentUsage: session.currentUsage,
6434
+ parentSessionId: session.parentSessionId,
5313
6435
  updatedAt: used,
5314
6436
  attachedClients: session.attachedCount,
5315
6437
  status: "live",
@@ -5332,9 +6454,13 @@ var SessionManager = class {
5332
6454
  title: r.title,
5333
6455
  agentId: r.agentId,
5334
6456
  currentModel: r.currentModel,
5335
- currentUsage: r.currentUsage,
6457
+ currentUsage: r.currentUsage ? {
6458
+ ...r.currentUsage,
6459
+ costAmount: (r.currentUsage.cumulativeCost ?? 0) + (r.currentUsage.costAmount ?? 0) || void 0
6460
+ } : void 0,
5336
6461
  importedFromMachine: r.importedFromMachine,
5337
6462
  importedFromUpstreamSessionId: r.importedFromUpstreamSessionId,
6463
+ parentSessionId: r.parentSessionId,
5338
6464
  updatedAt: used,
5339
6465
  attachedClients: 0,
5340
6466
  status: "cold",
@@ -5456,6 +6582,7 @@ var SessionManager = class {
5456
6582
  currentMode: args.bundle.session.currentMode,
5457
6583
  currentUsage: args.bundle.session.currentUsage,
5458
6584
  agentCommands: args.bundle.session.agentCommands,
6585
+ agentModes: args.bundle.session.agentModes,
5459
6586
  createdAt: args.preservedCreatedAt ?? now,
5460
6587
  // Fallback path for historyMtimeIso (used when the history file
5461
6588
  // is missing). Keep this consistent with the utimes stamp above.
@@ -5678,6 +6805,7 @@ function mergeForPersistence(session, existing) {
5678
6805
  agentCommands,
5679
6806
  agentModes,
5680
6807
  agentModels,
6808
+ parentSessionId: session.parentSessionId ?? existing?.parentSessionId,
5681
6809
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
5682
6810
  });
5683
6811
  }
@@ -5698,6 +6826,9 @@ function usageSnapshotToPersisted(usage) {
5698
6826
  if (usage.costCurrency !== void 0) {
5699
6827
  out.costCurrency = usage.costCurrency;
5700
6828
  }
6829
+ if (usage.cumulativeCost !== void 0) {
6830
+ out.cumulativeCost = usage.cumulativeCost;
6831
+ }
5701
6832
  return Object.keys(out).length > 0 ? out : void 0;
5702
6833
  }
5703
6834
  function persistedUsageToSnapshot(usage) {
@@ -5841,6 +6972,47 @@ function extractInitialCurrentMode(result) {
5841
6972
  }
5842
6973
  return void 0;
5843
6974
  }
6975
+ async function restoreCurrentMode(opts) {
6976
+ const {
6977
+ agent,
6978
+ upstreamSessionId,
6979
+ persistedMode,
6980
+ agentReportedMode,
6981
+ advertisedModes,
6982
+ logger
6983
+ } = opts;
6984
+ if (!persistedMode) {
6985
+ return agentReportedMode;
6986
+ }
6987
+ if (persistedMode === agentReportedMode) {
6988
+ return persistedMode;
6989
+ }
6990
+ if (advertisedModes && advertisedModes.length > 0 && !advertisedModes.some((m) => m.id === persistedMode)) {
6991
+ const known = advertisedModes.map((m) => m.id).join(", ");
6992
+ logger?.warn(
6993
+ `resurrect: persisted currentMode=${JSON.stringify(persistedMode)} not in agent's availableModes ([${known}]); skipping session/set_mode, session will use ${JSON.stringify(agentReportedMode)}`
6994
+ );
6995
+ return agentReportedMode;
6996
+ }
6997
+ try {
6998
+ logger?.info(
6999
+ `resurrect: pushing persisted modeId=${JSON.stringify(persistedMode)} to agent (agentReported=${JSON.stringify(agentReportedMode)})`
7000
+ );
7001
+ await agent.connection.request("session/set_mode", {
7002
+ sessionId: upstreamSessionId,
7003
+ modeId: persistedMode
7004
+ });
7005
+ logger?.info(
7006
+ `resurrect: session/set_mode accepted, effectiveMode=${JSON.stringify(persistedMode)}`
7007
+ );
7008
+ return persistedMode;
7009
+ } catch (err) {
7010
+ logger?.warn(
7011
+ `resurrect: session/set_mode rejected by agent for modeId=${JSON.stringify(persistedMode)} (${err.message}); session will use ${JSON.stringify(agentReportedMode)}`
7012
+ );
7013
+ return agentReportedMode;
7014
+ }
7015
+ }
5844
7016
  function parseModesList(list) {
5845
7017
  if (!Array.isArray(list)) {
5846
7018
  return [];
@@ -5901,7 +7073,7 @@ async function historyMtimeIso(sessionId) {
5901
7073
  // src/core/extensions.ts
5902
7074
  import { spawn as spawn4 } from "child_process";
5903
7075
  import * as fs11 from "fs";
5904
- import * as fsp3 from "fs/promises";
7076
+ import * as fsp5 from "fs/promises";
5905
7077
  import * as path7 from "path";
5906
7078
  var RESTART_BASE_MS = 1e3;
5907
7079
  var RESTART_CAP_MS = 6e4;
@@ -5910,8 +7082,10 @@ var ExtensionManager = class {
5910
7082
  entries = /* @__PURE__ */ new Map();
5911
7083
  stopping = false;
5912
7084
  context;
5913
- constructor(extensions, context) {
7085
+ tokenRegistry;
7086
+ constructor(extensions, context, options = {}) {
5914
7087
  this.context = context;
7088
+ this.tokenRegistry = options.tokenRegistry;
5915
7089
  for (const ext of extensions) {
5916
7090
  this.entries.set(ext.name, this.makeEntry(ext));
5917
7091
  }
@@ -5919,11 +7093,482 @@ var ExtensionManager = class {
5919
7093
  setContext(context) {
5920
7094
  this.context = context;
5921
7095
  }
7096
+ // Called by the WS handler after a process connects and calls initialize
7097
+ // with clientInfo.version. Stored on the entry and surfaced in list().
7098
+ reportVersion(name, version) {
7099
+ const entry = this.entries.get(name);
7100
+ if (entry) {
7101
+ entry.version = version;
7102
+ }
7103
+ }
7104
+ async start() {
7105
+ if (!this.context) {
7106
+ throw new Error("ExtensionManager: setContext must be called before start");
7107
+ }
7108
+ await fsp5.mkdir(paths.extensionsDir(), { recursive: true });
7109
+ await this.reapOrphans();
7110
+ for (const entry of this.entries.values()) {
7111
+ if (!entry.config.enabled) {
7112
+ continue;
7113
+ }
7114
+ this.spawn(entry, 0);
7115
+ }
7116
+ }
7117
+ async stop() {
7118
+ this.stopping = true;
7119
+ const tasks = [];
7120
+ for (const entry of this.entries.values()) {
7121
+ if (entry.restartTimer) {
7122
+ clearTimeout(entry.restartTimer);
7123
+ entry.restartTimer = void 0;
7124
+ }
7125
+ const child = entry.child;
7126
+ if (!child) {
7127
+ continue;
7128
+ }
7129
+ try {
7130
+ child.kill("SIGTERM");
7131
+ } catch {
7132
+ }
7133
+ tasks.push(
7134
+ new Promise((resolve3) => {
7135
+ if (child.exitCode !== null || child.signalCode !== null) {
7136
+ resolve3();
7137
+ return;
7138
+ }
7139
+ const timer = setTimeout(() => {
7140
+ try {
7141
+ child.kill("SIGKILL");
7142
+ } catch {
7143
+ }
7144
+ resolve3();
7145
+ }, STOP_GRACE_MS);
7146
+ child.on("exit", () => {
7147
+ clearTimeout(timer);
7148
+ resolve3();
7149
+ });
7150
+ })
7151
+ );
7152
+ }
7153
+ await Promise.allSettled(tasks);
7154
+ for (const entry of this.entries.values()) {
7155
+ try {
7156
+ entry.logStream?.end();
7157
+ } catch {
7158
+ }
7159
+ entry.child = void 0;
7160
+ entry.logStream = void 0;
7161
+ entry.pid = void 0;
7162
+ }
7163
+ }
7164
+ list() {
7165
+ return [...this.entries.values()].map((entry) => this.infoFor(entry));
7166
+ }
7167
+ get(name) {
7168
+ const entry = this.entries.get(name);
7169
+ return entry ? this.infoFor(entry) : void 0;
7170
+ }
7171
+ has(name) {
7172
+ return this.entries.has(name);
7173
+ }
7174
+ async startByName(name) {
7175
+ const entry = this.entries.get(name);
7176
+ if (!entry) {
7177
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7178
+ }
7179
+ if (entry.child) {
7180
+ throw withCode2(new Error(`extension ${name} already running`), "CONFLICT");
7181
+ }
7182
+ if (entry.restartTimer) {
7183
+ clearTimeout(entry.restartTimer);
7184
+ entry.restartTimer = void 0;
7185
+ }
7186
+ entry.manuallyStopped = false;
7187
+ entry.restartCount = 0;
7188
+ this.spawn(entry, 0);
7189
+ return this.infoFor(entry);
7190
+ }
7191
+ async stopByName(name) {
7192
+ const entry = this.entries.get(name);
7193
+ if (!entry) {
7194
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7195
+ }
7196
+ entry.manuallyStopped = true;
7197
+ if (entry.restartTimer) {
7198
+ clearTimeout(entry.restartTimer);
7199
+ entry.restartTimer = void 0;
7200
+ }
7201
+ const child = entry.child;
7202
+ if (!child) {
7203
+ return this.infoFor(entry);
7204
+ }
7205
+ await this.terminate(entry, child);
7206
+ return this.infoFor(entry);
7207
+ }
7208
+ async restartByName(name) {
7209
+ await this.stopByName(name);
7210
+ return this.startByName(name);
7211
+ }
7212
+ // Register a new extension and (if enabled) start it. Used by the
7213
+ // POST /v1/extensions endpoint so `hydra-acp extensions add` can take
7214
+ // effect without a daemon restart.
7215
+ register(config) {
7216
+ if (this.entries.has(config.name)) {
7217
+ throw withCode2(
7218
+ new Error(`extension ${config.name} already exists`),
7219
+ "CONFLICT"
7220
+ );
7221
+ }
7222
+ if (!this.context) {
7223
+ throw new Error("ExtensionManager: setContext must be called before register");
7224
+ }
7225
+ const entry = this.makeEntry(config);
7226
+ this.entries.set(config.name, entry);
7227
+ if (config.enabled) {
7228
+ this.spawn(entry, 0);
7229
+ }
7230
+ return this.infoFor(entry);
7231
+ }
7232
+ async unregister(name) {
7233
+ const entry = this.entries.get(name);
7234
+ if (!entry) {
7235
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7236
+ }
7237
+ entry.manuallyStopped = true;
7238
+ if (entry.restartTimer) {
7239
+ clearTimeout(entry.restartTimer);
7240
+ entry.restartTimer = void 0;
7241
+ }
7242
+ const child = entry.child;
7243
+ if (child) {
7244
+ await this.terminate(entry, child);
7245
+ }
7246
+ try {
7247
+ entry.logStream?.end();
7248
+ } catch {
7249
+ }
7250
+ this.entries.delete(name);
7251
+ }
7252
+ async terminate(entry, child) {
7253
+ if (child.exitCode !== null || child.signalCode !== null) {
7254
+ return;
7255
+ }
7256
+ const exited = new Promise((resolve3) => {
7257
+ entry.exitWaiters.push(resolve3);
7258
+ });
7259
+ try {
7260
+ child.kill("SIGTERM");
7261
+ } catch {
7262
+ }
7263
+ const killTimer = setTimeout(() => {
7264
+ try {
7265
+ child.kill("SIGKILL");
7266
+ } catch {
7267
+ }
7268
+ }, STOP_GRACE_MS);
7269
+ if (typeof killTimer.unref === "function") {
7270
+ killTimer.unref();
7271
+ }
7272
+ try {
7273
+ await exited;
7274
+ } finally {
7275
+ clearTimeout(killTimer);
7276
+ }
7277
+ }
7278
+ infoFor(entry) {
7279
+ let status;
7280
+ if (entry.child) {
7281
+ status = "running";
7282
+ } else if (entry.restartTimer) {
7283
+ status = "restarting";
7284
+ } else if (!entry.config.enabled) {
7285
+ status = "disabled";
7286
+ } else {
7287
+ status = "stopped";
7288
+ }
7289
+ return {
7290
+ name: entry.config.name,
7291
+ status,
7292
+ pid: entry.pid,
7293
+ enabled: entry.config.enabled,
7294
+ restartCount: entry.restartCount,
7295
+ startedAt: entry.startedAt,
7296
+ lastExitCode: entry.lastExitCode,
7297
+ logPath: paths.extensionLogFile(entry.config.name),
7298
+ version: entry.version
7299
+ };
7300
+ }
7301
+ makeEntry(config) {
7302
+ return {
7303
+ config,
7304
+ child: void 0,
7305
+ logStream: void 0,
7306
+ restartTimer: void 0,
7307
+ pid: void 0,
7308
+ startedAt: void 0,
7309
+ restartCount: 0,
7310
+ lastExitCode: void 0,
7311
+ manuallyStopped: false,
7312
+ exitWaiters: [],
7313
+ version: void 0,
7314
+ processToken: void 0
7315
+ };
7316
+ }
7317
+ async reapOrphans() {
7318
+ let entries;
7319
+ try {
7320
+ entries = await fsp5.readdir(paths.extensionsDir());
7321
+ } catch (err) {
7322
+ const e = err;
7323
+ if (e.code === "ENOENT") {
7324
+ return;
7325
+ }
7326
+ throw err;
7327
+ }
7328
+ for (const entry of entries) {
7329
+ if (!entry.endsWith(".pid")) {
7330
+ continue;
7331
+ }
7332
+ const pidPath = path7.join(paths.extensionsDir(), entry);
7333
+ let pid;
7334
+ try {
7335
+ const raw = await fsp5.readFile(pidPath, "utf8");
7336
+ const parsed = Number.parseInt(raw.trim(), 10);
7337
+ if (Number.isInteger(parsed) && parsed > 0) {
7338
+ pid = parsed;
7339
+ }
7340
+ } catch {
7341
+ }
7342
+ if (typeof pid === "number" && isAlive(pid)) {
7343
+ try {
7344
+ process.kill(pid, "SIGTERM");
7345
+ } catch {
7346
+ }
7347
+ const deadline = Date.now() + STOP_GRACE_MS;
7348
+ while (Date.now() < deadline && isAlive(pid)) {
7349
+ await new Promise((r) => setTimeout(r, 50));
7350
+ }
7351
+ if (isAlive(pid)) {
7352
+ try {
7353
+ process.kill(pid, "SIGKILL");
7354
+ } catch {
7355
+ }
7356
+ }
7357
+ }
7358
+ await fsp5.unlink(pidPath).catch(() => void 0);
7359
+ }
7360
+ }
7361
+ spawn(entry, attempt) {
7362
+ if (this.stopping || entry.manuallyStopped) {
7363
+ return;
7364
+ }
7365
+ const ctx = this.context;
7366
+ if (!ctx) {
7367
+ throw new Error("ExtensionManager.spawn called before setContext");
7368
+ }
7369
+ const ext = entry.config;
7370
+ const command = ext.command.length > 0 ? ext.command : [ext.name];
7371
+ const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
7372
+ flags: "a"
7373
+ });
7374
+ logStream.write(
7375
+ `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting extension ${ext.name} (attempt ${attempt + 1})
7376
+ `
7377
+ );
7378
+ const processToken = this.tokenRegistry?.mint(ext.name, "extension") ?? ctx.serviceToken;
7379
+ entry.processToken = processToken;
7380
+ entry.version = void 0;
7381
+ const env = {
7382
+ ...process.env,
7383
+ HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
7384
+ HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
7385
+ HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
7386
+ HYDRA_ACP_TOKEN: processToken,
7387
+ HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
7388
+ HYDRA_ACP_HOME: ctx.hydraHome,
7389
+ HYDRA_ACP_EXTENSION_NAME: ext.name,
7390
+ ...ext.env
7391
+ };
7392
+ const [cmd, ...baseArgs] = command;
7393
+ if (cmd === void 0) {
7394
+ logStream.write(`[hydra-acp] extension ${ext.name} has empty command
7395
+ `);
7396
+ logStream.end();
7397
+ return;
7398
+ }
7399
+ const args = [...baseArgs, ...ext.args];
7400
+ let child;
7401
+ try {
7402
+ child = spawn4(cmd, args, {
7403
+ env,
7404
+ stdio: ["ignore", "pipe", "pipe"],
7405
+ detached: false
7406
+ });
7407
+ } catch (err) {
7408
+ logStream.write(
7409
+ `[hydra-acp] failed to spawn ${ext.name}: ${err.message}
7410
+ `
7411
+ );
7412
+ logStream.end();
7413
+ this.scheduleRestart(entry, attempt);
7414
+ return;
7415
+ }
7416
+ if (child.stdout) {
7417
+ child.stdout.pipe(logStream, { end: false });
7418
+ }
7419
+ if (child.stderr) {
7420
+ child.stderr.pipe(logStream, { end: false });
7421
+ }
7422
+ if (typeof child.pid === "number") {
7423
+ try {
7424
+ fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7425
+ `, {
7426
+ encoding: "utf8",
7427
+ mode: 384
7428
+ });
7429
+ } catch (err) {
7430
+ logStream.write(
7431
+ `[hydra-acp] failed to write pid file for ${ext.name}: ${err.message}
7432
+ `
7433
+ );
7434
+ }
7435
+ }
7436
+ entry.child = child;
7437
+ entry.logStream = logStream;
7438
+ entry.pid = typeof child.pid === "number" ? child.pid : void 0;
7439
+ entry.startedAt = Date.now();
7440
+ entry.lastExitCode = void 0;
7441
+ child.on("error", (err) => {
7442
+ logStream.write(
7443
+ `[hydra-acp] extension ${ext.name} error: ${err.message}
7444
+ `
7445
+ );
7446
+ });
7447
+ child.on("exit", (code, signal) => {
7448
+ try {
7449
+ fs11.unlinkSync(paths.extensionPidFile(ext.name));
7450
+ } catch {
7451
+ }
7452
+ logStream.write(
7453
+ `[hydra-acp] extension ${ext.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
7454
+ `
7455
+ );
7456
+ entry.child = void 0;
7457
+ entry.pid = void 0;
7458
+ entry.lastExitCode = typeof code === "number" ? code : void 0;
7459
+ if (entry.processToken) {
7460
+ this.tokenRegistry?.revoke(ext.name);
7461
+ entry.processToken = void 0;
7462
+ }
7463
+ const waiters = entry.exitWaiters.splice(0);
7464
+ for (const resolve3 of waiters) {
7465
+ resolve3();
7466
+ }
7467
+ if (this.stopping || entry.manuallyStopped) {
7468
+ try {
7469
+ logStream.end();
7470
+ } catch {
7471
+ }
7472
+ entry.logStream = void 0;
7473
+ return;
7474
+ }
7475
+ entry.restartCount += 1;
7476
+ this.scheduleRestart(entry, attempt + 1);
7477
+ });
7478
+ }
7479
+ scheduleRestart(entry, attempt) {
7480
+ if (this.stopping || entry.manuallyStopped) {
7481
+ return;
7482
+ }
7483
+ const delay = Math.min(
7484
+ RESTART_BASE_MS * 2 ** Math.min(attempt, 10),
7485
+ RESTART_CAP_MS
7486
+ );
7487
+ entry.restartTimer = setTimeout(() => {
7488
+ entry.restartTimer = void 0;
7489
+ this.spawn(entry, attempt);
7490
+ }, delay);
7491
+ if (typeof entry.restartTimer.unref === "function") {
7492
+ entry.restartTimer.unref();
7493
+ }
7494
+ }
7495
+ };
7496
+ function isAlive(pid) {
7497
+ try {
7498
+ process.kill(pid, 0);
7499
+ return true;
7500
+ } catch {
7501
+ return false;
7502
+ }
7503
+ }
7504
+ function withCode2(err, code) {
7505
+ err.code = code;
7506
+ return err;
7507
+ }
7508
+
7509
+ // src/core/transformer-manager.ts
7510
+ import { spawn as spawn5 } from "child_process";
7511
+ import * as fs12 from "fs";
7512
+ import * as fsp6 from "fs/promises";
7513
+ import * as path8 from "path";
7514
+ var RESTART_BASE_MS2 = 1e3;
7515
+ var RESTART_CAP_MS2 = 6e4;
7516
+ var STOP_GRACE_MS2 = 3e3;
7517
+ var TransformerManager = class {
7518
+ entries = /* @__PURE__ */ new Map();
7519
+ // Transformers that have completed transformer/initialize and are ready to
7520
+ // participate in chains. Keyed by transformer name.
7521
+ connected = /* @__PURE__ */ new Map();
7522
+ stopping = false;
7523
+ context;
7524
+ tokenRegistry;
7525
+ constructor(transformers, context, options = {}) {
7526
+ this.context = context;
7527
+ this.tokenRegistry = options.tokenRegistry;
7528
+ for (const t of transformers) {
7529
+ this.entries.set(t.name, this.makeEntry(t));
7530
+ }
7531
+ }
7532
+ setContext(context) {
7533
+ this.context = context;
7534
+ }
7535
+ reportVersion(name, version) {
7536
+ const entry = this.entries.get(name);
7537
+ if (entry) {
7538
+ entry.version = version;
7539
+ }
7540
+ }
7541
+ // Called by the WS handler after transformer/initialize completes. The
7542
+ // transformer is now eligible to participate in session chains.
7543
+ registerConnection(name, connection, intercepts) {
7544
+ this.connected.set(name, {
7545
+ name,
7546
+ connection,
7547
+ intercepts: new Set(intercepts)
7548
+ });
7549
+ }
7550
+ // Called by the WS handler when the transformer's WS connection closes.
7551
+ deregisterConnection(name) {
7552
+ this.connected.delete(name);
7553
+ }
7554
+ // Resolve a list of transformer names to their live TransformerRef objects.
7555
+ // Names that are configured but not yet connected are silently skipped
7556
+ // (fail-open: session proceeds without that transformer rather than failing).
7557
+ resolveChain(names) {
7558
+ const out = [];
7559
+ for (const name of names) {
7560
+ const ref = this.connected.get(name);
7561
+ if (ref) {
7562
+ out.push(ref);
7563
+ }
7564
+ }
7565
+ return out;
7566
+ }
5922
7567
  async start() {
5923
7568
  if (!this.context) {
5924
- throw new Error("ExtensionManager: setContext must be called before start");
7569
+ throw new Error("TransformerManager: setContext must be called before start");
5925
7570
  }
5926
- await fsp3.mkdir(paths.extensionsDir(), { recursive: true });
7571
+ await fsp6.mkdir(paths.transformersDir(), { recursive: true });
5927
7572
  await this.reapOrphans();
5928
7573
  for (const entry of this.entries.values()) {
5929
7574
  if (!entry.config.enabled) {
@@ -5960,7 +7605,7 @@ var ExtensionManager = class {
5960
7605
  } catch {
5961
7606
  }
5962
7607
  resolve3();
5963
- }, STOP_GRACE_MS);
7608
+ }, STOP_GRACE_MS2);
5964
7609
  child.on("exit", () => {
5965
7610
  clearTimeout(timer);
5966
7611
  resolve3();
@@ -5992,10 +7637,10 @@ var ExtensionManager = class {
5992
7637
  async startByName(name) {
5993
7638
  const entry = this.entries.get(name);
5994
7639
  if (!entry) {
5995
- throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7640
+ throw withCode3(new Error(`unknown transformer: ${name}`), "NOT_FOUND");
5996
7641
  }
5997
7642
  if (entry.child) {
5998
- throw withCode2(new Error(`extension ${name} already running`), "CONFLICT");
7643
+ throw withCode3(new Error(`transformer ${name} already running`), "CONFLICT");
5999
7644
  }
6000
7645
  if (entry.restartTimer) {
6001
7646
  clearTimeout(entry.restartTimer);
@@ -6009,7 +7654,7 @@ var ExtensionManager = class {
6009
7654
  async stopByName(name) {
6010
7655
  const entry = this.entries.get(name);
6011
7656
  if (!entry) {
6012
- throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7657
+ throw withCode3(new Error(`unknown transformer: ${name}`), "NOT_FOUND");
6013
7658
  }
6014
7659
  entry.manuallyStopped = true;
6015
7660
  if (entry.restartTimer) {
@@ -6027,18 +7672,15 @@ var ExtensionManager = class {
6027
7672
  await this.stopByName(name);
6028
7673
  return this.startByName(name);
6029
7674
  }
6030
- // Register a new extension and (if enabled) start it. Used by the
6031
- // POST /v1/extensions endpoint so `hydra-acp extensions add` can take
6032
- // effect without a daemon restart.
6033
7675
  register(config) {
6034
7676
  if (this.entries.has(config.name)) {
6035
- throw withCode2(
6036
- new Error(`extension ${config.name} already exists`),
7677
+ throw withCode3(
7678
+ new Error(`transformer ${config.name} already exists`),
6037
7679
  "CONFLICT"
6038
7680
  );
6039
7681
  }
6040
7682
  if (!this.context) {
6041
- throw new Error("ExtensionManager: setContext must be called before register");
7683
+ throw new Error("TransformerManager: setContext must be called before register");
6042
7684
  }
6043
7685
  const entry = this.makeEntry(config);
6044
7686
  this.entries.set(config.name, entry);
@@ -6050,7 +7692,7 @@ var ExtensionManager = class {
6050
7692
  async unregister(name) {
6051
7693
  const entry = this.entries.get(name);
6052
7694
  if (!entry) {
6053
- throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7695
+ throw withCode3(new Error(`unknown transformer: ${name}`), "NOT_FOUND");
6054
7696
  }
6055
7697
  entry.manuallyStopped = true;
6056
7698
  if (entry.restartTimer) {
@@ -6083,7 +7725,7 @@ var ExtensionManager = class {
6083
7725
  child.kill("SIGKILL");
6084
7726
  } catch {
6085
7727
  }
6086
- }, STOP_GRACE_MS);
7728
+ }, STOP_GRACE_MS2);
6087
7729
  if (typeof killTimer.unref === "function") {
6088
7730
  killTimer.unref();
6089
7731
  }
@@ -6112,7 +7754,8 @@ var ExtensionManager = class {
6112
7754
  restartCount: entry.restartCount,
6113
7755
  startedAt: entry.startedAt,
6114
7756
  lastExitCode: entry.lastExitCode,
6115
- logPath: paths.extensionLogFile(entry.config.name)
7757
+ logPath: paths.transformerLogFile(entry.config.name),
7758
+ version: entry.version
6116
7759
  };
6117
7760
  }
6118
7761
  makeEntry(config) {
@@ -6126,13 +7769,15 @@ var ExtensionManager = class {
6126
7769
  restartCount: 0,
6127
7770
  lastExitCode: void 0,
6128
7771
  manuallyStopped: false,
6129
- exitWaiters: []
7772
+ exitWaiters: [],
7773
+ version: void 0,
7774
+ processToken: void 0
6130
7775
  };
6131
7776
  }
6132
7777
  async reapOrphans() {
6133
7778
  let entries;
6134
7779
  try {
6135
- entries = await fsp3.readdir(paths.extensionsDir());
7780
+ entries = await fsp6.readdir(paths.transformersDir());
6136
7781
  } catch (err) {
6137
7782
  const e = err;
6138
7783
  if (e.code === "ENOENT") {
@@ -6144,33 +7789,33 @@ var ExtensionManager = class {
6144
7789
  if (!entry.endsWith(".pid")) {
6145
7790
  continue;
6146
7791
  }
6147
- const pidPath = path7.join(paths.extensionsDir(), entry);
7792
+ const pidPath = path8.join(paths.transformersDir(), entry);
6148
7793
  let pid;
6149
7794
  try {
6150
- const raw = await fsp3.readFile(pidPath, "utf8");
7795
+ const raw = await fsp6.readFile(pidPath, "utf8");
6151
7796
  const parsed = Number.parseInt(raw.trim(), 10);
6152
7797
  if (Number.isInteger(parsed) && parsed > 0) {
6153
7798
  pid = parsed;
6154
7799
  }
6155
7800
  } catch {
6156
7801
  }
6157
- if (typeof pid === "number" && isAlive(pid)) {
7802
+ if (typeof pid === "number" && isAlive2(pid)) {
6158
7803
  try {
6159
7804
  process.kill(pid, "SIGTERM");
6160
7805
  } catch {
6161
7806
  }
6162
- const deadline = Date.now() + STOP_GRACE_MS;
6163
- while (Date.now() < deadline && isAlive(pid)) {
7807
+ const deadline = Date.now() + STOP_GRACE_MS2;
7808
+ while (Date.now() < deadline && isAlive2(pid)) {
6164
7809
  await new Promise((r) => setTimeout(r, 50));
6165
7810
  }
6166
- if (isAlive(pid)) {
7811
+ if (isAlive2(pid)) {
6167
7812
  try {
6168
7813
  process.kill(pid, "SIGKILL");
6169
7814
  } catch {
6170
7815
  }
6171
7816
  }
6172
7817
  }
6173
- await fsp3.unlink(pidPath).catch(() => void 0);
7818
+ await fsp6.unlink(pidPath).catch(() => void 0);
6174
7819
  }
6175
7820
  }
6176
7821
  spawn(entry, attempt) {
@@ -6179,46 +7824,49 @@ var ExtensionManager = class {
6179
7824
  }
6180
7825
  const ctx = this.context;
6181
7826
  if (!ctx) {
6182
- throw new Error("ExtensionManager.spawn called before setContext");
7827
+ throw new Error("TransformerManager.spawn called before setContext");
6183
7828
  }
6184
- const ext = entry.config;
6185
- const command = ext.command.length > 0 ? ext.command : [ext.name];
6186
- const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
7829
+ const t = entry.config;
7830
+ const command = t.command.length > 0 ? t.command : [t.name];
7831
+ const logStream = fs12.createWriteStream(paths.transformerLogFile(t.name), {
6187
7832
  flags: "a"
6188
7833
  });
6189
7834
  logStream.write(
6190
- `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting extension ${ext.name} (attempt ${attempt + 1})
7835
+ `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting transformer ${t.name} (attempt ${attempt + 1})
6191
7836
  `
6192
7837
  );
7838
+ const processToken = this.tokenRegistry?.mint(t.name, "transformer") ?? ctx.serviceToken;
7839
+ entry.processToken = processToken;
7840
+ entry.version = void 0;
6193
7841
  const env = {
6194
7842
  ...process.env,
6195
7843
  HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
6196
7844
  HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
6197
7845
  HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
6198
- HYDRA_ACP_TOKEN: ctx.serviceToken,
7846
+ HYDRA_ACP_TOKEN: processToken,
6199
7847
  HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
6200
7848
  HYDRA_ACP_HOME: ctx.hydraHome,
6201
- HYDRA_ACP_EXTENSION_NAME: ext.name,
6202
- ...ext.env
7849
+ HYDRA_ACP_TRANSFORMER_NAME: t.name,
7850
+ ...t.env
6203
7851
  };
6204
7852
  const [cmd, ...baseArgs] = command;
6205
7853
  if (cmd === void 0) {
6206
- logStream.write(`[hydra-acp] extension ${ext.name} has empty command
7854
+ logStream.write(`[hydra-acp] transformer ${t.name} has empty command
6207
7855
  `);
6208
7856
  logStream.end();
6209
7857
  return;
6210
7858
  }
6211
- const args = [...baseArgs, ...ext.args];
7859
+ const args = [...baseArgs, ...t.args];
6212
7860
  let child;
6213
7861
  try {
6214
- child = spawn4(cmd, args, {
7862
+ child = spawn5(cmd, args, {
6215
7863
  env,
6216
7864
  stdio: ["ignore", "pipe", "pipe"],
6217
7865
  detached: false
6218
7866
  });
6219
7867
  } catch (err) {
6220
7868
  logStream.write(
6221
- `[hydra-acp] failed to spawn ${ext.name}: ${err.message}
7869
+ `[hydra-acp] failed to spawn ${t.name}: ${err.message}
6222
7870
  `
6223
7871
  );
6224
7872
  logStream.end();
@@ -6233,14 +7881,14 @@ var ExtensionManager = class {
6233
7881
  }
6234
7882
  if (typeof child.pid === "number") {
6235
7883
  try {
6236
- fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7884
+ fs12.writeFileSync(paths.transformerPidFile(t.name), `${child.pid}
6237
7885
  `, {
6238
7886
  encoding: "utf8",
6239
7887
  mode: 384
6240
7888
  });
6241
7889
  } catch (err) {
6242
7890
  logStream.write(
6243
- `[hydra-acp] failed to write pid file for ${ext.name}: ${err.message}
7891
+ `[hydra-acp] failed to write pid file for ${t.name}: ${err.message}
6244
7892
  `
6245
7893
  );
6246
7894
  }
@@ -6252,22 +7900,26 @@ var ExtensionManager = class {
6252
7900
  entry.lastExitCode = void 0;
6253
7901
  child.on("error", (err) => {
6254
7902
  logStream.write(
6255
- `[hydra-acp] extension ${ext.name} error: ${err.message}
7903
+ `[hydra-acp] transformer ${t.name} error: ${err.message}
6256
7904
  `
6257
7905
  );
6258
7906
  });
6259
7907
  child.on("exit", (code, signal) => {
6260
7908
  try {
6261
- fs11.unlinkSync(paths.extensionPidFile(ext.name));
7909
+ fs12.unlinkSync(paths.transformerPidFile(t.name));
6262
7910
  } catch {
6263
7911
  }
6264
7912
  logStream.write(
6265
- `[hydra-acp] extension ${ext.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
7913
+ `[hydra-acp] transformer ${t.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
6266
7914
  `
6267
7915
  );
6268
7916
  entry.child = void 0;
6269
7917
  entry.pid = void 0;
6270
7918
  entry.lastExitCode = typeof code === "number" ? code : void 0;
7919
+ if (entry.processToken) {
7920
+ this.tokenRegistry?.revoke(t.name);
7921
+ entry.processToken = void 0;
7922
+ }
6271
7923
  const waiters = entry.exitWaiters.splice(0);
6272
7924
  for (const resolve3 of waiters) {
6273
7925
  resolve3();
@@ -6289,8 +7941,8 @@ var ExtensionManager = class {
6289
7941
  return;
6290
7942
  }
6291
7943
  const delay = Math.min(
6292
- RESTART_BASE_MS * 2 ** Math.min(attempt, 10),
6293
- RESTART_CAP_MS
7944
+ RESTART_BASE_MS2 * 2 ** Math.min(attempt, 10),
7945
+ RESTART_CAP_MS2
6294
7946
  );
6295
7947
  entry.restartTimer = setTimeout(() => {
6296
7948
  entry.restartTimer = void 0;
@@ -6301,7 +7953,7 @@ var ExtensionManager = class {
6301
7953
  }
6302
7954
  }
6303
7955
  };
6304
- function isAlive(pid) {
7956
+ function isAlive2(pid) {
6305
7957
  try {
6306
7958
  process.kill(pid, 0);
6307
7959
  return true;
@@ -6309,14 +7961,14 @@ function isAlive(pid) {
6309
7961
  return false;
6310
7962
  }
6311
7963
  }
6312
- function withCode2(err, code) {
7964
+ function withCode3(err, code) {
6313
7965
  err.code = code;
6314
7966
  return err;
6315
7967
  }
6316
7968
 
6317
7969
  // src/core/agent-prune.ts
6318
- import * as fsp4 from "fs/promises";
6319
- import * as path8 from "path";
7970
+ import * as fsp7 from "fs/promises";
7971
+ import * as path9 from "path";
6320
7972
  var logSink3 = (msg) => {
6321
7973
  process.stderr.write(msg + "\n");
6322
7974
  };
@@ -6334,10 +7986,10 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6334
7986
  desiredByAgent.set(a.id, a.version ?? "current");
6335
7987
  }
6336
7988
  const activeByAgent = sessionManager.activeAgentVersions();
6337
- const platformDir = path8.join(paths.agentsDir(), platformKey);
7989
+ const platformDir = path9.join(paths.agentsDir(), platformKey);
6338
7990
  let agentEntries;
6339
7991
  try {
6340
- agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
7992
+ agentEntries = await fsp7.readdir(platformDir, { withFileTypes: true });
6341
7993
  } catch (err) {
6342
7994
  const e = err;
6343
7995
  if (e.code === "ENOENT") {
@@ -6356,10 +8008,10 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6356
8008
  continue;
6357
8009
  }
6358
8010
  const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
6359
- const agentDir = path8.join(platformDir, agentId);
8011
+ const agentDir = path9.join(platformDir, agentId);
6360
8012
  let versionEntries;
6361
8013
  try {
6362
- versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
8014
+ versionEntries = await fsp7.readdir(agentDir, { withFileTypes: true });
6363
8015
  } catch (err) {
6364
8016
  logSink3(
6365
8017
  `hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
@@ -6380,9 +8032,9 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6380
8032
  if (version.includes(".partial-")) {
6381
8033
  continue;
6382
8034
  }
6383
- const versionDir = path8.join(agentDir, version);
8035
+ const versionDir = path9.join(agentDir, version);
6384
8036
  try {
6385
- await fsp4.rm(versionDir, { recursive: true, force: true });
8037
+ await fsp7.rm(versionDir, { recursive: true, force: true });
6386
8038
  logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
6387
8039
  } catch (err) {
6388
8040
  logSink3(
@@ -6394,8 +8046,8 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6394
8046
  }
6395
8047
 
6396
8048
  // src/core/session-tokens.ts
6397
- import * as fs12 from "fs/promises";
6398
- import * as path9 from "path";
8049
+ import * as fs13 from "fs/promises";
8050
+ import * as path10 from "path";
6399
8051
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
6400
8052
  var TOKEN_PREFIX = "hydra_session_";
6401
8053
  var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
@@ -6403,7 +8055,7 @@ var ID_LENGTH = 12;
6403
8055
  var TOKEN_BYTES = 32;
6404
8056
  var WRITE_DEBOUNCE_MS = 50;
6405
8057
  function tokensFilePath() {
6406
- return path9.join(paths.home(), "session-tokens.json");
8058
+ return path10.join(paths.home(), "session-tokens.json");
6407
8059
  }
6408
8060
  function sha256Hex(input) {
6409
8061
  return createHash("sha256").update(input).digest("hex");
@@ -6430,7 +8082,7 @@ var SessionTokenStore = class _SessionTokenStore {
6430
8082
  static async load() {
6431
8083
  let records = [];
6432
8084
  try {
6433
- const raw = await fs12.readFile(tokensFilePath(), "utf8");
8085
+ const raw = await fs13.readFile(tokensFilePath(), "utf8");
6434
8086
  const parsed = JSON.parse(raw);
6435
8087
  if (parsed && Array.isArray(parsed.records)) {
6436
8088
  records = parsed.records.filter(isRecord);
@@ -6558,8 +8210,8 @@ var SessionTokenStore = class _SessionTokenStore {
6558
8210
  const records = Array.from(this.records.values());
6559
8211
  const payload = JSON.stringify({ records }, null, 2) + "\n";
6560
8212
  this.writeInflight = (async () => {
6561
- await fs12.mkdir(paths.home(), { recursive: true });
6562
- await fs12.writeFile(tokensFilePath(), payload, {
8213
+ await fs13.mkdir(paths.home(), { recursive: true });
8214
+ await fs13.writeFile(tokensFilePath(), payload, {
6563
8215
  encoding: "utf8",
6564
8216
  mode: 384
6565
8217
  });
@@ -6655,6 +8307,34 @@ function tokenFromUpgradeRequest(req) {
6655
8307
  }
6656
8308
  return void 0;
6657
8309
  }
8310
+ var ProcessTokenRegistry = class {
8311
+ tokens = /* @__PURE__ */ new Map();
8312
+ mint(name, kind) {
8313
+ const token = generateServiceToken();
8314
+ this.tokens.set(token, { name, kind });
8315
+ return token;
8316
+ }
8317
+ // Revoke all tokens associated with the named process. Called when a
8318
+ // process exits or is unregistered so stale tokens can't be reused if a
8319
+ // new process happens to reconnect before a full daemon restart.
8320
+ revoke(name) {
8321
+ for (const [token, identity] of this.tokens) {
8322
+ if (identity.name === name) {
8323
+ this.tokens.delete(token);
8324
+ }
8325
+ }
8326
+ }
8327
+ resolve(token) {
8328
+ return this.tokens.get(token);
8329
+ }
8330
+ async validate(token) {
8331
+ const identity = this.tokens.get(token);
8332
+ if (!identity) {
8333
+ return void 0;
8334
+ }
8335
+ return `${identity.kind}:${identity.name}`;
8336
+ }
8337
+ };
6658
8338
  function constantTimeEqual(a, b) {
6659
8339
  if (a.length !== b.length) {
6660
8340
  return false;
@@ -6739,7 +8419,13 @@ var Bundle = z5.object({
6739
8419
  exportedAt: z5.string(),
6740
8420
  exportedFrom: z5.object({
6741
8421
  hydraVersion: z5.string(),
6742
- machine: z5.string()
8422
+ machine: z5.string(),
8423
+ // Externally-reachable name (and optional ":port") for the exporting
8424
+ // daemon, sourced from config.daemon.publicHost (or daemon.host when
8425
+ // non-loopback). Carried so an importer can construct a hydra:// URL
8426
+ // that dials back to the origin — e.g. over Tailscale. Omitted when
8427
+ // the exporter has no routable address; never falls back to loopback.
8428
+ hydraHost: z5.string().optional()
6743
8429
  }),
6744
8430
  session: BundleSession,
6745
8431
  history: z5.array(HistoryEntrySchema),
@@ -6751,7 +8437,8 @@ function encodeBundle(params) {
6751
8437
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
6752
8438
  exportedFrom: {
6753
8439
  hydraVersion: params.hydraVersion,
6754
- machine: params.machine
8440
+ machine: params.machine,
8441
+ ...params.hydraHost !== void 0 && params.hydraHost.length > 0 ? { hydraHost: params.hydraHost } : {}
6755
8442
  },
6756
8443
  session: {
6757
8444
  sessionId: params.record.sessionId,
@@ -7095,7 +8782,7 @@ function isUpstreamInterrupted(u, errorText) {
7095
8782
  }
7096
8783
  function mapPlan(u) {
7097
8784
  const entries = u.entries;
7098
- if (!Array.isArray(entries)) {
8785
+ if (!Array.isArray(entries) || entries.length === 0) {
7099
8786
  return null;
7100
8787
  }
7101
8788
  const normalized = [];
@@ -7131,7 +8818,15 @@ function mapModel(u) {
7131
8818
  if (!model) {
7132
8819
  return null;
7133
8820
  }
7134
- return { kind: "model-changed", model: sanitizeSingleLine(model) };
8821
+ const raw = u.availableModels;
8822
+ const availableModels = Array.isArray(raw) ? raw.map(
8823
+ (m) => typeof m === "object" && m !== null ? m.modelId : typeof m === "string" ? m : void 0
8824
+ ).filter((id) => typeof id === "string" && id.length > 0) : void 0;
8825
+ return {
8826
+ kind: "model-changed",
8827
+ model: sanitizeSingleLine(model),
8828
+ ...availableModels && availableModels.length > 0 ? { availableModels } : {}
8829
+ };
7135
8830
  }
7136
8831
  function mapTurnComplete(u) {
7137
8832
  const stopReason = readString(u, "stopReason");
@@ -7431,7 +9126,21 @@ function formatNumber(n) {
7431
9126
  return n.toLocaleString("en-US");
7432
9127
  }
7433
9128
 
9129
+ // src/core/remote-url.ts
9130
+ function isLoopbackHost(host) {
9131
+ return host === "127.0.0.1" || host === "::1" || host === "localhost" || host === "[::1]";
9132
+ }
9133
+
7434
9134
  // src/daemon/routes/sessions.ts
9135
+ function resolveHydraHost(defaults) {
9136
+ if (defaults.publicHost && defaults.publicHost.length > 0) {
9137
+ return defaults.publicHost;
9138
+ }
9139
+ if (defaults.host && !isLoopbackHost(defaults.host)) {
9140
+ return defaults.port !== void 0 ? `${defaults.host}:${defaults.port}` : defaults.host;
9141
+ }
9142
+ return void 0;
9143
+ }
7435
9144
  function registerSessionRoutes(app, manager, defaults) {
7436
9145
  app.get("/v1/sessions", async (request) => {
7437
9146
  const query = request.query;
@@ -7531,7 +9240,8 @@ function registerSessionRoutes(app, manager, defaults) {
7531
9240
  history: exported.history,
7532
9241
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
7533
9242
  hydraVersion: HYDRA_VERSION,
7534
- machine: os3.hostname()
9243
+ machine: os3.hostname(),
9244
+ hydraHost: resolveHydraHost(defaults)
7535
9245
  });
7536
9246
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7537
9247
  reply.header(
@@ -7553,7 +9263,8 @@ function registerSessionRoutes(app, manager, defaults) {
7553
9263
  history: exported.history,
7554
9264
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
7555
9265
  hydraVersion: HYDRA_VERSION,
7556
- machine: os3.hostname()
9266
+ machine: os3.hostname(),
9267
+ hydraHost: resolveHydraHost(defaults)
7557
9268
  });
7558
9269
  reply.header("Content-Type", "text/markdown; charset=utf-8");
7559
9270
  reply.code(200).send(bundleToMarkdown(bundle));
@@ -7866,6 +9577,122 @@ function parseRegisterBody(body) {
7866
9577
  };
7867
9578
  }
7868
9579
 
9580
+ // src/daemon/routes/transformers.ts
9581
+ var NAME_RE2 = /^[A-Za-z0-9._-]+$/;
9582
+ function registerTransformerRoutes(app, transformers) {
9583
+ app.get("/v1/transformers", async () => {
9584
+ return { transformers: transformers.list() };
9585
+ });
9586
+ app.get("/v1/transformers/:name", async (request, reply) => {
9587
+ const name = request.params.name;
9588
+ const info = transformers.get(name);
9589
+ if (!info) {
9590
+ reply.code(404).send({ error: `unknown transformer: ${name}` });
9591
+ return;
9592
+ }
9593
+ return info;
9594
+ });
9595
+ app.post("/v1/transformers", async (request, reply) => {
9596
+ const body = request.body ?? {};
9597
+ const parsed = parseRegisterBody2(body);
9598
+ if ("error" in parsed) {
9599
+ reply.code(400).send({ error: parsed.error });
9600
+ return;
9601
+ }
9602
+ try {
9603
+ const info = transformers.register(parsed.config);
9604
+ reply.code(201).send(info);
9605
+ } catch (err) {
9606
+ sendError2(reply, err);
9607
+ }
9608
+ });
9609
+ app.delete("/v1/transformers/:name", async (request, reply) => {
9610
+ const name = request.params.name;
9611
+ try {
9612
+ await transformers.unregister(name);
9613
+ reply.code(204).send();
9614
+ } catch (err) {
9615
+ sendError2(reply, err);
9616
+ }
9617
+ });
9618
+ app.post("/v1/transformers/:name/start", async (request, reply) => {
9619
+ const name = request.params.name;
9620
+ try {
9621
+ const info = await transformers.startByName(name);
9622
+ reply.code(200).send(info);
9623
+ } catch (err) {
9624
+ sendError2(reply, err);
9625
+ }
9626
+ });
9627
+ app.post("/v1/transformers/:name/stop", async (request, reply) => {
9628
+ const name = request.params.name;
9629
+ try {
9630
+ const info = await transformers.stopByName(name);
9631
+ reply.code(200).send(info);
9632
+ } catch (err) {
9633
+ sendError2(reply, err);
9634
+ }
9635
+ });
9636
+ app.post("/v1/transformers/:name/restart", async (request, reply) => {
9637
+ const name = request.params.name;
9638
+ try {
9639
+ const info = await transformers.restartByName(name);
9640
+ reply.code(200).send(info);
9641
+ } catch (err) {
9642
+ sendError2(reply, err);
9643
+ }
9644
+ });
9645
+ }
9646
+ function sendError2(reply, err) {
9647
+ const code = err.code;
9648
+ const message = err.message ?? "unknown error";
9649
+ if (code === "NOT_FOUND") {
9650
+ reply.code(404).send({ error: message });
9651
+ return;
9652
+ }
9653
+ if (code === "CONFLICT") {
9654
+ reply.code(409).send({ error: message });
9655
+ return;
9656
+ }
9657
+ reply.code(500).send({ error: message });
9658
+ }
9659
+ function parseRegisterBody2(body) {
9660
+ const name = body.name;
9661
+ if (typeof name !== "string" || !NAME_RE2.test(name)) {
9662
+ return { error: "name must match [A-Za-z0-9._-]+" };
9663
+ }
9664
+ const command = body.command;
9665
+ if (command !== void 0 && (!Array.isArray(command) || command.some((c) => typeof c !== "string"))) {
9666
+ return { error: "command must be string[]" };
9667
+ }
9668
+ const args = body.args;
9669
+ if (args !== void 0 && (!Array.isArray(args) || args.some((a) => typeof a !== "string"))) {
9670
+ return { error: "args must be string[]" };
9671
+ }
9672
+ const env = body.env;
9673
+ if (env !== void 0 && (typeof env !== "object" || env === null || Array.isArray(env))) {
9674
+ return { error: "env must be an object of string\u2192string" };
9675
+ }
9676
+ if (env && Object.values(env).some(
9677
+ (v) => typeof v !== "string"
9678
+ )) {
9679
+ return { error: "env values must be strings" };
9680
+ }
9681
+ const enabled = body.enabled;
9682
+ if (enabled !== void 0 && typeof enabled !== "boolean") {
9683
+ return { error: "enabled must be a boolean" };
9684
+ }
9685
+ return {
9686
+ config: {
9687
+ name,
9688
+ command: command ?? [],
9689
+ args: args ?? [],
9690
+ env: env ?? {},
9691
+ enabled: enabled === void 0 ? true : enabled
9692
+ }
9693
+ };
9694
+ }
9695
+
7869
9696
  // src/daemon/routes/config.ts
7870
9697
  function registerConfigRoutes(app, defaults) {
7871
9698
  app.get("/v1/config", async () => {
@@ -7880,19 +9707,19 @@ function registerConfigRoutes(app, defaults) {
7880
9707
  import { z as z6 } from "zod";
7881
9708
 
7882
9709
  // src/core/password.ts
7883
- import * as fs13 from "fs/promises";
7884
- import * as path10 from "path";
9710
+ import * as fs14 from "fs/promises";
9711
+ import * as path11 from "path";
7885
9712
  import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
7886
9713
  import { promisify } from "util";
7887
9714
  var scryptAsync = promisify(scrypt);
7888
9715
  function passwordHashPath() {
7889
- return path10.join(paths.home(), "password-hash");
9716
+ return path11.join(paths.home(), "password-hash");
7890
9717
  }
7891
9718
  var DEFAULT_N = 1 << 15;
7892
9719
  var MAX_MEM = 128 * 1024 * 1024;
7893
9720
  async function hasPassword() {
7894
9721
  try {
7895
- const text = await fs13.readFile(passwordHashPath(), "utf8");
9722
+ const text = await fs14.readFile(passwordHashPath(), "utf8");
7896
9723
  return text.trim().length > 0;
7897
9724
  } catch (err) {
7898
9725
  const e = err;
@@ -7908,7 +9735,7 @@ async function verifyPassword(plaintext) {
7908
9735
  }
7909
9736
  let line;
7910
9737
  try {
7911
- line = (await fs13.readFile(passwordHashPath(), "utf8")).trim();
9738
+ line = (await fs14.readFile(passwordHashPath(), "utf8")).trim();
7912
9739
  } catch (err) {
7913
9740
  const e = err;
7914
9741
  if (e.code === "ENOENT") {
@@ -8102,6 +9929,8 @@ function wsToMessageStream(ws) {
8102
9929
  }
8103
9930
 
8104
9931
  // src/daemon/acp-ws.ts
9932
+ import * as os4 from "os";
9933
+ import * as path12 from "path";
8105
9934
  function registerAcpWsEndpoint(app, deps) {
8106
9935
  app.get("/acp", { websocket: true }, async (socket, request) => {
8107
9936
  const token = tokenFromUpgradeRequest({
@@ -8112,10 +9941,12 @@ function registerAcpWsEndpoint(app, deps) {
8112
9941
  socket.close(4401, "Unauthorized");
8113
9942
  return;
8114
9943
  }
9944
+ const processIdentity = deps.processRegistry?.resolve(token);
8115
9945
  const stream = wsToMessageStream(socket);
8116
9946
  const connection = new JsonRpcConnection(stream);
8117
9947
  const state = {
8118
9948
  clientId: `hydra_client_${nanoid2(12)}`,
9949
+ processIdentity,
8119
9950
  attached: /* @__PURE__ */ new Map()
8120
9951
  };
8121
9952
  connection.onClose(() => {
@@ -8136,14 +9967,158 @@ function registerAcpWsEndpoint(app, deps) {
8136
9967
  }
8137
9968
  };
8138
9969
  connection.onRequest("initialize", async (raw) => {
8139
- InitializeParams.parse(raw ?? {});
9970
+ const params = InitializeParams.parse(raw ?? {});
9971
+ const version = params.clientInfo?.version;
9972
+ if (version && processIdentity) {
9973
+ if (processIdentity.kind === "extension") {
9974
+ deps.onExtensionVersion?.(processIdentity.name, version);
9975
+ } else {
9976
+ deps.onTransformerVersion?.(processIdentity.name, version);
9977
+ }
9978
+ }
8140
9979
  return buildInitializeResult();
8141
9980
  });
9981
+ if (processIdentity?.kind === "transformer") {
9982
+ connection.onRequest("transformer/initialize", async (raw) => {
9983
+ const params = raw ?? {};
9984
+ const intercepts = Array.isArray(params.intercepts) ? params.intercepts.filter(
9985
+ (v) => typeof v === "string"
9986
+ ) : [];
9987
+ if (deps.transformers) {
9988
+ deps.transformers.registerConnection(
9989
+ processIdentity.name,
9990
+ connection,
9991
+ intercepts
9992
+ );
9993
+ if (deps.manager?.defaultTransformers.includes(processIdentity.name)) {
9994
+ const ref = deps.transformers.resolveChain([processIdentity.name])[0];
9995
+ if (ref) {
9996
+ for (const session of deps.manager.liveSessions()) {
9997
+ session.addTransformer(ref);
9998
+ }
9999
+ }
10000
+ }
10001
+ }
10002
+ return { ack: true };
10003
+ });
10004
+ connection.onClose(() => {
10005
+ deps.transformers?.deregisterConnection(processIdentity.name);
10006
+ });
10007
+ connection.onRequest("hydra-acp/emit_message", async (raw) => {
10008
+ const params = raw ?? {};
10009
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
10010
+ const method = typeof params.method === "string" ? params.method : void 0;
10011
+ const envelope = params.envelope;
10012
+ const route = params.route;
10013
+ if (!sessionId || !method) {
10014
+ throw Object.assign(new Error("emit_message requires sessionId and method"), { code: -32602 });
10015
+ }
10016
+ const session = deps.manager.get(sessionId);
10017
+ if (!session) {
10018
+ throw Object.assign(new Error(`session ${sessionId} not found`), { code: JsonRpcErrorCodes.SessionNotFound });
10019
+ }
10020
+ const respondsTo = typeof params.respondsTo === "string" ? params.respondsTo : void 0;
10021
+ if (respondsTo) {
10022
+ session.dischargeClaim(respondsTo, envelope);
10023
+ return { ok: true };
10024
+ }
10025
+ if (route === "chain") {
10026
+ await session.emitToChain(processIdentity.name, method, envelope);
10027
+ return { ok: true };
10028
+ }
10029
+ if (route === "daemon") {
10030
+ await session.emitToChain(processIdentity.name, method, envelope);
10031
+ return { ok: true };
10032
+ }
10033
+ throw Object.assign(new Error(`unsupported route: ${JSON.stringify(route)}`), { code: -32602 });
10034
+ });
10035
+ connection.onRequest("hydra-acp/spawn_child_session", async (raw) => {
10036
+ const params = raw ?? {};
10037
+ const agentId = typeof params.agentId === "string" ? params.agentId : deps.defaultAgent;
10038
+ const cwd = typeof params.cwd === "string" ? params.cwd : void 0;
10039
+ const parentSessionId = typeof params.parentSessionId === "string" ? params.parentSessionId : void 0;
10040
+ if (!cwd) {
10041
+ throw Object.assign(new Error("spawn_child_session requires cwd"), { code: -32602 });
10042
+ }
10043
+ const child = await deps.manager.create({
10044
+ agentId,
10045
+ cwd,
10046
+ parentSessionId,
10047
+ transformChain: []
10048
+ // children start with no chain by default
10049
+ });
10050
+ return { childSessionId: child.sessionId };
10051
+ });
10052
+ connection.onRequest("hydra-acp/await_child", async (raw) => {
10053
+ const params = raw ?? {};
10054
+ const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
10055
+ const until = params.until === "idle" ? "idle" : "turn_complete";
10056
+ const timeoutMs = typeof params.timeoutMs === "number" ? Math.min(params.timeoutMs, 30 * 6e4) : 5 * 6e4;
10057
+ if (!childSessionId) {
10058
+ throw Object.assign(new Error("await_child requires childSessionId"), { code: -32602 });
10059
+ }
10060
+ const child = deps.manager.get(childSessionId);
10061
+ if (!child) {
10062
+ throw Object.assign(
10063
+ new Error(`child session ${childSessionId} not found`),
10064
+ { code: JsonRpcErrorCodes.SessionNotFound }
10065
+ );
10066
+ }
10067
+ return new Promise((resolve3) => {
10068
+ const entries = [];
10069
+ let unsubscribe;
10070
+ const finish = () => {
10071
+ clearTimeout(timer);
10072
+ unsubscribe?.();
10073
+ resolve3({ entries });
10074
+ };
10075
+ unsubscribe = child.onBroadcast((entry) => {
10076
+ entries.push(entry);
10077
+ if (until === "turn_complete") {
10078
+ const upd = entry.params?.update;
10079
+ if (upd?.sessionUpdate === "turn_complete") {
10080
+ finish();
10081
+ }
10082
+ }
10083
+ });
10084
+ const timer = setTimeout(finish, timeoutMs);
10085
+ if (typeof timer.unref === "function") {
10086
+ timer.unref();
10087
+ }
10088
+ child.onClose(() => finish());
10089
+ });
10090
+ });
10091
+ connection.onRequest("hydra-acp/close_child_session", async (raw) => {
10092
+ const params = raw ?? {};
10093
+ const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
10094
+ if (!childSessionId) {
10095
+ throw Object.assign(new Error("close_child_session requires childSessionId"), { code: -32602 });
10096
+ }
10097
+ const child = deps.manager.get(childSessionId);
10098
+ if (child) {
10099
+ await child.close({ deleteRecord: false });
10100
+ }
10101
+ return { ok: true };
10102
+ });
10103
+ connection.onRequest("hydra-acp/keep_alive", async (raw) => {
10104
+ const params = raw ?? {};
10105
+ const token2 = typeof params.token === "string" ? params.token : void 0;
10106
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
10107
+ const estimatedRemainingMs = typeof params.estimatedRemainingMs === "number" ? params.estimatedRemainingMs : void 0;
10108
+ if (token2 && sessionId) {
10109
+ const session = deps.manager.get(sessionId);
10110
+ session?.keepAliveClaim(token2, estimatedRemainingMs);
10111
+ }
10112
+ return { ok: true };
10113
+ });
10114
+ }
8142
10115
  connection.onRequest("session/new", async (raw) => {
8143
10116
  const params = SessionNewParams.parse(raw);
8144
10117
  const hydraMeta = extractHydraMeta(
8145
10118
  raw?._meta
8146
10119
  );
10120
+ const transformerNames = Array.isArray(hydraMeta.transformers) && hydraMeta.transformers.every((t) => typeof t === "string") ? hydraMeta.transformers : deps.manager.defaultTransformers ?? [];
10121
+ const transformChain = deps.transformers?.resolveChain(transformerNames) ?? [];
8147
10122
  const session = await deps.manager.create({
8148
10123
  cwd: params.cwd,
8149
10124
  agentId: params.agentId ?? deps.defaultAgent,
@@ -8151,7 +10126,8 @@ function registerAcpWsEndpoint(app, deps) {
8151
10126
  title: hydraMeta.name,
8152
10127
  agentArgs: hydraMeta.agentArgs,
8153
10128
  model: hydraMeta.model,
8154
- onInstallProgress: makeInstallProgressForwarder(connection)
10129
+ onInstallProgress: makeInstallProgressForwarder(connection),
10130
+ transformChain
8155
10131
  });
8156
10132
  const client = bindClientToSession(connection, session, state);
8157
10133
  const { entries: replay } = await session.attach(client, "full");
@@ -8183,6 +10159,14 @@ function registerAcpWsEndpoint(app, deps) {
8183
10159
  });
8184
10160
  connection.onRequest("session/attach", async (raw) => {
8185
10161
  const params = SessionAttachParams.parse(raw);
10162
+ const attachVersion = params.clientInfo?.version;
10163
+ if (attachVersion && processIdentity) {
10164
+ if (processIdentity.kind === "extension") {
10165
+ deps.onExtensionVersion?.(processIdentity.name, attachVersion);
10166
+ } else {
10167
+ deps.onTransformerVersion?.(processIdentity.name, attachVersion);
10168
+ }
10169
+ }
8186
10170
  const hydraHints = extractHydraMeta(params._meta).resume;
8187
10171
  const readonly = params.readonly === true;
8188
10172
  app.log.info(
@@ -8229,16 +10213,13 @@ function registerAcpWsEndpoint(app, deps) {
8229
10213
  let resurrectParams = fromDisk;
8230
10214
  if (hydraHints) {
8231
10215
  resurrectParams = {
10216
+ ...fromDisk,
8232
10217
  hydraSessionId: params.sessionId,
8233
10218
  upstreamSessionId: hydraHints.upstreamSessionId,
8234
10219
  agentId: hydraHints.agentId,
8235
10220
  cwd: hydraHints.cwd,
8236
- title: hydraHints.title ?? fromDisk?.title,
8237
- agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
8238
- currentModel: fromDisk?.currentModel,
8239
- currentMode: fromDisk?.currentMode,
8240
- agentCommands: fromDisk?.agentCommands,
8241
- createdAt: fromDisk?.createdAt
10221
+ ...hydraHints.title !== void 0 ? { title: hydraHints.title } : {},
10222
+ ...hydraHints.agentArgs !== void 0 ? { agentArgs: hydraHints.agentArgs } : {}
8242
10223
  };
8243
10224
  }
8244
10225
  if (!resurrectParams) {
@@ -8252,6 +10233,7 @@ function registerAcpWsEndpoint(app, deps) {
8252
10233
  ...resurrectParams,
8253
10234
  onInstallProgress: makeInstallProgressForwarder(connection)
8254
10235
  });
10236
+ wireDefaultTransformers(session, deps);
8255
10237
  }
8256
10238
  const client = bindClientToSession(
8257
10239
  connection,
@@ -8341,6 +10323,7 @@ function registerAcpWsEndpoint(app, deps) {
8341
10323
  `session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
8342
10324
  );
8343
10325
  session = await deps.manager.resurrect(fromDisk);
10326
+ wireDefaultTransformers(session, deps);
8344
10327
  const client = bindClientToSession(
8345
10328
  connection,
8346
10329
  session,
@@ -8428,6 +10411,51 @@ function registerAcpWsEndpoint(app, deps) {
8428
10411
  }
8429
10412
  return session.amendPrompt(att.clientId, params);
8430
10413
  });
10414
+ connection.onRequest("hydra-acp/stream_open", async (raw) => {
10415
+ const params = StreamOpenParams.parse(raw);
10416
+ denyIfReadonly(params.sessionId, "hydra-acp/stream_open");
10417
+ const session = deps.manager.get(params.sessionId);
10418
+ if (!session) {
10419
+ const err = new Error(`session ${params.sessionId} not found`);
10420
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10421
+ throw err;
10422
+ }
10423
+ const openOpts = {};
10424
+ if (params.mode !== void 0) {
10425
+ openOpts.mode = params.mode;
10426
+ }
10427
+ if (params.capacityBytes !== void 0) {
10428
+ openOpts.capacityBytes = params.capacityBytes;
10429
+ }
10430
+ if (params.fileCapBytes !== void 0) {
10431
+ openOpts.fileCapBytes = params.fileCapBytes;
10432
+ }
10433
+ if ((params.mode ?? "memory") === "file") {
10434
+ openOpts.filePathFor = (sid) => path12.join(os4.tmpdir(), `hydra-stdin-${sid}.log`);
10435
+ }
10436
+ return session.openStream(openOpts);
10437
+ });
10438
+ connection.onRequest("hydra-acp/stream_write", async (raw) => {
10439
+ const params = StreamWriteParams.parse(raw);
10440
+ denyIfReadonly(params.sessionId, "hydra-acp/stream_write");
10441
+ const session = deps.manager.get(params.sessionId);
10442
+ if (!session) {
10443
+ const err = new Error(`session ${params.sessionId} not found`);
10444
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10445
+ throw err;
10446
+ }
10447
+ return session.streamWrite(params.chunk, params.eof);
10448
+ });
10449
+ connection.onRequest("hydra-acp/stream_read", async (raw) => {
10450
+ const params = StreamReadParams.parse(raw);
10451
+ const session = deps.manager.get(params.sessionId);
10452
+ if (!session) {
10453
+ const err = new Error(`session ${params.sessionId} not found`);
10454
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10455
+ throw err;
10456
+ }
10457
+ return session.streamRead(params.cursor, params.maxBytes, params.waitMs);
10458
+ });
8431
10459
  connection.onRequest("session/load", async (raw) => {
8432
10460
  const rawObj = raw ?? {};
8433
10461
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -8448,6 +10476,7 @@ function registerAcpWsEndpoint(app, deps) {
8448
10476
  throw err;
8449
10477
  }
8450
10478
  session = await deps.manager.resurrect(fromDisk);
10479
+ wireDefaultTransformers(session, deps);
8451
10480
  }
8452
10481
  const client = bindClientToSession(connection, session, state);
8453
10482
  const { entries: replay } = await session.attach(client, "pending_only");
@@ -8498,6 +10527,32 @@ function registerAcpWsEndpoint(app, deps) {
8498
10527
  app.log.info(decision.logMessage);
8499
10528
  return decision.session.forwardRequest("session/set_model", rawParams);
8500
10529
  });
10530
+ connection.onRequest("session/set_mode", async (rawParams) => {
10531
+ const params = rawParams;
10532
+ const sessionIdField = params?.sessionId;
10533
+ if (typeof sessionIdField === "string") {
10534
+ denyIfReadonly(sessionIdField, "session/set_mode");
10535
+ }
10536
+ if (!params || typeof params.sessionId !== "string") {
10537
+ const err = new Error("session/set_mode requires string sessionId");
10538
+ err.code = JsonRpcErrorCodes.InvalidParams;
10539
+ throw err;
10540
+ }
10541
+ if (typeof params.modeId !== "string") {
10542
+ const err = new Error("session/set_mode requires string modeId");
10543
+ err.code = JsonRpcErrorCodes.InvalidParams;
10544
+ throw err;
10545
+ }
10546
+ const session = deps.manager.get(params.sessionId);
10547
+ if (!session) {
10548
+ const err = new Error(`session ${params.sessionId} not found`);
10549
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10550
+ throw err;
10551
+ }
10552
+ const result = await session.forwardRequest("session/set_mode", rawParams);
10553
+ session.applyModeChange(params.modeId);
10554
+ return result;
10555
+ });
8501
10556
  connection.setDefaultHandler(async (rawParams, method) => {
8502
10557
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
8503
10558
  const err = new Error(`Method not found: ${method}`);
@@ -8717,6 +10772,9 @@ function buildResponseMeta(session) {
8717
10772
  if (session.turnStartedAt !== void 0) {
8718
10773
  ours.turnStartedAt = session.turnStartedAt;
8719
10774
  }
10775
+ if (session.agentCapabilities !== void 0) {
10776
+ ours.agentCapabilities = session.agentCapabilities;
10777
+ }
8720
10778
  const queue = session.queueSnapshot();
8721
10779
  if (queue.length > 0) {
8722
10780
  ours.queue = queue;
@@ -8767,6 +10825,17 @@ function buildInitializeResult() {
8767
10825
  })
8768
10826
  };
8769
10827
  }
10828
+ function wireDefaultTransformers(session, deps) {
10829
+ if (!deps.transformers || !deps.manager) {
10830
+ return;
10831
+ }
10832
+ for (const name of deps.manager.defaultTransformers) {
10833
+ const ref = deps.transformers.resolveChain([name])[0];
10834
+ if (ref) {
10835
+ session.addTransformer(ref);
10836
+ }
10837
+ }
10838
+ }
8770
10839
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
8771
10840
  void state;
8772
10841
  void session;
@@ -8781,10 +10850,10 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
8781
10850
  async function startDaemon(config, serviceToken) {
8782
10851
  ensureLoopbackOrTls(config);
8783
10852
  const httpsOptions = config.daemon.tls ? {
8784
- key: await fsp5.readFile(config.daemon.tls.key),
8785
- cert: await fsp5.readFile(config.daemon.tls.cert)
10853
+ key: await fsp8.readFile(config.daemon.tls.key),
10854
+ cert: await fsp8.readFile(config.daemon.tls.cert)
8786
10855
  } : void 0;
8787
- await fsp5.mkdir(paths.home(), { recursive: true });
10856
+ await fsp8.mkdir(paths.home(), { recursive: true });
8788
10857
  const { stream: logStream, fileStream } = await buildLogStream(
8789
10858
  config.daemon.logLevel
8790
10859
  );
@@ -8809,9 +10878,11 @@ async function startDaemon(config, serviceToken) {
8809
10878
  });
8810
10879
  const sessionTokenStore = await SessionTokenStore.load();
8811
10880
  const authRateLimiter = new AuthRateLimiter();
10881
+ const processRegistry = new ProcessTokenRegistry();
8812
10882
  const validator = new CompositeTokenValidator([
8813
10883
  new StaticTokenValidator(serviceToken),
8814
- new SessionTokenValidator(sessionTokenStore)
10884
+ new SessionTokenValidator(sessionTokenStore),
10885
+ processRegistry
8815
10886
  ]);
8816
10887
  const auth = bearerAuth({ validator });
8817
10888
  app.addHook("onRequest", async (request, reply) => {
@@ -8848,18 +10919,28 @@ async function startDaemon(config, serviceToken) {
8848
10919
  const manager = new SessionManager(registry, spawner, void 0, {
8849
10920
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
8850
10921
  defaultModels: config.defaultModels,
10922
+ defaultTransformers: config.defaultTransformers,
8851
10923
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
8852
10924
  logger: agentLogger,
8853
10925
  npmRegistry: config.npmRegistry
8854
10926
  });
8855
- const extensions = new ExtensionManager(extensionList(config));
10927
+ const extensions = new ExtensionManager(extensionList(config), void 0, {
10928
+ tokenRegistry: processRegistry
10929
+ });
10930
+ const transformers = new TransformerManager(transformerList(config), void 0, {
10931
+ tokenRegistry: processRegistry
10932
+ });
8856
10933
  registerHealthRoutes(app, HYDRA_VERSION);
8857
10934
  registerSessionRoutes(app, manager, {
8858
10935
  agentId: config.defaultAgent,
8859
- cwd: config.defaultCwd
10936
+ cwd: config.defaultCwd,
10937
+ publicHost: config.daemon.publicHost,
10938
+ host: config.daemon.host,
10939
+ port: config.daemon.port
8860
10940
  });
8861
10941
  registerAgentRoutes(app, registry, manager, { npmRegistry: config.npmRegistry });
8862
10942
  registerExtensionRoutes(app, extensions);
10943
+ registerTransformerRoutes(app, transformers);
8863
10944
  registerConfigRoutes(app, {
8864
10945
  defaultAgent: config.defaultAgent,
8865
10946
  defaultCwd: config.defaultCwd
@@ -8871,13 +10952,17 @@ async function startDaemon(config, serviceToken) {
8871
10952
  registerAcpWsEndpoint(app, {
8872
10953
  validator,
8873
10954
  manager,
8874
- defaultAgent: config.defaultAgent
10955
+ defaultAgent: config.defaultAgent,
10956
+ processRegistry,
10957
+ onExtensionVersion: (name, version) => extensions.reportVersion(name, version),
10958
+ onTransformerVersion: (name, version) => transformers.reportVersion(name, version),
10959
+ transformers
8875
10960
  });
8876
10961
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
8877
10962
  const address = app.server.address();
8878
10963
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
8879
- await fsp5.mkdir(paths.home(), { recursive: true });
8880
- await fsp5.writeFile(
10964
+ await fsp8.mkdir(paths.home(), { recursive: true });
10965
+ await fsp8.writeFile(
8881
10966
  paths.pidFile(),
8882
10967
  JSON.stringify({
8883
10968
  pid: process.pid,
@@ -8889,15 +10974,18 @@ async function startDaemon(config, serviceToken) {
8889
10974
  );
8890
10975
  const scheme = config.daemon.tls ? "https" : "http";
8891
10976
  const wsScheme = config.daemon.tls ? "wss" : "ws";
8892
- extensions.setContext({
10977
+ const processContext = {
8893
10978
  daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
8894
10979
  daemonHost: config.daemon.host,
8895
10980
  daemonPort: boundPort,
8896
10981
  serviceToken,
8897
10982
  daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
8898
10983
  hydraHome: paths.home()
8899
- });
10984
+ };
10985
+ extensions.setContext(processContext);
10986
+ transformers.setContext(processContext);
8900
10987
  await extensions.start();
10988
+ await transformers.start();
8901
10989
  void manager.resurrectPendingQueues().catch((err) => {
8902
10990
  app.log.warn(
8903
10991
  `queue replay scan failed: ${err.message}`
@@ -8907,6 +10995,7 @@ async function startDaemon(config, serviceToken) {
8907
10995
  clearInterval(sweepInterval);
8908
10996
  await sessionTokenStore.flush();
8909
10997
  await extensions.stop();
10998
+ await transformers.stop();
8910
10999
  await manager.closeAll();
8911
11000
  await manager.flushMetaWrites();
8912
11001
  setBinaryInstallLogger(null);
@@ -8914,7 +11003,7 @@ async function startDaemon(config, serviceToken) {
8914
11003
  setAgentPruneLogger(null);
8915
11004
  await app.close();
8916
11005
  try {
8917
- fs14.unlinkSync(paths.pidFile());
11006
+ fs15.unlinkSync(paths.pidFile());
8918
11007
  } catch {
8919
11008
  }
8920
11009
  try {
@@ -8922,7 +11011,7 @@ async function startDaemon(config, serviceToken) {
8922
11011
  } catch {
8923
11012
  }
8924
11013
  };
8925
- return { app, manager, registry, extensions, shutdown };
11014
+ return { app, manager, registry, extensions, transformers, shutdown };
8926
11015
  }
8927
11016
  async function buildLogStream(level) {
8928
11017
  const fileStream = await createPinoRoll({