@hydra-acp/cli 0.1.44 → 0.1.45

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
@@ -238,6 +245,12 @@ var ExtensionBody = z.object({
238
245
  env: z.record(z.string()).default({}),
239
246
  enabled: z.boolean().default(true)
240
247
  });
248
+ var TransformerBody = z.object({
249
+ command: z.array(z.string()).default([]),
250
+ args: z.array(z.string()).default([]),
251
+ env: z.record(z.string()).default({}),
252
+ enabled: z.boolean().default(true)
253
+ });
241
254
  var HydraConfig = z.object({
242
255
  daemon: DaemonConfig.default({}),
243
256
  registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
@@ -259,6 +272,8 @@ var HydraConfig = z.object({
259
272
  // recency and truncated to this count. `--all` overrides in the CLI.
260
273
  sessionListColdLimit: z.number().int().nonnegative().default(20),
261
274
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
275
+ transformers: z.record(ExtensionName, TransformerBody).default({}),
276
+ defaultTransformers: z.array(z.string()).default([]),
262
277
  // npm registry URL used when installing npm-distributed agents into
263
278
  // ~/.hydra-acp/agents. Overrides the global ~/.npmrc registry so a
264
279
  // corporate .npmrc pointing at an internal registry doesn't break
@@ -281,6 +296,12 @@ function extensionList(config) {
281
296
  ...body
282
297
  }));
283
298
  }
299
+ function transformerList(config) {
300
+ return Object.entries(config.transformers).map(([name, body]) => ({
301
+ name,
302
+ ...body
303
+ }));
304
+ }
284
305
  async function readConfigFile() {
285
306
  let raw;
286
307
  try {
@@ -1138,7 +1159,8 @@ var JsonRpcErrorCodes = {
1138
1159
  // can't collide with future spec assignments.
1139
1160
  BundleAlreadyImported: -32010,
1140
1161
  PermissionDenied: -32011,
1141
- AlreadyAttached: -32012
1162
+ AlreadyAttached: -32012,
1163
+ StreamNotEnabled: -32013
1142
1164
  };
1143
1165
  var InitializeParams = z3.object({
1144
1166
  protocolVersion: z3.number().optional(),
@@ -1219,6 +1241,9 @@ function extractHydraMeta(meta) {
1219
1241
  if (Array.isArray(obj.agentArgs) && obj.agentArgs.every((a) => typeof a === "string")) {
1220
1242
  out.agentArgs = obj.agentArgs;
1221
1243
  }
1244
+ if (Array.isArray(obj.transformers) && obj.transformers.every((t) => typeof t === "string")) {
1245
+ out.transformers = obj.transformers;
1246
+ }
1222
1247
  if (obj.resume) {
1223
1248
  const parsed = SessionResumeHints.safeParse(obj.resume);
1224
1249
  if (parsed.success) {
@@ -1385,6 +1410,8 @@ var SessionListEntry = z3.object({
1385
1410
  // future "connect back to origin" callers would dial both.
1386
1411
  importedFromMachine: z3.string().optional(),
1387
1412
  importedFromUpstreamSessionId: z3.string().optional(),
1413
+ // Set when this session was spawned as a child by a transformer.
1414
+ parentSessionId: z3.string().optional(),
1388
1415
  updatedAt: z3.string(),
1389
1416
  attachedClients: z3.number().int().nonnegative(),
1390
1417
  status: z3.enum(["live", "cold"]).default("live"),
@@ -1518,6 +1545,65 @@ var PromptAmendedParams = z3.object({
1518
1545
  originator: PromptOriginatorSchema,
1519
1546
  amendedAt: z3.number()
1520
1547
  });
1548
+ var StreamOpenParams = z3.object({
1549
+ sessionId: z3.string(),
1550
+ // 'memory' keeps the ring in RAM only — needed for the eventual MCP
1551
+ // tool surface. 'file' adds a temp file projection that the agent can
1552
+ // consume with shell tools (tail -f / head / grep) when MCP isn't
1553
+ // available. The temp file's path is returned in the response.
1554
+ mode: z3.enum(["memory", "file"]).optional(),
1555
+ // Ring capacity in bytes. Server clamps to a reasonable minimum and
1556
+ // its configured max; omitted falls back to the daemon default.
1557
+ capacityBytes: z3.number().int().positive().optional(),
1558
+ // File mode only. Soft cap in bytes; after this many bytes are
1559
+ // written to the file, further appends still land in the ring but
1560
+ // stop being mirrored to disk. The daemon emits one stream_truncated
1561
+ // session/update notification when the cap is first hit.
1562
+ fileCapBytes: z3.number().int().positive().optional()
1563
+ });
1564
+ var StreamOpenResult = z3.object({
1565
+ // Only present when mode === "file".
1566
+ filePath: z3.string().optional(),
1567
+ capacityBytes: z3.number().int().positive(),
1568
+ fileCapBytes: z3.number().int().positive().optional()
1569
+ });
1570
+ var StreamWriteParams = z3.object({
1571
+ sessionId: z3.string(),
1572
+ // Base64-encoded bytes. UTF-8 stdin gets re-encoded on the wire; the
1573
+ // ring is byte-exact so binary streams (audio, framed protocols) work
1574
+ // identically.
1575
+ chunk: z3.string(),
1576
+ // True on the final write. Pending long-poll reads / waits return with
1577
+ // eof:true once this is observed.
1578
+ eof: z3.boolean().optional()
1579
+ });
1580
+ var StreamWriteResult = z3.object({
1581
+ // Absolute writeCursor after this append landed.
1582
+ writeCursor: z3.number().int().nonnegative()
1583
+ });
1584
+ var StreamReadParams = z3.object({
1585
+ sessionId: z3.string(),
1586
+ cursor: z3.number().int().nonnegative(),
1587
+ // Cap on bytes returned. Server enforces a hard ceiling (STREAM_READ_MAX_BYTES,
1588
+ // currently 64 KiB) even when the caller asks for more.
1589
+ maxBytes: z3.number().int().positive().optional(),
1590
+ // Long-poll timeout in ms. 0 / omitted returns immediately with
1591
+ // whatever's available (possibly empty). Server cap 60s.
1592
+ waitMs: z3.number().int().nonnegative().optional()
1593
+ });
1594
+ var StreamReadResult = z3.object({
1595
+ // Base64-encoded bytes. Empty string when nothing new is available
1596
+ // and either waitMs was 0 or the long-poll expired without data.
1597
+ bytes: z3.string(),
1598
+ nextCursor: z3.number().int().nonnegative(),
1599
+ // Set when `cursor` pointed before the oldest still-resident byte —
1600
+ // value is the count of bytes that were evicted between the caller's
1601
+ // cursor and what we still have.
1602
+ gap: z3.number().int().nonnegative().optional(),
1603
+ // True when the producer has closed AND there are no more bytes
1604
+ // after nextCursor.
1605
+ eof: z3.boolean().optional()
1606
+ });
1521
1607
  var AgentInstallProgressParams = z3.object({
1522
1608
  agentId: z3.string(),
1523
1609
  version: z3.string(),
@@ -1923,6 +2009,264 @@ import { customAlphabet as customAlphabet3 } from "nanoid";
1923
2009
  // src/core/session.ts
1924
2010
  import { customAlphabet } from "nanoid";
1925
2011
 
2012
+ // src/core/stream-buffer.ts
2013
+ import * as fsp3 from "fs/promises";
2014
+ var DEFAULT_CAPACITY_BYTES = 16 * 1024 * 1024;
2015
+ var STREAM_READ_MAX_BYTES = 64 * 1024;
2016
+ var STREAM_WAIT_MAX_MS = 6e4;
2017
+ var SessionStreamBuffer = class {
2018
+ storage;
2019
+ capacityBytes;
2020
+ // Absolute monotonic byte offset of the next byte to be written. Also
2021
+ // the count of bytes ever appended. `writeCursor - capacityBytes`
2022
+ // (clamped at 0) is the oldest still-resident byte's cursor.
2023
+ writeCursor = 0;
2024
+ closed = false;
2025
+ waiters = [];
2026
+ filePath;
2027
+ fileCapBytes;
2028
+ fileBytesWritten = 0;
2029
+ fileCapReached = false;
2030
+ onFileCapReached;
2031
+ logWriteError;
2032
+ // Single-flight chain for file appends so concurrent stream_write
2033
+ // calls don't interleave their writes.
2034
+ fileWriteChain = Promise.resolve();
2035
+ constructor(opts = {}) {
2036
+ this.capacityBytes = opts.capacityBytes ?? DEFAULT_CAPACITY_BYTES;
2037
+ if (this.capacityBytes <= 0) {
2038
+ throw new Error("capacityBytes must be > 0");
2039
+ }
2040
+ this.storage = Buffer.alloc(this.capacityBytes);
2041
+ this.filePath = opts.filePath;
2042
+ this.fileCapBytes = opts.fileCapBytes ?? Number.POSITIVE_INFINITY;
2043
+ this.onFileCapReached = opts.onFileCapReached;
2044
+ this.logWriteError = opts.logWriteError;
2045
+ }
2046
+ get capacity() {
2047
+ return this.capacityBytes;
2048
+ }
2049
+ get writeCursorPos() {
2050
+ return this.writeCursor;
2051
+ }
2052
+ get oldestAvailable() {
2053
+ return Math.max(0, this.writeCursor - this.capacityBytes);
2054
+ }
2055
+ get isClosed() {
2056
+ return this.closed;
2057
+ }
2058
+ // Append-or-noop. Calls after close() are silently dropped (the
2059
+ // producer ought not to keep writing, but it's not worth throwing if
2060
+ // a chunk arrives late).
2061
+ append(chunk) {
2062
+ if (this.closed || chunk.length === 0) {
2063
+ return;
2064
+ }
2065
+ this.writeRing(chunk);
2066
+ this.writeCursor += chunk.length;
2067
+ if (this.filePath !== void 0) {
2068
+ this.scheduleFileWrite(chunk);
2069
+ }
2070
+ this.wakeWaiters("data");
2071
+ }
2072
+ close() {
2073
+ if (this.closed) {
2074
+ return;
2075
+ }
2076
+ this.closed = true;
2077
+ this.wakeWaiters("eof");
2078
+ }
2079
+ // Read up to `maxBytes` bytes starting at `cursor`. If `cursor` is
2080
+ // behind the oldest still-resident byte, gap-skip to the oldest and
2081
+ // report how many bytes were dropped. If `cursor` is at writeCursor
2082
+ // and the buffer is closed, return eof:true.
2083
+ read(cursor, maxBytes) {
2084
+ const cap = Math.max(0, Math.min(maxBytes, STREAM_READ_MAX_BYTES));
2085
+ if (cap === 0) {
2086
+ const tail = {
2087
+ bytes: Buffer.alloc(0),
2088
+ nextCursor: cursor
2089
+ };
2090
+ if (this.closed && cursor >= this.writeCursor) {
2091
+ tail.eof = true;
2092
+ }
2093
+ return tail;
2094
+ }
2095
+ let from = cursor;
2096
+ let gap = 0;
2097
+ const oldest = this.oldestAvailable;
2098
+ if (from < oldest) {
2099
+ gap = oldest - from;
2100
+ from = oldest;
2101
+ }
2102
+ const available = this.writeCursor - from;
2103
+ if (available <= 0) {
2104
+ const result2 = {
2105
+ bytes: Buffer.alloc(0),
2106
+ nextCursor: from
2107
+ };
2108
+ if (gap > 0) {
2109
+ result2.gap = gap;
2110
+ }
2111
+ if (this.closed) {
2112
+ result2.eof = true;
2113
+ }
2114
+ return result2;
2115
+ }
2116
+ const take = Math.min(available, cap);
2117
+ const bytes = this.sliceFromRing(from, take);
2118
+ const result = {
2119
+ bytes,
2120
+ nextCursor: from + take
2121
+ };
2122
+ if (gap > 0) {
2123
+ result.gap = gap;
2124
+ }
2125
+ if (this.closed && from + take >= this.writeCursor) {
2126
+ result.eof = true;
2127
+ }
2128
+ return result;
2129
+ }
2130
+ // Latest N bytes from the tail, capped at capacity / STREAM_READ_MAX_BYTES.
2131
+ // truncated:true when the requested span extends past the oldest
2132
+ // still-resident byte (i.e. there was more upstream that we don't have
2133
+ // anymore).
2134
+ tail(bytes) {
2135
+ const want = Math.max(0, Math.min(bytes, STREAM_READ_MAX_BYTES));
2136
+ const oldest = this.oldestAvailable;
2137
+ const startWant = this.writeCursor - want;
2138
+ const start = Math.max(oldest, startWant);
2139
+ const truncated = startWant < oldest;
2140
+ const slice = this.sliceFromRing(start, this.writeCursor - start);
2141
+ return {
2142
+ bytes: slice,
2143
+ startCursor: start,
2144
+ endCursor: this.writeCursor,
2145
+ truncated
2146
+ };
2147
+ }
2148
+ // First N bytes since the stream began. Returns truncated:true when
2149
+ // the head has already been evicted (cursor 0 is no longer resident).
2150
+ head(bytes) {
2151
+ const want = Math.max(0, Math.min(bytes, STREAM_READ_MAX_BYTES));
2152
+ const oldest = this.oldestAvailable;
2153
+ const truncated = oldest > 0;
2154
+ const start = oldest;
2155
+ const end = Math.min(this.writeCursor, start + want);
2156
+ const slice = this.sliceFromRing(start, end - start);
2157
+ return {
2158
+ bytes: slice,
2159
+ startCursor: start,
2160
+ endCursor: end,
2161
+ truncated
2162
+ };
2163
+ }
2164
+ // Long-poll until new bytes arrive past `cursor`, the buffer closes, or
2165
+ // the timeout expires. Resolves with "data" / "eof" / "timeout".
2166
+ waitForData(cursor, timeoutMs) {
2167
+ if (cursor < this.writeCursor) {
2168
+ return Promise.resolve("data");
2169
+ }
2170
+ if (this.closed) {
2171
+ return Promise.resolve("eof");
2172
+ }
2173
+ const cap = Math.max(0, Math.min(timeoutMs, STREAM_WAIT_MAX_MS));
2174
+ if (cap === 0) {
2175
+ return Promise.resolve("timeout");
2176
+ }
2177
+ return new Promise((resolve3) => {
2178
+ const waiter = {
2179
+ resolve: (outcome) => {
2180
+ if (waiter.timer !== void 0) {
2181
+ clearTimeout(waiter.timer);
2182
+ waiter.timer = void 0;
2183
+ }
2184
+ resolve3(outcome);
2185
+ },
2186
+ timer: setTimeout(() => {
2187
+ const idx = this.waiters.indexOf(waiter);
2188
+ if (idx >= 0) {
2189
+ this.waiters.splice(idx, 1);
2190
+ }
2191
+ waiter.timer = void 0;
2192
+ resolve3("timeout");
2193
+ }, cap)
2194
+ };
2195
+ this.waiters.push(waiter);
2196
+ });
2197
+ }
2198
+ wakeWaiters(outcome) {
2199
+ if (this.waiters.length === 0) {
2200
+ return;
2201
+ }
2202
+ const wake = this.waiters;
2203
+ this.waiters = [];
2204
+ for (const w of wake) {
2205
+ w.resolve(outcome);
2206
+ }
2207
+ }
2208
+ writeRing(chunk) {
2209
+ const len = chunk.length;
2210
+ if (len >= this.capacityBytes) {
2211
+ const tailStart = len - this.capacityBytes;
2212
+ chunk.copy(this.storage, 0, tailStart, len);
2213
+ return;
2214
+ }
2215
+ const offset = this.writeCursor % this.capacityBytes;
2216
+ const tailRoom = this.capacityBytes - offset;
2217
+ if (len <= tailRoom) {
2218
+ chunk.copy(this.storage, offset, 0, len);
2219
+ } else {
2220
+ chunk.copy(this.storage, offset, 0, tailRoom);
2221
+ chunk.copy(this.storage, 0, tailRoom, len);
2222
+ }
2223
+ }
2224
+ sliceFromRing(fromCursor, length) {
2225
+ if (length <= 0) {
2226
+ return Buffer.alloc(0);
2227
+ }
2228
+ const out = Buffer.alloc(length);
2229
+ const offset = fromCursor % this.capacityBytes;
2230
+ const tailLen = Math.min(length, this.capacityBytes - offset);
2231
+ this.storage.copy(out, 0, offset, offset + tailLen);
2232
+ if (tailLen < length) {
2233
+ this.storage.copy(out, tailLen, 0, length - tailLen);
2234
+ }
2235
+ return out;
2236
+ }
2237
+ scheduleFileWrite(chunk) {
2238
+ const path13 = this.filePath;
2239
+ if (path13 === void 0) {
2240
+ return;
2241
+ }
2242
+ if (this.fileCapReached) {
2243
+ return;
2244
+ }
2245
+ const remaining = this.fileCapBytes - this.fileBytesWritten;
2246
+ if (remaining <= 0) {
2247
+ this.fileCapReached = true;
2248
+ this.onFileCapReached?.();
2249
+ return;
2250
+ }
2251
+ const slice = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
2252
+ this.fileBytesWritten += slice.length;
2253
+ const willHitCap = this.fileBytesWritten >= this.fileCapBytes;
2254
+ this.fileWriteChain = this.fileWriteChain.then(() => fsp3.appendFile(path13, slice)).catch((err) => {
2255
+ this.logWriteError?.(err);
2256
+ });
2257
+ if (willHitCap && !this.fileCapReached) {
2258
+ this.fileCapReached = true;
2259
+ this.onFileCapReached?.();
2260
+ }
2261
+ }
2262
+ // Wait for any pending file appends to flush. Used by tests and by
2263
+ // session close handlers that want to ensure the file is durable
2264
+ // before unlinking.
2265
+ async drainFileWrites() {
2266
+ await this.fileWriteChain.catch(() => void 0);
2267
+ }
2268
+ };
2269
+
1926
2270
  // src/core/hydra-commands.ts
1927
2271
  var HYDRA_COMMANDS = [
1928
2272
  {
@@ -1992,8 +2336,10 @@ async function deleteQueue(sessionId) {
1992
2336
  }
1993
2337
 
1994
2338
  // src/core/session.ts
2339
+ import * as fsp4 from "fs/promises";
1995
2340
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1996
2341
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
2342
+ var generateChainToken = customAlphabet(HYDRA_ID_ALPHABET, 16);
1997
2343
  var HYDRA_SESSION_PREFIX = "hydra_session_";
1998
2344
  function generateMessageId() {
1999
2345
  return `m_${generateHydraId()}`;
@@ -2002,6 +2348,7 @@ function stripHydraSessionPrefix(id) {
2002
2348
  return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
2003
2349
  }
2004
2350
  var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
2351
+ var TRANSFORMER_CLAIM_TIMEOUT_MS = 5 * 60 * 1e3;
2005
2352
  var RECENTLY_TERMINAL_LIMIT = 64;
2006
2353
  var Session = class {
2007
2354
  sessionId;
@@ -2014,7 +2361,9 @@ var Session = class {
2014
2361
  agent;
2015
2362
  upstreamSessionId;
2016
2363
  agentMeta;
2364
+ agentCapabilities;
2017
2365
  agentArgs;
2366
+ parentSessionId;
2018
2367
  title;
2019
2368
  // Snapshot state delivered to attaching clients via the attach
2020
2369
  // response _meta rather than via history replay (which would be
@@ -2076,6 +2425,10 @@ var Session = class {
2076
2425
  internalPromptCapture;
2077
2426
  idleTimeoutMs;
2078
2427
  idleTimer;
2428
+ // Separate timer that fires session.idle to the transformer chain after
2429
+ // a quiet period. Distinct from idleTimer, which drives session close.
2430
+ idleEventTimer;
2431
+ idleEventTimeoutMs;
2079
2432
  // Time of the last recordable broadcast (or session creation, if
2080
2433
  // none yet). Drives the inactivity-based idle close; deliberately
2081
2434
  // does NOT include snapshot state pings (model/mode/title/commands)
@@ -2084,6 +2437,9 @@ var Session = class {
2084
2437
  lastRecordedAt;
2085
2438
  spawnReplacementAgent;
2086
2439
  logger;
2440
+ transformChain;
2441
+ // Outstanding "processing" claims: token → claim waiting for respondsTo discharge.
2442
+ pendingClaims = /* @__PURE__ */ new Map();
2087
2443
  agentChangeHandlers = [];
2088
2444
  // Last available_commands_update we observed from the agent. Stored
2089
2445
  // so we can re-broadcast a merged (hydra ∪ agent) list whenever
@@ -2110,6 +2466,7 @@ var Session = class {
2110
2466
  modelHandlers = [];
2111
2467
  modeHandlers = [];
2112
2468
  usageHandlers = [];
2469
+ cumulativeCost = 0;
2113
2470
  // Set by amendPrompt at the start of a cancel-and-resubmit dance.
2114
2471
  // broadcastTurnComplete reads it to attach the _meta.amended marker
2115
2472
  // to the cancelled turn's turn_complete notification, and to fire the
@@ -2124,6 +2481,11 @@ var Session = class {
2124
2481
  // older entries fall out and resolve to target_not_found, which is
2125
2482
  // the correct behavior.
2126
2483
  recentlyTerminal = /* @__PURE__ */ new Map();
2484
+ // Optional ring buffer for piped stdin, populated by openStream() when
2485
+ // a cat --stream session attaches. Lifecycle follows the session — the
2486
+ // markClosed path closes the buffer and unlinks any file projection.
2487
+ streamBuffer;
2488
+ streamFilePath;
2127
2489
  constructor(init) {
2128
2490
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
2129
2491
  this.cwd = init.cwd;
@@ -2131,11 +2493,14 @@ var Session = class {
2131
2493
  this.agent = init.agent;
2132
2494
  this.upstreamSessionId = init.upstreamSessionId;
2133
2495
  this.agentMeta = init.agentMeta;
2496
+ this.agentCapabilities = init.agentCapabilities;
2134
2497
  this.agentArgs = init.agentArgs;
2498
+ this.parentSessionId = init.parentSessionId;
2135
2499
  this.title = init.title;
2136
2500
  this.currentModel = init.currentModel;
2137
2501
  this.currentMode = init.currentMode;
2138
2502
  this.currentUsage = init.currentUsage;
2503
+ this.cumulativeCost = init.currentUsage?.cumulativeCost ?? 0;
2139
2504
  if (init.agentCommands && init.agentCommands.length > 0) {
2140
2505
  this.agentAdvertisedCommands = [...init.agentCommands];
2141
2506
  }
@@ -2146,8 +2511,10 @@ var Session = class {
2146
2511
  this.agentAdvertisedModels = [...init.agentModels];
2147
2512
  }
2148
2513
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
2514
+ this.idleEventTimeoutMs = init.idleEventTimeoutMs ?? 3e4;
2149
2515
  this.spawnReplacementAgent = init.spawnReplacementAgent;
2150
2516
  this.logger = init.logger;
2517
+ this.transformChain = init.transformChain ?? [];
2151
2518
  if (init.firstPromptSeeded) {
2152
2519
  this.firstPromptSeeded = true;
2153
2520
  }
@@ -2159,6 +2526,7 @@ var Session = class {
2159
2526
  this.lastRecordedAt = this.updatedAt;
2160
2527
  this.wireAgent(this.agent);
2161
2528
  this.scheduleIdleCheck();
2529
+ this.notifyChain("session.opened", {});
2162
2530
  }
2163
2531
  broadcastMergedCommands() {
2164
2532
  const merged = [
@@ -2211,34 +2579,7 @@ var Session = class {
2211
2579
  captureInternalChunk(this.internalPromptCapture, params);
2212
2580
  return;
2213
2581
  }
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);
2582
+ void this.runResponseChain(params);
2242
2583
  });
2243
2584
  agent.connection.onRequest("session/request_permission", async (params) => {
2244
2585
  return this.handlePermissionRequest(params);
@@ -2250,6 +2591,105 @@ var Session = class {
2250
2591
  this.markClosed({ deleteRecord: false });
2251
2592
  });
2252
2593
  }
2594
+ // Runs the response-side transformer chain, then the snapshot interceptors,
2595
+ // then recordAndBroadcast. All state mutation happens after the chain exits.
2596
+ // See forwardRequest for originatedBy / startIdx semantics.
2597
+ async runResponseChain(params, originatedBy = /* @__PURE__ */ new Set(), startIdx = 0) {
2598
+ let envelope = params;
2599
+ for (let i = startIdx; i < this.transformChain.length; i++) {
2600
+ const t = this.transformChain[i];
2601
+ if (originatedBy.has(t.name)) {
2602
+ continue;
2603
+ }
2604
+ if (!t.intercepts.has("response:session/update")) {
2605
+ continue;
2606
+ }
2607
+ const token = `t_${generateChainToken()}`;
2608
+ let result;
2609
+ try {
2610
+ result = await t.connection.request("transformer/message", {
2611
+ token,
2612
+ phase: "response",
2613
+ method: "session/update",
2614
+ direction: "agent\u2192client",
2615
+ sessionId: this.sessionId,
2616
+ envelope
2617
+ });
2618
+ } catch (err) {
2619
+ this.logger?.warn(
2620
+ `transformer ${t.name} error on response:session/update: ${err.message}`
2621
+ );
2622
+ continue;
2623
+ }
2624
+ const action = result?.action ?? "continue";
2625
+ if (action === "stop") {
2626
+ return;
2627
+ }
2628
+ if (action === "processing") {
2629
+ const claimIdx = i;
2630
+ const claimEnvelope = envelope;
2631
+ const claimOriginatedBy = new Set(originatedBy);
2632
+ await new Promise((resolve3) => {
2633
+ const timer = setTimeout(() => {
2634
+ if (this.pendingClaims.delete(token)) {
2635
+ this.broadcastQueueNotification(
2636
+ "hydra-acp/transformer_abandoned_request",
2637
+ { sessionId: this.sessionId, token, transformerName: t.name }
2638
+ );
2639
+ void this.runResponseChain(
2640
+ claimEnvelope,
2641
+ /* @__PURE__ */ new Set([...claimOriginatedBy, t.name]),
2642
+ claimIdx + 1
2643
+ ).then(resolve3);
2644
+ }
2645
+ }, TRANSFORMER_CLAIM_TIMEOUT_MS);
2646
+ if (typeof timer.unref === "function") {
2647
+ timer.unref();
2648
+ }
2649
+ this.pendingClaims.set(token, {
2650
+ resolve: () => resolve3(),
2651
+ timer,
2652
+ transformerName: t.name,
2653
+ method: "session/update",
2654
+ envelope: claimEnvelope,
2655
+ chainIdx: claimIdx,
2656
+ originatedBy: claimOriginatedBy,
2657
+ side: "response"
2658
+ });
2659
+ });
2660
+ return;
2661
+ }
2662
+ originatedBy.add(t.name);
2663
+ }
2664
+ const agentCmds = extractAdvertisedCommands(envelope);
2665
+ if (agentCmds !== null) {
2666
+ this.setAgentAdvertisedCommands(agentCmds);
2667
+ return;
2668
+ }
2669
+ const agentModes = extractAdvertisedModes(envelope);
2670
+ if (agentModes !== null) {
2671
+ this.setAgentAdvertisedModes(agentModes);
2672
+ return;
2673
+ }
2674
+ if (this.maybeApplyAgentModel(envelope)) {
2675
+ this.recordAndBroadcast("session/update", envelope);
2676
+ return;
2677
+ }
2678
+ if (this.maybeApplyAgentMode(envelope)) {
2679
+ this.recordAndBroadcast("session/update", envelope);
2680
+ return;
2681
+ }
2682
+ if (this.maybeApplyAgentConfigOption(envelope)) {
2683
+ this.recordAndBroadcast("session/update", envelope);
2684
+ return;
2685
+ }
2686
+ if (this.maybeApplyAgentUsage(envelope)) {
2687
+ this.recordAndBroadcast("session/update", this.injectCumulativeCost(envelope));
2688
+ return;
2689
+ }
2690
+ this.maybeApplyAgentSessionInfo(envelope);
2691
+ this.recordAndBroadcast("session/update", envelope);
2692
+ }
2253
2693
  onAgentChange(handler) {
2254
2694
  this.agentChangeHandlers.push(handler);
2255
2695
  }
@@ -2437,8 +2877,8 @@ var Session = class {
2437
2877
  recordedAt
2438
2878
  });
2439
2879
  }
2440
- if (this.currentUsage !== void 0) {
2441
- const u = this.currentUsage;
2880
+ if (this.currentUsage !== void 0 || this.cumulativeCost > 0) {
2881
+ const u = this.currentUsage ?? {};
2442
2882
  const update = {
2443
2883
  sessionUpdate: "usage_update"
2444
2884
  };
@@ -2450,8 +2890,8 @@ var Session = class {
2450
2890
  }
2451
2891
  if (typeof u.costAmount === "number" || typeof u.costCurrency === "string") {
2452
2892
  const cost = {};
2453
- if (typeof u.costAmount === "number") {
2454
- cost.amount = u.costAmount;
2893
+ if (typeof u.costAmount === "number" || this.cumulativeCost) {
2894
+ cost.amount = this.cumulativeCost + (u.costAmount ?? 0);
2455
2895
  }
2456
2896
  if (typeof u.costCurrency === "string") {
2457
2897
  cost.currency = u.costCurrency;
@@ -2990,8 +3430,142 @@ var Session = class {
2990
3430
  sessionId: this.upstreamSessionId
2991
3431
  });
2992
3432
  }
2993
- async forwardRequest(method, params) {
2994
- return this.agent.connection.request(method, this.rewriteForAgent(params));
3433
+ // Walk the request-side chain then forward to the agent.
3434
+ // originatedBy: transformer names already in the lineage — skipped for loop
3435
+ // prevention and to implement resume-routing on re-entry from emit_message.
3436
+ // startIdx: chain position to start from (0 for normal, emitterIdx+1 for re-entry).
3437
+ async forwardRequest(method, params, originatedBy = /* @__PURE__ */ new Set(), startIdx = 0) {
3438
+ let envelope = this.rewriteForAgent(params);
3439
+ for (let i = startIdx; i < this.transformChain.length; i++) {
3440
+ const t = this.transformChain[i];
3441
+ if (originatedBy.has(t.name)) {
3442
+ continue;
3443
+ }
3444
+ const intercept = `request:${method}`;
3445
+ if (!t.intercepts.has(intercept)) {
3446
+ continue;
3447
+ }
3448
+ const token = `t_${generateChainToken()}`;
3449
+ let result;
3450
+ try {
3451
+ result = await t.connection.request("transformer/message", {
3452
+ token,
3453
+ phase: "request",
3454
+ method,
3455
+ direction: "client\u2192agent",
3456
+ sessionId: this.sessionId,
3457
+ envelope
3458
+ });
3459
+ } catch (err) {
3460
+ this.logger?.warn(
3461
+ `transformer ${t.name} error on ${intercept}: ${err.message}`
3462
+ );
3463
+ continue;
3464
+ }
3465
+ const action = result?.action ?? "continue";
3466
+ if (action === "stop") {
3467
+ return result?.payload ?? defaultStopPayload(method);
3468
+ }
3469
+ if (action === "processing") {
3470
+ const claimIdx = i;
3471
+ const claimEnvelope = envelope;
3472
+ const claimOriginatedBy = new Set(originatedBy);
3473
+ return new Promise((resolve3) => {
3474
+ const timer = setTimeout(() => {
3475
+ if (this.pendingClaims.delete(token)) {
3476
+ this.broadcastQueueNotification(
3477
+ "hydra-acp/transformer_abandoned_request",
3478
+ { sessionId: this.sessionId, token, transformerName: t.name }
3479
+ );
3480
+ void this.forwardRequest(
3481
+ method,
3482
+ claimEnvelope,
3483
+ /* @__PURE__ */ new Set([...claimOriginatedBy, t.name]),
3484
+ claimIdx + 1
3485
+ ).then(resolve3).catch(() => resolve3(defaultStopPayload(method)));
3486
+ }
3487
+ }, TRANSFORMER_CLAIM_TIMEOUT_MS);
3488
+ if (typeof timer.unref === "function") {
3489
+ timer.unref();
3490
+ }
3491
+ this.pendingClaims.set(token, {
3492
+ resolve: resolve3,
3493
+ timer,
3494
+ transformerName: t.name,
3495
+ method,
3496
+ envelope: claimEnvelope,
3497
+ chainIdx: claimIdx,
3498
+ originatedBy: claimOriginatedBy,
3499
+ side: "request"
3500
+ });
3501
+ });
3502
+ }
3503
+ originatedBy.add(t.name);
3504
+ }
3505
+ return this.agent.connection.request(method, envelope);
3506
+ }
3507
+ // Called by the WS handler when emit_message carries respondsTo.
3508
+ // Discharges the outstanding claim so the original requester unblocks.
3509
+ dischargeClaim(token, result) {
3510
+ const claim = this.pendingClaims.get(token);
3511
+ if (!claim) {
3512
+ return false;
3513
+ }
3514
+ clearTimeout(claim.timer);
3515
+ this.pendingClaims.delete(token);
3516
+ claim.resolve(result);
3517
+ return true;
3518
+ }
3519
+ // Called by the WS handler on hydra-acp/keep_alive.
3520
+ // Resets the abandonment timer for an outstanding processing claim.
3521
+ keepAliveClaim(token, estimatedRemainingMs) {
3522
+ const claim = this.pendingClaims.get(token);
3523
+ if (!claim) {
3524
+ return false;
3525
+ }
3526
+ clearTimeout(claim.timer);
3527
+ const timeout = typeof estimatedRemainingMs === "number" && estimatedRemainingMs > 0 ? Math.min(estimatedRemainingMs * 1.5, 30 * 60 * 1e3) : TRANSFORMER_CLAIM_TIMEOUT_MS;
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: claim.transformerName }
3533
+ );
3534
+ if (claim.side === "response") {
3535
+ void this.runResponseChain(
3536
+ claim.envelope,
3537
+ /* @__PURE__ */ new Set([...claim.originatedBy, claim.transformerName]),
3538
+ claim.chainIdx + 1
3539
+ ).then(() => claim.resolve(void 0));
3540
+ } else {
3541
+ void this.forwardRequest(
3542
+ claim.method,
3543
+ claim.envelope,
3544
+ /* @__PURE__ */ new Set([...claim.originatedBy, claim.transformerName]),
3545
+ claim.chainIdx + 1
3546
+ ).then(claim.resolve).catch(() => claim.resolve(defaultStopPayload(claim.method)));
3547
+ }
3548
+ }
3549
+ }, timeout);
3550
+ if (typeof timer.unref === "function") {
3551
+ timer.unref();
3552
+ }
3553
+ claim.timer = timer;
3554
+ return true;
3555
+ }
3556
+ // Called by the WS handler when a transformer emits via route:"chain".
3557
+ // Finds the emitter's position and re-enters the appropriate chain walk
3558
+ // from the next slot, with the emitter in originatedBy so it cannot see
3559
+ // its own re-emission.
3560
+ async emitToChain(emitterName, method, envelope) {
3561
+ const emitterIdx = this.transformChain.findIndex((t) => t.name === emitterName);
3562
+ const startIdx = emitterIdx >= 0 ? emitterIdx + 1 : 0;
3563
+ const originatedBy = /* @__PURE__ */ new Set([emitterName]);
3564
+ if (method === "session/update") {
3565
+ await this.runResponseChain(envelope, originatedBy, startIdx);
3566
+ return;
3567
+ }
3568
+ await this.forwardRequest(method, envelope, originatedBy, startIdx);
2995
3569
  }
2996
3570
  rewriteForAgent(params) {
2997
3571
  if (params && typeof params === "object" && !Array.isArray(params)) {
@@ -3232,6 +3806,65 @@ var Session = class {
3232
3806
  }
3233
3807
  return true;
3234
3808
  }
3809
+ // Move currentUsage.costAmount into cumulativeCost and clear it so the
3810
+ // next agent life starts accumulating from $0. Fires usageHandlers so
3811
+ // meta.json is updated before the new agent starts emitting.
3812
+ accumulateAndResetCost() {
3813
+ const amount = this.currentUsage?.costAmount;
3814
+ if (!amount)
3815
+ return;
3816
+ this.cumulativeCost += amount;
3817
+ const next = {
3818
+ ...this.currentUsage ?? {},
3819
+ cumulativeCost: this.cumulativeCost,
3820
+ costAmount: void 0
3821
+ };
3822
+ this.currentUsage = next;
3823
+ for (const handler of this.usageHandlers) {
3824
+ try {
3825
+ handler(next);
3826
+ } catch {
3827
+ }
3828
+ }
3829
+ }
3830
+ // Returns a modified envelope with cost.amount replaced by the running
3831
+ // total (cumulativeCost + raw agent amount). No-op if the envelope
3832
+ // doesn't carry a numeric cost.amount.
3833
+ injectCumulativeCost(envelope) {
3834
+ if (!this.cumulativeCost)
3835
+ return envelope;
3836
+ if (!envelope || typeof envelope !== "object")
3837
+ return envelope;
3838
+ const obj = envelope;
3839
+ if (!obj.update || typeof obj.update !== "object")
3840
+ return envelope;
3841
+ const update = obj.update;
3842
+ if (update.sessionUpdate !== "usage_update")
3843
+ return envelope;
3844
+ if (!update.cost || typeof update.cost !== "object")
3845
+ return envelope;
3846
+ const cost = update.cost;
3847
+ if (typeof cost.amount !== "number")
3848
+ return envelope;
3849
+ return {
3850
+ ...obj,
3851
+ update: {
3852
+ ...update,
3853
+ cost: { ...cost, amount: this.cumulativeCost + cost.amount }
3854
+ }
3855
+ };
3856
+ }
3857
+ // Total cost across all agent lives for this hydra session. Used by
3858
+ // session/list so list rows show the accumulated figure.
3859
+ get totalUsage() {
3860
+ if (!this.currentUsage && !this.cumulativeCost)
3861
+ return void 0;
3862
+ const base = this.currentUsage ?? {};
3863
+ return {
3864
+ ...base,
3865
+ costAmount: this.cumulativeCost + (this.currentUsage?.costAmount ?? 0)
3866
+ };
3867
+ }
3235
3868
  // Update the cached agent command list, fire persist handlers, and
3236
3869
  // broadcast the merged list to attached clients. Idempotent on a
3237
3870
  // structurally identical list so we don't churn meta.json on noisy
@@ -3463,12 +4096,14 @@ var Session = class {
3463
4096
  cwd: this.cwd,
3464
4097
  agentArgs: this.agentArgs
3465
4098
  });
4099
+ this.accumulateAndResetCost();
3466
4100
  this.wireAgent(fresh.agent);
3467
4101
  const oldAgent = this.agent;
3468
4102
  this.agent = fresh.agent;
3469
4103
  this.agentId = newAgentId;
3470
4104
  this.upstreamSessionId = fresh.upstreamSessionId;
3471
4105
  this.agentMeta = fresh.agentMeta;
4106
+ this.agentCapabilities = fresh.agentCapabilities;
3472
4107
  this.agentAdvertisedCommands = [];
3473
4108
  this.broadcastMergedCommands();
3474
4109
  if (this.agentAdvertisedModels.length > 0) {
@@ -3636,12 +4271,120 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3636
4271
  }
3637
4272
  });
3638
4273
  }
4274
+ // stdin-stream lifecycle. Cat --stream calls openStream() once after
4275
+ // session/new, then forwards stdin chunks via streamWrite(). The agent
4276
+ // either consumes via the file path (when shell tools are available)
4277
+ // or — once the MCP surface lands — via tool calls that route through
4278
+ // streamRead() / streamTail() / streamHead() / streamWaitFor().
4279
+ hasStreamBuffer() {
4280
+ return this.streamBuffer !== void 0;
4281
+ }
4282
+ get streamPath() {
4283
+ return this.streamFilePath;
4284
+ }
4285
+ openStream(opts) {
4286
+ if (this.streamBuffer !== void 0) {
4287
+ throw new Error(
4288
+ `stream buffer already open for session ${this.sessionId}`
4289
+ );
4290
+ }
4291
+ const mode = opts.mode ?? "memory";
4292
+ const filePath = mode === "file" && opts.filePathFor !== void 0 ? opts.filePathFor(this.sessionId) : void 0;
4293
+ const bufferOpts = {};
4294
+ if (opts.capacityBytes !== void 0) {
4295
+ bufferOpts.capacityBytes = opts.capacityBytes;
4296
+ }
4297
+ if (filePath !== void 0) {
4298
+ bufferOpts.filePath = filePath;
4299
+ }
4300
+ if (opts.fileCapBytes !== void 0) {
4301
+ bufferOpts.fileCapBytes = opts.fileCapBytes;
4302
+ }
4303
+ bufferOpts.onFileCapReached = () => {
4304
+ this.recordAndBroadcast("session/update", {
4305
+ sessionId: this.upstreamSessionId,
4306
+ update: {
4307
+ sessionUpdate: "stream_truncated",
4308
+ ...filePath !== void 0 ? { filePath } : {},
4309
+ fileCapBytes: opts.fileCapBytes
4310
+ }
4311
+ });
4312
+ };
4313
+ const buf = new SessionStreamBuffer(bufferOpts);
4314
+ this.streamBuffer = buf;
4315
+ this.streamFilePath = filePath;
4316
+ const result = {
4317
+ capacityBytes: buf.capacity
4318
+ };
4319
+ if (filePath !== void 0) {
4320
+ result.filePath = filePath;
4321
+ }
4322
+ if (opts.fileCapBytes !== void 0) {
4323
+ result.fileCapBytes = opts.fileCapBytes;
4324
+ }
4325
+ return result;
4326
+ }
4327
+ streamWrite(chunkB64, eof) {
4328
+ const buf = this.requireStreamBuffer();
4329
+ if (chunkB64.length > 0) {
4330
+ const chunk = Buffer.from(chunkB64, "base64");
4331
+ buf.append(chunk);
4332
+ }
4333
+ if (eof === true) {
4334
+ buf.close();
4335
+ }
4336
+ return { writeCursor: buf.writeCursorPos };
4337
+ }
4338
+ async streamRead(cursor, maxBytes, waitMs) {
4339
+ const buf = this.requireStreamBuffer();
4340
+ const cap = Math.max(
4341
+ 0,
4342
+ Math.min(maxBytes ?? STREAM_READ_MAX_BYTES, STREAM_READ_MAX_BYTES)
4343
+ );
4344
+ let r = buf.read(cursor, cap);
4345
+ if (r.bytes.length === 0 && r.eof !== true && waitMs !== void 0 && waitMs > 0) {
4346
+ const outcome = await buf.waitForData(r.nextCursor, waitMs);
4347
+ if (outcome === "data" || outcome === "eof") {
4348
+ r = buf.read(r.nextCursor, cap);
4349
+ }
4350
+ }
4351
+ const out = {
4352
+ bytes: r.bytes.toString("base64"),
4353
+ nextCursor: r.nextCursor
4354
+ };
4355
+ if (r.gap !== void 0) {
4356
+ out.gap = r.gap;
4357
+ }
4358
+ if (r.eof === true) {
4359
+ out.eof = true;
4360
+ }
4361
+ return out;
4362
+ }
4363
+ requireStreamBuffer() {
4364
+ if (this.streamBuffer === void 0) {
4365
+ const err = new Error(
4366
+ `session ${this.sessionId} has no stream buffer; call hydra-acp/stream_open first`
4367
+ );
4368
+ err.code = JsonRpcErrorCodes.StreamNotEnabled;
4369
+ throw err;
4370
+ }
4371
+ return this.streamBuffer;
4372
+ }
3639
4373
  markClosed(opts) {
3640
4374
  if (this.closed) {
3641
4375
  return;
3642
4376
  }
3643
4377
  this.closed = true;
3644
4378
  this.cancelIdleTimer();
4379
+ if (this.currentEntry?.kind === "user") {
4380
+ this.broadcastTurnComplete(
4381
+ this.currentEntry.clientId,
4382
+ { stopReason: "interrupted" },
4383
+ this.currentEntry.messageId,
4384
+ this.currentEntry.wasAmend
4385
+ );
4386
+ this.currentEntry = void 0;
4387
+ }
3645
4388
  const stranded = this.promptQueue;
3646
4389
  this.promptQueue = [];
3647
4390
  for (const entry of stranded) {
@@ -3654,12 +4397,23 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3654
4397
  } catch {
3655
4398
  }
3656
4399
  }
4400
+ this.notifyChain("session.closed", {});
3657
4401
  const sessionId = this.sessionId;
3658
4402
  this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
3659
4403
  for (const client of this.clients.values()) {
3660
4404
  void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
3661
4405
  }
3662
4406
  this.clients.clear();
4407
+ if (this.streamBuffer !== void 0) {
4408
+ const buf = this.streamBuffer;
4409
+ const path13 = this.streamFilePath;
4410
+ this.streamBuffer = void 0;
4411
+ this.streamFilePath = void 0;
4412
+ buf.close();
4413
+ if (path13 !== void 0) {
4414
+ void buf.drainFileWrites().then(() => fsp4.unlink(path13).catch(() => void 0));
4415
+ }
4416
+ }
3663
4417
  for (const handler of this.closeHandlers) {
3664
4418
  handler(opts);
3665
4419
  }
@@ -3722,6 +4476,44 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3722
4476
  clearTimeout(this.idleTimer);
3723
4477
  this.idleTimer = void 0;
3724
4478
  }
4479
+ this.cancelIdleEventTimer();
4480
+ }
4481
+ // ── Lifecycle event timer ────────────────────────────────────────────────
4482
+ scheduleIdleEvent() {
4483
+ if (this.closed || this.idleEventTimeoutMs <= 0 || this.transformChain.length === 0) {
4484
+ return;
4485
+ }
4486
+ if (this.idleEventTimer) {
4487
+ clearTimeout(this.idleEventTimer);
4488
+ }
4489
+ this.idleEventTimer = setTimeout(() => {
4490
+ this.idleEventTimer = void 0;
4491
+ this.notifyChain("session.idle", {});
4492
+ }, this.idleEventTimeoutMs);
4493
+ if (typeof this.idleEventTimer.unref === "function") {
4494
+ this.idleEventTimer.unref();
4495
+ }
4496
+ }
4497
+ cancelIdleEventTimer() {
4498
+ if (this.idleEventTimer) {
4499
+ clearTimeout(this.idleEventTimer);
4500
+ this.idleEventTimer = void 0;
4501
+ }
4502
+ }
4503
+ // Send a lifecycle notification to every transformer in the chain that
4504
+ // declared the matching intercept. Fire-and-forget — no response expected.
4505
+ notifyChain(event, payload) {
4506
+ const intercept = `lifecycle:${event}`;
4507
+ for (const t of this.transformChain) {
4508
+ if (!t.intercepts.has(intercept)) {
4509
+ continue;
4510
+ }
4511
+ void t.connection.notify("transformer/session_event", {
4512
+ event,
4513
+ sessionId: this.sessionId,
4514
+ payload
4515
+ }).catch(() => void 0);
4516
+ }
3725
4517
  }
3726
4518
  rewriteForClient(params) {
3727
4519
  if (params && typeof params === "object" && !Array.isArray(params)) {
@@ -3761,6 +4553,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3761
4553
  }
3762
4554
  }
3763
4555
  this.scheduleIdleCheck();
4556
+ this.scheduleIdleEvent();
3764
4557
  }
3765
4558
  this.updatedAt = Date.now();
3766
4559
  for (const client of this.clients.values()) {
@@ -3917,6 +4710,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3917
4710
  return;
3918
4711
  }
3919
4712
  this.promptInFlight = true;
4713
+ await new Promise((r) => setImmediate(r));
3920
4714
  try {
3921
4715
  while (this.promptQueue.length > 0) {
3922
4716
  const next = this.promptQueue.shift();
@@ -3936,7 +4730,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3936
4730
  try {
3937
4731
  const result = await this.runQueueEntry(next);
3938
4732
  next.resolve(result);
3939
- await Promise.resolve();
4733
+ await new Promise((r) => setImmediate(r));
3940
4734
  } catch (err) {
3941
4735
  next.reject(err);
3942
4736
  } finally {
@@ -3973,21 +4767,25 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3973
4767
  }
3974
4768
  );
3975
4769
  } catch (err) {
4770
+ if (!this.closed) {
4771
+ this.broadcastTurnComplete(
4772
+ entry.clientId,
4773
+ { stopReason: "error" },
4774
+ entry.messageId,
4775
+ entry.wasAmend
4776
+ );
4777
+ }
4778
+ this.clearAmendIfMatches(entry.messageId);
4779
+ throw err;
4780
+ }
4781
+ if (!this.closed) {
3976
4782
  this.broadcastTurnComplete(
3977
4783
  entry.clientId,
3978
- { stopReason: "error" },
4784
+ response,
3979
4785
  entry.messageId,
3980
4786
  entry.wasAmend
3981
4787
  );
3982
- this.clearAmendIfMatches(entry.messageId);
3983
- throw err;
3984
4788
  }
3985
- this.broadcastTurnComplete(
3986
- entry.clientId,
3987
- response,
3988
- entry.messageId,
3989
- entry.wasAmend
3990
- );
3991
4789
  this.clearAmendIfMatches(entry.messageId);
3992
4790
  return response;
3993
4791
  }
@@ -4252,6 +5050,12 @@ function extractPromptText(prompt) {
4252
5050
  return "";
4253
5051
  }).join("");
4254
5052
  }
5053
+ function defaultStopPayload(method) {
5054
+ if (method === "session/prompt") {
5055
+ return { stopReason: "stopped" };
5056
+ }
5057
+ return {};
5058
+ }
4255
5059
  function firstLine(text, max) {
4256
5060
  for (const raw of text.split(/\r?\n/)) {
4257
5061
  const line = raw.trim();
@@ -4292,7 +5096,8 @@ var PersistedUsage = z4.object({
4292
5096
  used: z4.number().optional(),
4293
5097
  size: z4.number().optional(),
4294
5098
  costAmount: z4.number().optional(),
4295
- costCurrency: z4.string().optional()
5099
+ costCurrency: z4.string().optional(),
5100
+ cumulativeCost: z4.number().optional()
4296
5101
  });
4297
5102
  var SessionRecord = z4.object({
4298
5103
  version: z4.literal(1),
@@ -4341,6 +5146,9 @@ var SessionRecord = z4.object({
4341
5146
  // it) so the local history.jsonl gets populated from the agent's
4342
5147
  // memory. Cleared after that first resurrect completes.
4343
5148
  pendingHistorySync: z4.boolean().optional(),
5149
+ // Set when this session was spawned as a child by a transformer via
5150
+ // hydra-acp/spawn_child_session. Points to the spawning session's id.
5151
+ parentSessionId: z4.string().optional(),
4344
5152
  createdAt: z4.string(),
4345
5153
  updatedAt: z4.string()
4346
5154
  });
@@ -4462,6 +5270,7 @@ function recordFromMemorySession(args) {
4462
5270
  agentModes: args.agentModes,
4463
5271
  agentModels: args.agentModels,
4464
5272
  pendingHistorySync: args.pendingHistorySync,
5273
+ parentSessionId: args.parentSessionId,
4465
5274
  createdAt: args.createdAt ?? now,
4466
5275
  updatedAt: args.updatedAt ?? now
4467
5276
  };
@@ -4671,7 +5480,9 @@ var SessionManager = class {
4671
5480
  this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
4672
5481
  this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
4673
5482
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
5483
+ this.idleEventTimeoutMs = options.idleEventTimeoutMs ?? 3e4;
4674
5484
  this.defaultModels = options.defaultModels ?? {};
5485
+ this.defaultTransformers = options.defaultTransformers ?? [];
4675
5486
  this.logger = options.logger;
4676
5487
  this.npmRegistry = options.npmRegistry;
4677
5488
  }
@@ -4683,6 +5494,8 @@ var SessionManager = class {
4683
5494
  histories;
4684
5495
  idleTimeoutMs;
4685
5496
  defaultModels;
5497
+ defaultTransformers;
5498
+ idleEventTimeoutMs;
4686
5499
  sessionHistoryMaxEntries;
4687
5500
  // Serialize meta.json read-modify-write operations per session id so
4688
5501
  // concurrent snapshot updates (e.g. an agent emitting model + mode
@@ -4699,15 +5512,40 @@ var SessionManager = class {
4699
5512
  model: params.model,
4700
5513
  onInstallProgress: params.onInstallProgress
4701
5514
  });
5515
+ if (params.transformChain && params.transformChain.length > 0) {
5516
+ let caps = { ...fresh.agentCapabilities ?? {} };
5517
+ for (const t of params.transformChain) {
5518
+ if (!t.intercepts.has("agent:initialize")) {
5519
+ continue;
5520
+ }
5521
+ try {
5522
+ const result = await t.connection.request("transformer/message", {
5523
+ token: `t_${generateRawSessionId()}`,
5524
+ phase: "response",
5525
+ method: "initialize",
5526
+ direction: "agent\u2192daemon",
5527
+ sessionId: "(pre-session)",
5528
+ envelope: caps
5529
+ });
5530
+ if (result.action === "stop" && result.payload) {
5531
+ caps = result.payload;
5532
+ }
5533
+ } catch {
5534
+ }
5535
+ }
5536
+ fresh.agentCapabilities = caps;
5537
+ }
4702
5538
  const session = new Session({
4703
5539
  cwd: params.cwd,
4704
5540
  agentId: params.agentId,
4705
5541
  agent: fresh.agent,
4706
5542
  upstreamSessionId: fresh.upstreamSessionId,
4707
5543
  agentMeta: fresh.agentMeta,
5544
+ agentCapabilities: fresh.agentCapabilities,
4708
5545
  title: params.title,
4709
5546
  agentArgs: params.agentArgs,
4710
5547
  idleTimeoutMs: this.idleTimeoutMs,
5548
+ idleEventTimeoutMs: this.idleEventTimeoutMs,
4711
5549
  logger: this.logger,
4712
5550
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
4713
5551
  historyStore: this.histories,
@@ -4715,7 +5553,9 @@ var SessionManager = class {
4715
5553
  currentModel: fresh.initialModel,
4716
5554
  currentMode: fresh.initialMode,
4717
5555
  agentModes: fresh.initialModes,
4718
- agentModels: fresh.initialModels
5556
+ agentModels: fresh.initialModels,
5557
+ transformChain: params.transformChain,
5558
+ parentSessionId: params.parentSessionId
4719
5559
  });
4720
5560
  await this.attachManagerHooks(session);
4721
5561
  return session;
@@ -4769,17 +5609,22 @@ var SessionManager = class {
4769
5609
  cwd: params.cwd,
4770
5610
  plan
4771
5611
  });
5612
+ let agentCapabilities;
4772
5613
  try {
4773
- await agent.connection.request("initialize", {
4774
- protocolVersion: ACP_PROTOCOL_VERSION,
4775
- clientCapabilities: {},
4776
- clientInfo: { name: "hydra", version: HYDRA_VERSION }
4777
- });
4778
- } catch (err) {
4779
- await agent.kill().catch(() => void 0);
4780
- throw err;
4781
- }
4782
- let loadResult;
5614
+ const initResult = await agent.connection.request(
5615
+ "initialize",
5616
+ {
5617
+ protocolVersion: ACP_PROTOCOL_VERSION,
5618
+ clientCapabilities: {},
5619
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
5620
+ }
5621
+ );
5622
+ agentCapabilities = initResult.agentCapabilities;
5623
+ } catch (err) {
5624
+ await agent.kill().catch(() => void 0);
5625
+ throw err;
5626
+ }
5627
+ let loadResult;
4783
5628
  try {
4784
5629
  loadResult = await agent.connection.request(
4785
5630
  "session/load",
@@ -4811,6 +5656,7 @@ var SessionManager = class {
4811
5656
  agent,
4812
5657
  upstreamSessionId: params.upstreamSessionId,
4813
5658
  agentMeta: loadResult?._meta,
5659
+ agentCapabilities,
4814
5660
  title: params.title,
4815
5661
  agentArgs: params.agentArgs,
4816
5662
  idleTimeoutMs: this.idleTimeoutMs,
@@ -4861,6 +5707,7 @@ var SessionManager = class {
4861
5707
  agent: fresh.agent,
4862
5708
  upstreamSessionId: fresh.upstreamSessionId,
4863
5709
  agentMeta: fresh.agentMeta,
5710
+ agentCapabilities: fresh.agentCapabilities,
4864
5711
  title: params.title,
4865
5712
  agentArgs: params.agentArgs,
4866
5713
  idleTimeoutMs: this.idleTimeoutMs,
@@ -5042,11 +5889,15 @@ var SessionManager = class {
5042
5889
  plan
5043
5890
  });
5044
5891
  try {
5045
- await agent.connection.request("initialize", {
5046
- protocolVersion: ACP_PROTOCOL_VERSION,
5047
- clientCapabilities: {},
5048
- clientInfo: { name: "hydra", version: HYDRA_VERSION }
5049
- });
5892
+ const initResult = await agent.connection.request(
5893
+ "initialize",
5894
+ {
5895
+ protocolVersion: ACP_PROTOCOL_VERSION,
5896
+ clientCapabilities: {},
5897
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
5898
+ }
5899
+ );
5900
+ const agentCapabilities = initResult.agentCapabilities;
5050
5901
  const newResult = await agent.connection.request(
5051
5902
  "session/new",
5052
5903
  {
@@ -5088,6 +5939,7 @@ var SessionManager = class {
5088
5939
  agent,
5089
5940
  upstreamSessionId: sessionIdRaw,
5090
5941
  agentMeta: newResult._meta,
5942
+ agentCapabilities,
5091
5943
  initialModel,
5092
5944
  initialModels: initialModels.length > 0 ? initialModels : void 0,
5093
5945
  initialModes: initialModes.length > 0 ? initialModes : void 0,
@@ -5208,7 +6060,13 @@ var SessionManager = class {
5208
6060
  agentArgs: record.agentArgs,
5209
6061
  currentModel: record.currentModel,
5210
6062
  currentMode: record.currentMode,
5211
- currentUsage: persistedUsageToSnapshot(record.currentUsage),
6063
+ currentUsage: persistedUsageToSnapshot(
6064
+ record.currentUsage ? {
6065
+ ...record.currentUsage,
6066
+ cumulativeCost: (record.currentUsage.cumulativeCost ?? 0) + (record.currentUsage.costAmount ?? 0),
6067
+ costAmount: void 0
6068
+ } : void 0
6069
+ ),
5212
6070
  agentCommands: record.agentCommands,
5213
6071
  agentModes: record.agentModes,
5214
6072
  agentModels: record.agentModels,
@@ -5309,7 +6167,8 @@ var SessionManager = class {
5309
6167
  title: session.title,
5310
6168
  agentId: session.agentId,
5311
6169
  currentModel: session.currentModel,
5312
- currentUsage: session.currentUsage,
6170
+ currentUsage: session.totalUsage,
6171
+ parentSessionId: session.parentSessionId,
5313
6172
  updatedAt: used,
5314
6173
  attachedClients: session.attachedCount,
5315
6174
  status: "live",
@@ -5332,9 +6191,13 @@ var SessionManager = class {
5332
6191
  title: r.title,
5333
6192
  agentId: r.agentId,
5334
6193
  currentModel: r.currentModel,
5335
- currentUsage: r.currentUsage,
6194
+ currentUsage: r.currentUsage ? {
6195
+ ...r.currentUsage,
6196
+ costAmount: (r.currentUsage.cumulativeCost ?? 0) + (r.currentUsage.costAmount ?? 0) || void 0
6197
+ } : void 0,
5336
6198
  importedFromMachine: r.importedFromMachine,
5337
6199
  importedFromUpstreamSessionId: r.importedFromUpstreamSessionId,
6200
+ parentSessionId: r.parentSessionId,
5338
6201
  updatedAt: used,
5339
6202
  attachedClients: 0,
5340
6203
  status: "cold",
@@ -5678,6 +6541,7 @@ function mergeForPersistence(session, existing) {
5678
6541
  agentCommands,
5679
6542
  agentModes,
5680
6543
  agentModels,
6544
+ parentSessionId: session.parentSessionId ?? existing?.parentSessionId,
5681
6545
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
5682
6546
  });
5683
6547
  }
@@ -5698,6 +6562,9 @@ function usageSnapshotToPersisted(usage) {
5698
6562
  if (usage.costCurrency !== void 0) {
5699
6563
  out.costCurrency = usage.costCurrency;
5700
6564
  }
6565
+ if (usage.cumulativeCost !== void 0) {
6566
+ out.cumulativeCost = usage.cumulativeCost;
6567
+ }
5701
6568
  return Object.keys(out).length > 0 ? out : void 0;
5702
6569
  }
5703
6570
  function persistedUsageToSnapshot(usage) {
@@ -5888,42 +6755,515 @@ async function loadPromptHistorySafely(sessionId) {
5888
6755
  } catch {
5889
6756
  return [];
5890
6757
  }
5891
- }
5892
- async function historyMtimeIso(sessionId) {
6758
+ }
6759
+ async function historyMtimeIso(sessionId) {
6760
+ try {
6761
+ const st = await fs10.stat(paths.historyFile(sessionId));
6762
+ return new Date(st.mtimeMs).toISOString();
6763
+ } catch {
6764
+ return void 0;
6765
+ }
6766
+ }
6767
+
6768
+ // src/core/extensions.ts
6769
+ import { spawn as spawn4 } from "child_process";
6770
+ import * as fs11 from "fs";
6771
+ import * as fsp5 from "fs/promises";
6772
+ import * as path7 from "path";
6773
+ var RESTART_BASE_MS = 1e3;
6774
+ var RESTART_CAP_MS = 6e4;
6775
+ var STOP_GRACE_MS = 3e3;
6776
+ var ExtensionManager = class {
6777
+ entries = /* @__PURE__ */ new Map();
6778
+ stopping = false;
6779
+ context;
6780
+ tokenRegistry;
6781
+ constructor(extensions, context, options = {}) {
6782
+ this.context = context;
6783
+ this.tokenRegistry = options.tokenRegistry;
6784
+ for (const ext of extensions) {
6785
+ this.entries.set(ext.name, this.makeEntry(ext));
6786
+ }
6787
+ }
6788
+ setContext(context) {
6789
+ this.context = context;
6790
+ }
6791
+ // Called by the WS handler after a process connects and calls initialize
6792
+ // with clientInfo.version. Stored on the entry and surfaced in list().
6793
+ reportVersion(name, version) {
6794
+ const entry = this.entries.get(name);
6795
+ if (entry) {
6796
+ entry.version = version;
6797
+ }
6798
+ }
6799
+ async start() {
6800
+ if (!this.context) {
6801
+ throw new Error("ExtensionManager: setContext must be called before start");
6802
+ }
6803
+ await fsp5.mkdir(paths.extensionsDir(), { recursive: true });
6804
+ await this.reapOrphans();
6805
+ for (const entry of this.entries.values()) {
6806
+ if (!entry.config.enabled) {
6807
+ continue;
6808
+ }
6809
+ this.spawn(entry, 0);
6810
+ }
6811
+ }
6812
+ async stop() {
6813
+ this.stopping = true;
6814
+ const tasks = [];
6815
+ for (const entry of this.entries.values()) {
6816
+ if (entry.restartTimer) {
6817
+ clearTimeout(entry.restartTimer);
6818
+ entry.restartTimer = void 0;
6819
+ }
6820
+ const child = entry.child;
6821
+ if (!child) {
6822
+ continue;
6823
+ }
6824
+ try {
6825
+ child.kill("SIGTERM");
6826
+ } catch {
6827
+ }
6828
+ tasks.push(
6829
+ new Promise((resolve3) => {
6830
+ if (child.exitCode !== null || child.signalCode !== null) {
6831
+ resolve3();
6832
+ return;
6833
+ }
6834
+ const timer = setTimeout(() => {
6835
+ try {
6836
+ child.kill("SIGKILL");
6837
+ } catch {
6838
+ }
6839
+ resolve3();
6840
+ }, STOP_GRACE_MS);
6841
+ child.on("exit", () => {
6842
+ clearTimeout(timer);
6843
+ resolve3();
6844
+ });
6845
+ })
6846
+ );
6847
+ }
6848
+ await Promise.allSettled(tasks);
6849
+ for (const entry of this.entries.values()) {
6850
+ try {
6851
+ entry.logStream?.end();
6852
+ } catch {
6853
+ }
6854
+ entry.child = void 0;
6855
+ entry.logStream = void 0;
6856
+ entry.pid = void 0;
6857
+ }
6858
+ }
6859
+ list() {
6860
+ return [...this.entries.values()].map((entry) => this.infoFor(entry));
6861
+ }
6862
+ get(name) {
6863
+ const entry = this.entries.get(name);
6864
+ return entry ? this.infoFor(entry) : void 0;
6865
+ }
6866
+ has(name) {
6867
+ return this.entries.has(name);
6868
+ }
6869
+ async startByName(name) {
6870
+ const entry = this.entries.get(name);
6871
+ if (!entry) {
6872
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
6873
+ }
6874
+ if (entry.child) {
6875
+ throw withCode2(new Error(`extension ${name} already running`), "CONFLICT");
6876
+ }
6877
+ if (entry.restartTimer) {
6878
+ clearTimeout(entry.restartTimer);
6879
+ entry.restartTimer = void 0;
6880
+ }
6881
+ entry.manuallyStopped = false;
6882
+ entry.restartCount = 0;
6883
+ this.spawn(entry, 0);
6884
+ return this.infoFor(entry);
6885
+ }
6886
+ async stopByName(name) {
6887
+ const entry = this.entries.get(name);
6888
+ if (!entry) {
6889
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
6890
+ }
6891
+ entry.manuallyStopped = true;
6892
+ if (entry.restartTimer) {
6893
+ clearTimeout(entry.restartTimer);
6894
+ entry.restartTimer = void 0;
6895
+ }
6896
+ const child = entry.child;
6897
+ if (!child) {
6898
+ return this.infoFor(entry);
6899
+ }
6900
+ await this.terminate(entry, child);
6901
+ return this.infoFor(entry);
6902
+ }
6903
+ async restartByName(name) {
6904
+ await this.stopByName(name);
6905
+ return this.startByName(name);
6906
+ }
6907
+ // Register a new extension and (if enabled) start it. Used by the
6908
+ // POST /v1/extensions endpoint so `hydra-acp extensions add` can take
6909
+ // effect without a daemon restart.
6910
+ register(config) {
6911
+ if (this.entries.has(config.name)) {
6912
+ throw withCode2(
6913
+ new Error(`extension ${config.name} already exists`),
6914
+ "CONFLICT"
6915
+ );
6916
+ }
6917
+ if (!this.context) {
6918
+ throw new Error("ExtensionManager: setContext must be called before register");
6919
+ }
6920
+ const entry = this.makeEntry(config);
6921
+ this.entries.set(config.name, entry);
6922
+ if (config.enabled) {
6923
+ this.spawn(entry, 0);
6924
+ }
6925
+ return this.infoFor(entry);
6926
+ }
6927
+ async unregister(name) {
6928
+ const entry = this.entries.get(name);
6929
+ if (!entry) {
6930
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
6931
+ }
6932
+ entry.manuallyStopped = true;
6933
+ if (entry.restartTimer) {
6934
+ clearTimeout(entry.restartTimer);
6935
+ entry.restartTimer = void 0;
6936
+ }
6937
+ const child = entry.child;
6938
+ if (child) {
6939
+ await this.terminate(entry, child);
6940
+ }
6941
+ try {
6942
+ entry.logStream?.end();
6943
+ } catch {
6944
+ }
6945
+ this.entries.delete(name);
6946
+ }
6947
+ async terminate(entry, child) {
6948
+ if (child.exitCode !== null || child.signalCode !== null) {
6949
+ return;
6950
+ }
6951
+ const exited = new Promise((resolve3) => {
6952
+ entry.exitWaiters.push(resolve3);
6953
+ });
6954
+ try {
6955
+ child.kill("SIGTERM");
6956
+ } catch {
6957
+ }
6958
+ const killTimer = setTimeout(() => {
6959
+ try {
6960
+ child.kill("SIGKILL");
6961
+ } catch {
6962
+ }
6963
+ }, STOP_GRACE_MS);
6964
+ if (typeof killTimer.unref === "function") {
6965
+ killTimer.unref();
6966
+ }
6967
+ try {
6968
+ await exited;
6969
+ } finally {
6970
+ clearTimeout(killTimer);
6971
+ }
6972
+ }
6973
+ infoFor(entry) {
6974
+ let status;
6975
+ if (entry.child) {
6976
+ status = "running";
6977
+ } else if (entry.restartTimer) {
6978
+ status = "restarting";
6979
+ } else if (!entry.config.enabled) {
6980
+ status = "disabled";
6981
+ } else {
6982
+ status = "stopped";
6983
+ }
6984
+ return {
6985
+ name: entry.config.name,
6986
+ status,
6987
+ pid: entry.pid,
6988
+ enabled: entry.config.enabled,
6989
+ restartCount: entry.restartCount,
6990
+ startedAt: entry.startedAt,
6991
+ lastExitCode: entry.lastExitCode,
6992
+ logPath: paths.extensionLogFile(entry.config.name),
6993
+ version: entry.version
6994
+ };
6995
+ }
6996
+ makeEntry(config) {
6997
+ return {
6998
+ config,
6999
+ child: void 0,
7000
+ logStream: void 0,
7001
+ restartTimer: void 0,
7002
+ pid: void 0,
7003
+ startedAt: void 0,
7004
+ restartCount: 0,
7005
+ lastExitCode: void 0,
7006
+ manuallyStopped: false,
7007
+ exitWaiters: [],
7008
+ version: void 0,
7009
+ processToken: void 0
7010
+ };
7011
+ }
7012
+ async reapOrphans() {
7013
+ let entries;
7014
+ try {
7015
+ entries = await fsp5.readdir(paths.extensionsDir());
7016
+ } catch (err) {
7017
+ const e = err;
7018
+ if (e.code === "ENOENT") {
7019
+ return;
7020
+ }
7021
+ throw err;
7022
+ }
7023
+ for (const entry of entries) {
7024
+ if (!entry.endsWith(".pid")) {
7025
+ continue;
7026
+ }
7027
+ const pidPath = path7.join(paths.extensionsDir(), entry);
7028
+ let pid;
7029
+ try {
7030
+ const raw = await fsp5.readFile(pidPath, "utf8");
7031
+ const parsed = Number.parseInt(raw.trim(), 10);
7032
+ if (Number.isInteger(parsed) && parsed > 0) {
7033
+ pid = parsed;
7034
+ }
7035
+ } catch {
7036
+ }
7037
+ if (typeof pid === "number" && isAlive(pid)) {
7038
+ try {
7039
+ process.kill(pid, "SIGTERM");
7040
+ } catch {
7041
+ }
7042
+ const deadline = Date.now() + STOP_GRACE_MS;
7043
+ while (Date.now() < deadline && isAlive(pid)) {
7044
+ await new Promise((r) => setTimeout(r, 50));
7045
+ }
7046
+ if (isAlive(pid)) {
7047
+ try {
7048
+ process.kill(pid, "SIGKILL");
7049
+ } catch {
7050
+ }
7051
+ }
7052
+ }
7053
+ await fsp5.unlink(pidPath).catch(() => void 0);
7054
+ }
7055
+ }
7056
+ spawn(entry, attempt) {
7057
+ if (this.stopping || entry.manuallyStopped) {
7058
+ return;
7059
+ }
7060
+ const ctx = this.context;
7061
+ if (!ctx) {
7062
+ throw new Error("ExtensionManager.spawn called before setContext");
7063
+ }
7064
+ const ext = entry.config;
7065
+ const command = ext.command.length > 0 ? ext.command : [ext.name];
7066
+ const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
7067
+ flags: "a"
7068
+ });
7069
+ logStream.write(
7070
+ `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting extension ${ext.name} (attempt ${attempt + 1})
7071
+ `
7072
+ );
7073
+ const processToken = this.tokenRegistry?.mint(ext.name, "extension") ?? ctx.serviceToken;
7074
+ entry.processToken = processToken;
7075
+ entry.version = void 0;
7076
+ const env = {
7077
+ ...process.env,
7078
+ HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
7079
+ HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
7080
+ HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
7081
+ HYDRA_ACP_TOKEN: processToken,
7082
+ HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
7083
+ HYDRA_ACP_HOME: ctx.hydraHome,
7084
+ HYDRA_ACP_EXTENSION_NAME: ext.name,
7085
+ ...ext.env
7086
+ };
7087
+ const [cmd, ...baseArgs] = command;
7088
+ if (cmd === void 0) {
7089
+ logStream.write(`[hydra-acp] extension ${ext.name} has empty command
7090
+ `);
7091
+ logStream.end();
7092
+ return;
7093
+ }
7094
+ const args = [...baseArgs, ...ext.args];
7095
+ let child;
7096
+ try {
7097
+ child = spawn4(cmd, args, {
7098
+ env,
7099
+ stdio: ["ignore", "pipe", "pipe"],
7100
+ detached: false
7101
+ });
7102
+ } catch (err) {
7103
+ logStream.write(
7104
+ `[hydra-acp] failed to spawn ${ext.name}: ${err.message}
7105
+ `
7106
+ );
7107
+ logStream.end();
7108
+ this.scheduleRestart(entry, attempt);
7109
+ return;
7110
+ }
7111
+ if (child.stdout) {
7112
+ child.stdout.pipe(logStream, { end: false });
7113
+ }
7114
+ if (child.stderr) {
7115
+ child.stderr.pipe(logStream, { end: false });
7116
+ }
7117
+ if (typeof child.pid === "number") {
7118
+ try {
7119
+ fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7120
+ `, {
7121
+ encoding: "utf8",
7122
+ mode: 384
7123
+ });
7124
+ } catch (err) {
7125
+ logStream.write(
7126
+ `[hydra-acp] failed to write pid file for ${ext.name}: ${err.message}
7127
+ `
7128
+ );
7129
+ }
7130
+ }
7131
+ entry.child = child;
7132
+ entry.logStream = logStream;
7133
+ entry.pid = typeof child.pid === "number" ? child.pid : void 0;
7134
+ entry.startedAt = Date.now();
7135
+ entry.lastExitCode = void 0;
7136
+ child.on("error", (err) => {
7137
+ logStream.write(
7138
+ `[hydra-acp] extension ${ext.name} error: ${err.message}
7139
+ `
7140
+ );
7141
+ });
7142
+ child.on("exit", (code, signal) => {
7143
+ try {
7144
+ fs11.unlinkSync(paths.extensionPidFile(ext.name));
7145
+ } catch {
7146
+ }
7147
+ logStream.write(
7148
+ `[hydra-acp] extension ${ext.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
7149
+ `
7150
+ );
7151
+ entry.child = void 0;
7152
+ entry.pid = void 0;
7153
+ entry.lastExitCode = typeof code === "number" ? code : void 0;
7154
+ if (entry.processToken) {
7155
+ this.tokenRegistry?.revoke(ext.name);
7156
+ entry.processToken = void 0;
7157
+ }
7158
+ const waiters = entry.exitWaiters.splice(0);
7159
+ for (const resolve3 of waiters) {
7160
+ resolve3();
7161
+ }
7162
+ if (this.stopping || entry.manuallyStopped) {
7163
+ try {
7164
+ logStream.end();
7165
+ } catch {
7166
+ }
7167
+ entry.logStream = void 0;
7168
+ return;
7169
+ }
7170
+ entry.restartCount += 1;
7171
+ this.scheduleRestart(entry, attempt + 1);
7172
+ });
7173
+ }
7174
+ scheduleRestart(entry, attempt) {
7175
+ if (this.stopping || entry.manuallyStopped) {
7176
+ return;
7177
+ }
7178
+ const delay = Math.min(
7179
+ RESTART_BASE_MS * 2 ** Math.min(attempt, 10),
7180
+ RESTART_CAP_MS
7181
+ );
7182
+ entry.restartTimer = setTimeout(() => {
7183
+ entry.restartTimer = void 0;
7184
+ this.spawn(entry, attempt);
7185
+ }, delay);
7186
+ if (typeof entry.restartTimer.unref === "function") {
7187
+ entry.restartTimer.unref();
7188
+ }
7189
+ }
7190
+ };
7191
+ function isAlive(pid) {
5893
7192
  try {
5894
- const st = await fs10.stat(paths.historyFile(sessionId));
5895
- return new Date(st.mtimeMs).toISOString();
7193
+ process.kill(pid, 0);
7194
+ return true;
5896
7195
  } catch {
5897
- return void 0;
7196
+ return false;
5898
7197
  }
5899
7198
  }
7199
+ function withCode2(err, code) {
7200
+ err.code = code;
7201
+ return err;
7202
+ }
5900
7203
 
5901
- // src/core/extensions.ts
5902
- import { spawn as spawn4 } from "child_process";
5903
- import * as fs11 from "fs";
5904
- import * as fsp3 from "fs/promises";
5905
- import * as path7 from "path";
5906
- var RESTART_BASE_MS = 1e3;
5907
- var RESTART_CAP_MS = 6e4;
5908
- var STOP_GRACE_MS = 3e3;
5909
- var ExtensionManager = class {
7204
+ // src/core/transformer-manager.ts
7205
+ import { spawn as spawn5 } from "child_process";
7206
+ import * as fs12 from "fs";
7207
+ import * as fsp6 from "fs/promises";
7208
+ import * as path8 from "path";
7209
+ var RESTART_BASE_MS2 = 1e3;
7210
+ var RESTART_CAP_MS2 = 6e4;
7211
+ var STOP_GRACE_MS2 = 3e3;
7212
+ var TransformerManager = class {
5910
7213
  entries = /* @__PURE__ */ new Map();
7214
+ // Transformers that have completed transformer/initialize and are ready to
7215
+ // participate in chains. Keyed by transformer name.
7216
+ connected = /* @__PURE__ */ new Map();
5911
7217
  stopping = false;
5912
7218
  context;
5913
- constructor(extensions, context) {
7219
+ tokenRegistry;
7220
+ constructor(transformers, context, options = {}) {
5914
7221
  this.context = context;
5915
- for (const ext of extensions) {
5916
- this.entries.set(ext.name, this.makeEntry(ext));
7222
+ this.tokenRegistry = options.tokenRegistry;
7223
+ for (const t of transformers) {
7224
+ this.entries.set(t.name, this.makeEntry(t));
5917
7225
  }
5918
7226
  }
5919
7227
  setContext(context) {
5920
7228
  this.context = context;
5921
7229
  }
7230
+ reportVersion(name, version) {
7231
+ const entry = this.entries.get(name);
7232
+ if (entry) {
7233
+ entry.version = version;
7234
+ }
7235
+ }
7236
+ // Called by the WS handler after transformer/initialize completes. The
7237
+ // transformer is now eligible to participate in session chains.
7238
+ registerConnection(name, connection, intercepts) {
7239
+ this.connected.set(name, {
7240
+ name,
7241
+ connection,
7242
+ intercepts: new Set(intercepts)
7243
+ });
7244
+ }
7245
+ // Called by the WS handler when the transformer's WS connection closes.
7246
+ deregisterConnection(name) {
7247
+ this.connected.delete(name);
7248
+ }
7249
+ // Resolve a list of transformer names to their live TransformerRef objects.
7250
+ // Names that are configured but not yet connected are silently skipped
7251
+ // (fail-open: session proceeds without that transformer rather than failing).
7252
+ resolveChain(names) {
7253
+ const out = [];
7254
+ for (const name of names) {
7255
+ const ref = this.connected.get(name);
7256
+ if (ref) {
7257
+ out.push(ref);
7258
+ }
7259
+ }
7260
+ return out;
7261
+ }
5922
7262
  async start() {
5923
7263
  if (!this.context) {
5924
- throw new Error("ExtensionManager: setContext must be called before start");
7264
+ throw new Error("TransformerManager: setContext must be called before start");
5925
7265
  }
5926
- await fsp3.mkdir(paths.extensionsDir(), { recursive: true });
7266
+ await fsp6.mkdir(paths.transformersDir(), { recursive: true });
5927
7267
  await this.reapOrphans();
5928
7268
  for (const entry of this.entries.values()) {
5929
7269
  if (!entry.config.enabled) {
@@ -5960,7 +7300,7 @@ var ExtensionManager = class {
5960
7300
  } catch {
5961
7301
  }
5962
7302
  resolve3();
5963
- }, STOP_GRACE_MS);
7303
+ }, STOP_GRACE_MS2);
5964
7304
  child.on("exit", () => {
5965
7305
  clearTimeout(timer);
5966
7306
  resolve3();
@@ -5992,10 +7332,10 @@ var ExtensionManager = class {
5992
7332
  async startByName(name) {
5993
7333
  const entry = this.entries.get(name);
5994
7334
  if (!entry) {
5995
- throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7335
+ throw withCode3(new Error(`unknown transformer: ${name}`), "NOT_FOUND");
5996
7336
  }
5997
7337
  if (entry.child) {
5998
- throw withCode2(new Error(`extension ${name} already running`), "CONFLICT");
7338
+ throw withCode3(new Error(`transformer ${name} already running`), "CONFLICT");
5999
7339
  }
6000
7340
  if (entry.restartTimer) {
6001
7341
  clearTimeout(entry.restartTimer);
@@ -6009,7 +7349,7 @@ var ExtensionManager = class {
6009
7349
  async stopByName(name) {
6010
7350
  const entry = this.entries.get(name);
6011
7351
  if (!entry) {
6012
- throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7352
+ throw withCode3(new Error(`unknown transformer: ${name}`), "NOT_FOUND");
6013
7353
  }
6014
7354
  entry.manuallyStopped = true;
6015
7355
  if (entry.restartTimer) {
@@ -6027,18 +7367,15 @@ var ExtensionManager = class {
6027
7367
  await this.stopByName(name);
6028
7368
  return this.startByName(name);
6029
7369
  }
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
7370
  register(config) {
6034
7371
  if (this.entries.has(config.name)) {
6035
- throw withCode2(
6036
- new Error(`extension ${config.name} already exists`),
7372
+ throw withCode3(
7373
+ new Error(`transformer ${config.name} already exists`),
6037
7374
  "CONFLICT"
6038
7375
  );
6039
7376
  }
6040
7377
  if (!this.context) {
6041
- throw new Error("ExtensionManager: setContext must be called before register");
7378
+ throw new Error("TransformerManager: setContext must be called before register");
6042
7379
  }
6043
7380
  const entry = this.makeEntry(config);
6044
7381
  this.entries.set(config.name, entry);
@@ -6050,7 +7387,7 @@ var ExtensionManager = class {
6050
7387
  async unregister(name) {
6051
7388
  const entry = this.entries.get(name);
6052
7389
  if (!entry) {
6053
- throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
7390
+ throw withCode3(new Error(`unknown transformer: ${name}`), "NOT_FOUND");
6054
7391
  }
6055
7392
  entry.manuallyStopped = true;
6056
7393
  if (entry.restartTimer) {
@@ -6083,7 +7420,7 @@ var ExtensionManager = class {
6083
7420
  child.kill("SIGKILL");
6084
7421
  } catch {
6085
7422
  }
6086
- }, STOP_GRACE_MS);
7423
+ }, STOP_GRACE_MS2);
6087
7424
  if (typeof killTimer.unref === "function") {
6088
7425
  killTimer.unref();
6089
7426
  }
@@ -6112,7 +7449,8 @@ var ExtensionManager = class {
6112
7449
  restartCount: entry.restartCount,
6113
7450
  startedAt: entry.startedAt,
6114
7451
  lastExitCode: entry.lastExitCode,
6115
- logPath: paths.extensionLogFile(entry.config.name)
7452
+ logPath: paths.transformerLogFile(entry.config.name),
7453
+ version: entry.version
6116
7454
  };
6117
7455
  }
6118
7456
  makeEntry(config) {
@@ -6126,13 +7464,15 @@ var ExtensionManager = class {
6126
7464
  restartCount: 0,
6127
7465
  lastExitCode: void 0,
6128
7466
  manuallyStopped: false,
6129
- exitWaiters: []
7467
+ exitWaiters: [],
7468
+ version: void 0,
7469
+ processToken: void 0
6130
7470
  };
6131
7471
  }
6132
7472
  async reapOrphans() {
6133
7473
  let entries;
6134
7474
  try {
6135
- entries = await fsp3.readdir(paths.extensionsDir());
7475
+ entries = await fsp6.readdir(paths.transformersDir());
6136
7476
  } catch (err) {
6137
7477
  const e = err;
6138
7478
  if (e.code === "ENOENT") {
@@ -6144,33 +7484,33 @@ var ExtensionManager = class {
6144
7484
  if (!entry.endsWith(".pid")) {
6145
7485
  continue;
6146
7486
  }
6147
- const pidPath = path7.join(paths.extensionsDir(), entry);
7487
+ const pidPath = path8.join(paths.transformersDir(), entry);
6148
7488
  let pid;
6149
7489
  try {
6150
- const raw = await fsp3.readFile(pidPath, "utf8");
7490
+ const raw = await fsp6.readFile(pidPath, "utf8");
6151
7491
  const parsed = Number.parseInt(raw.trim(), 10);
6152
7492
  if (Number.isInteger(parsed) && parsed > 0) {
6153
7493
  pid = parsed;
6154
7494
  }
6155
7495
  } catch {
6156
7496
  }
6157
- if (typeof pid === "number" && isAlive(pid)) {
7497
+ if (typeof pid === "number" && isAlive2(pid)) {
6158
7498
  try {
6159
7499
  process.kill(pid, "SIGTERM");
6160
7500
  } catch {
6161
7501
  }
6162
- const deadline = Date.now() + STOP_GRACE_MS;
6163
- while (Date.now() < deadline && isAlive(pid)) {
7502
+ const deadline = Date.now() + STOP_GRACE_MS2;
7503
+ while (Date.now() < deadline && isAlive2(pid)) {
6164
7504
  await new Promise((r) => setTimeout(r, 50));
6165
7505
  }
6166
- if (isAlive(pid)) {
7506
+ if (isAlive2(pid)) {
6167
7507
  try {
6168
7508
  process.kill(pid, "SIGKILL");
6169
7509
  } catch {
6170
7510
  }
6171
7511
  }
6172
7512
  }
6173
- await fsp3.unlink(pidPath).catch(() => void 0);
7513
+ await fsp6.unlink(pidPath).catch(() => void 0);
6174
7514
  }
6175
7515
  }
6176
7516
  spawn(entry, attempt) {
@@ -6179,46 +7519,49 @@ var ExtensionManager = class {
6179
7519
  }
6180
7520
  const ctx = this.context;
6181
7521
  if (!ctx) {
6182
- throw new Error("ExtensionManager.spawn called before setContext");
7522
+ throw new Error("TransformerManager.spawn called before setContext");
6183
7523
  }
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), {
7524
+ const t = entry.config;
7525
+ const command = t.command.length > 0 ? t.command : [t.name];
7526
+ const logStream = fs12.createWriteStream(paths.transformerLogFile(t.name), {
6187
7527
  flags: "a"
6188
7528
  });
6189
7529
  logStream.write(
6190
- `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting extension ${ext.name} (attempt ${attempt + 1})
7530
+ `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting transformer ${t.name} (attempt ${attempt + 1})
6191
7531
  `
6192
7532
  );
7533
+ const processToken = this.tokenRegistry?.mint(t.name, "transformer") ?? ctx.serviceToken;
7534
+ entry.processToken = processToken;
7535
+ entry.version = void 0;
6193
7536
  const env = {
6194
7537
  ...process.env,
6195
7538
  HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
6196
7539
  HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
6197
7540
  HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
6198
- HYDRA_ACP_TOKEN: ctx.serviceToken,
7541
+ HYDRA_ACP_TOKEN: processToken,
6199
7542
  HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
6200
7543
  HYDRA_ACP_HOME: ctx.hydraHome,
6201
- HYDRA_ACP_EXTENSION_NAME: ext.name,
6202
- ...ext.env
7544
+ HYDRA_ACP_TRANSFORMER_NAME: t.name,
7545
+ ...t.env
6203
7546
  };
6204
7547
  const [cmd, ...baseArgs] = command;
6205
7548
  if (cmd === void 0) {
6206
- logStream.write(`[hydra-acp] extension ${ext.name} has empty command
7549
+ logStream.write(`[hydra-acp] transformer ${t.name} has empty command
6207
7550
  `);
6208
7551
  logStream.end();
6209
7552
  return;
6210
7553
  }
6211
- const args = [...baseArgs, ...ext.args];
7554
+ const args = [...baseArgs, ...t.args];
6212
7555
  let child;
6213
7556
  try {
6214
- child = spawn4(cmd, args, {
7557
+ child = spawn5(cmd, args, {
6215
7558
  env,
6216
7559
  stdio: ["ignore", "pipe", "pipe"],
6217
7560
  detached: false
6218
7561
  });
6219
7562
  } catch (err) {
6220
7563
  logStream.write(
6221
- `[hydra-acp] failed to spawn ${ext.name}: ${err.message}
7564
+ `[hydra-acp] failed to spawn ${t.name}: ${err.message}
6222
7565
  `
6223
7566
  );
6224
7567
  logStream.end();
@@ -6233,14 +7576,14 @@ var ExtensionManager = class {
6233
7576
  }
6234
7577
  if (typeof child.pid === "number") {
6235
7578
  try {
6236
- fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7579
+ fs12.writeFileSync(paths.transformerPidFile(t.name), `${child.pid}
6237
7580
  `, {
6238
7581
  encoding: "utf8",
6239
7582
  mode: 384
6240
7583
  });
6241
7584
  } catch (err) {
6242
7585
  logStream.write(
6243
- `[hydra-acp] failed to write pid file for ${ext.name}: ${err.message}
7586
+ `[hydra-acp] failed to write pid file for ${t.name}: ${err.message}
6244
7587
  `
6245
7588
  );
6246
7589
  }
@@ -6252,22 +7595,26 @@ var ExtensionManager = class {
6252
7595
  entry.lastExitCode = void 0;
6253
7596
  child.on("error", (err) => {
6254
7597
  logStream.write(
6255
- `[hydra-acp] extension ${ext.name} error: ${err.message}
7598
+ `[hydra-acp] transformer ${t.name} error: ${err.message}
6256
7599
  `
6257
7600
  );
6258
7601
  });
6259
7602
  child.on("exit", (code, signal) => {
6260
7603
  try {
6261
- fs11.unlinkSync(paths.extensionPidFile(ext.name));
7604
+ fs12.unlinkSync(paths.transformerPidFile(t.name));
6262
7605
  } catch {
6263
7606
  }
6264
7607
  logStream.write(
6265
- `[hydra-acp] extension ${ext.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
7608
+ `[hydra-acp] transformer ${t.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
6266
7609
  `
6267
7610
  );
6268
7611
  entry.child = void 0;
6269
7612
  entry.pid = void 0;
6270
7613
  entry.lastExitCode = typeof code === "number" ? code : void 0;
7614
+ if (entry.processToken) {
7615
+ this.tokenRegistry?.revoke(t.name);
7616
+ entry.processToken = void 0;
7617
+ }
6271
7618
  const waiters = entry.exitWaiters.splice(0);
6272
7619
  for (const resolve3 of waiters) {
6273
7620
  resolve3();
@@ -6289,8 +7636,8 @@ var ExtensionManager = class {
6289
7636
  return;
6290
7637
  }
6291
7638
  const delay = Math.min(
6292
- RESTART_BASE_MS * 2 ** Math.min(attempt, 10),
6293
- RESTART_CAP_MS
7639
+ RESTART_BASE_MS2 * 2 ** Math.min(attempt, 10),
7640
+ RESTART_CAP_MS2
6294
7641
  );
6295
7642
  entry.restartTimer = setTimeout(() => {
6296
7643
  entry.restartTimer = void 0;
@@ -6301,7 +7648,7 @@ var ExtensionManager = class {
6301
7648
  }
6302
7649
  }
6303
7650
  };
6304
- function isAlive(pid) {
7651
+ function isAlive2(pid) {
6305
7652
  try {
6306
7653
  process.kill(pid, 0);
6307
7654
  return true;
@@ -6309,14 +7656,14 @@ function isAlive(pid) {
6309
7656
  return false;
6310
7657
  }
6311
7658
  }
6312
- function withCode2(err, code) {
7659
+ function withCode3(err, code) {
6313
7660
  err.code = code;
6314
7661
  return err;
6315
7662
  }
6316
7663
 
6317
7664
  // src/core/agent-prune.ts
6318
- import * as fsp4 from "fs/promises";
6319
- import * as path8 from "path";
7665
+ import * as fsp7 from "fs/promises";
7666
+ import * as path9 from "path";
6320
7667
  var logSink3 = (msg) => {
6321
7668
  process.stderr.write(msg + "\n");
6322
7669
  };
@@ -6334,10 +7681,10 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6334
7681
  desiredByAgent.set(a.id, a.version ?? "current");
6335
7682
  }
6336
7683
  const activeByAgent = sessionManager.activeAgentVersions();
6337
- const platformDir = path8.join(paths.agentsDir(), platformKey);
7684
+ const platformDir = path9.join(paths.agentsDir(), platformKey);
6338
7685
  let agentEntries;
6339
7686
  try {
6340
- agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
7687
+ agentEntries = await fsp7.readdir(platformDir, { withFileTypes: true });
6341
7688
  } catch (err) {
6342
7689
  const e = err;
6343
7690
  if (e.code === "ENOENT") {
@@ -6356,10 +7703,10 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6356
7703
  continue;
6357
7704
  }
6358
7705
  const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
6359
- const agentDir = path8.join(platformDir, agentId);
7706
+ const agentDir = path9.join(platformDir, agentId);
6360
7707
  let versionEntries;
6361
7708
  try {
6362
- versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
7709
+ versionEntries = await fsp7.readdir(agentDir, { withFileTypes: true });
6363
7710
  } catch (err) {
6364
7711
  logSink3(
6365
7712
  `hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
@@ -6380,9 +7727,9 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6380
7727
  if (version.includes(".partial-")) {
6381
7728
  continue;
6382
7729
  }
6383
- const versionDir = path8.join(agentDir, version);
7730
+ const versionDir = path9.join(agentDir, version);
6384
7731
  try {
6385
- await fsp4.rm(versionDir, { recursive: true, force: true });
7732
+ await fsp7.rm(versionDir, { recursive: true, force: true });
6386
7733
  logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
6387
7734
  } catch (err) {
6388
7735
  logSink3(
@@ -6394,8 +7741,8 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6394
7741
  }
6395
7742
 
6396
7743
  // src/core/session-tokens.ts
6397
- import * as fs12 from "fs/promises";
6398
- import * as path9 from "path";
7744
+ import * as fs13 from "fs/promises";
7745
+ import * as path10 from "path";
6399
7746
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
6400
7747
  var TOKEN_PREFIX = "hydra_session_";
6401
7748
  var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
@@ -6403,7 +7750,7 @@ var ID_LENGTH = 12;
6403
7750
  var TOKEN_BYTES = 32;
6404
7751
  var WRITE_DEBOUNCE_MS = 50;
6405
7752
  function tokensFilePath() {
6406
- return path9.join(paths.home(), "session-tokens.json");
7753
+ return path10.join(paths.home(), "session-tokens.json");
6407
7754
  }
6408
7755
  function sha256Hex(input) {
6409
7756
  return createHash("sha256").update(input).digest("hex");
@@ -6430,7 +7777,7 @@ var SessionTokenStore = class _SessionTokenStore {
6430
7777
  static async load() {
6431
7778
  let records = [];
6432
7779
  try {
6433
- const raw = await fs12.readFile(tokensFilePath(), "utf8");
7780
+ const raw = await fs13.readFile(tokensFilePath(), "utf8");
6434
7781
  const parsed = JSON.parse(raw);
6435
7782
  if (parsed && Array.isArray(parsed.records)) {
6436
7783
  records = parsed.records.filter(isRecord);
@@ -6558,8 +7905,8 @@ var SessionTokenStore = class _SessionTokenStore {
6558
7905
  const records = Array.from(this.records.values());
6559
7906
  const payload = JSON.stringify({ records }, null, 2) + "\n";
6560
7907
  this.writeInflight = (async () => {
6561
- await fs12.mkdir(paths.home(), { recursive: true });
6562
- await fs12.writeFile(tokensFilePath(), payload, {
7908
+ await fs13.mkdir(paths.home(), { recursive: true });
7909
+ await fs13.writeFile(tokensFilePath(), payload, {
6563
7910
  encoding: "utf8",
6564
7911
  mode: 384
6565
7912
  });
@@ -6655,6 +8002,34 @@ function tokenFromUpgradeRequest(req) {
6655
8002
  }
6656
8003
  return void 0;
6657
8004
  }
8005
+ var ProcessTokenRegistry = class {
8006
+ tokens = /* @__PURE__ */ new Map();
8007
+ mint(name, kind) {
8008
+ const token = generateServiceToken();
8009
+ this.tokens.set(token, { name, kind });
8010
+ return token;
8011
+ }
8012
+ // Revoke all tokens associated with the named process. Called when a
8013
+ // process exits or is unregistered so stale tokens can't be reused if a
8014
+ // new process happens to reconnect before a full daemon restart.
8015
+ revoke(name) {
8016
+ for (const [token, identity] of this.tokens) {
8017
+ if (identity.name === name) {
8018
+ this.tokens.delete(token);
8019
+ }
8020
+ }
8021
+ }
8022
+ resolve(token) {
8023
+ return this.tokens.get(token);
8024
+ }
8025
+ async validate(token) {
8026
+ const identity = this.tokens.get(token);
8027
+ if (!identity) {
8028
+ return void 0;
8029
+ }
8030
+ return `${identity.kind}:${identity.name}`;
8031
+ }
8032
+ };
6658
8033
  function constantTimeEqual(a, b) {
6659
8034
  if (a.length !== b.length) {
6660
8035
  return false;
@@ -6739,7 +8114,13 @@ var Bundle = z5.object({
6739
8114
  exportedAt: z5.string(),
6740
8115
  exportedFrom: z5.object({
6741
8116
  hydraVersion: z5.string(),
6742
- machine: z5.string()
8117
+ machine: z5.string(),
8118
+ // Externally-reachable name (and optional ":port") for the exporting
8119
+ // daemon, sourced from config.daemon.publicHost (or daemon.host when
8120
+ // non-loopback). Carried so an importer can construct a hydra:// URL
8121
+ // that dials back to the origin — e.g. over Tailscale. Omitted when
8122
+ // the exporter has no routable address; never falls back to loopback.
8123
+ hydraHost: z5.string().optional()
6743
8124
  }),
6744
8125
  session: BundleSession,
6745
8126
  history: z5.array(HistoryEntrySchema),
@@ -6751,7 +8132,8 @@ function encodeBundle(params) {
6751
8132
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
6752
8133
  exportedFrom: {
6753
8134
  hydraVersion: params.hydraVersion,
6754
- machine: params.machine
8135
+ machine: params.machine,
8136
+ ...params.hydraHost !== void 0 && params.hydraHost.length > 0 ? { hydraHost: params.hydraHost } : {}
6755
8137
  },
6756
8138
  session: {
6757
8139
  sessionId: params.record.sessionId,
@@ -7095,7 +8477,7 @@ function isUpstreamInterrupted(u, errorText) {
7095
8477
  }
7096
8478
  function mapPlan(u) {
7097
8479
  const entries = u.entries;
7098
- if (!Array.isArray(entries)) {
8480
+ if (!Array.isArray(entries) || entries.length === 0) {
7099
8481
  return null;
7100
8482
  }
7101
8483
  const normalized = [];
@@ -7431,7 +8813,21 @@ function formatNumber(n) {
7431
8813
  return n.toLocaleString("en-US");
7432
8814
  }
7433
8815
 
8816
+ // src/core/remote-url.ts
8817
+ function isLoopbackHost(host) {
8818
+ return host === "127.0.0.1" || host === "::1" || host === "localhost" || host === "[::1]";
8819
+ }
8820
+
7434
8821
  // src/daemon/routes/sessions.ts
8822
+ function resolveHydraHost(defaults) {
8823
+ if (defaults.publicHost && defaults.publicHost.length > 0) {
8824
+ return defaults.publicHost;
8825
+ }
8826
+ if (defaults.host && !isLoopbackHost(defaults.host)) {
8827
+ return defaults.port !== void 0 ? `${defaults.host}:${defaults.port}` : defaults.host;
8828
+ }
8829
+ return void 0;
8830
+ }
7435
8831
  function registerSessionRoutes(app, manager, defaults) {
7436
8832
  app.get("/v1/sessions", async (request) => {
7437
8833
  const query = request.query;
@@ -7531,7 +8927,8 @@ function registerSessionRoutes(app, manager, defaults) {
7531
8927
  history: exported.history,
7532
8928
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
7533
8929
  hydraVersion: HYDRA_VERSION,
7534
- machine: os3.hostname()
8930
+ machine: os3.hostname(),
8931
+ hydraHost: resolveHydraHost(defaults)
7535
8932
  });
7536
8933
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7537
8934
  reply.header(
@@ -7553,7 +8950,8 @@ function registerSessionRoutes(app, manager, defaults) {
7553
8950
  history: exported.history,
7554
8951
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
7555
8952
  hydraVersion: HYDRA_VERSION,
7556
- machine: os3.hostname()
8953
+ machine: os3.hostname(),
8954
+ hydraHost: resolveHydraHost(defaults)
7557
8955
  });
7558
8956
  reply.header("Content-Type", "text/markdown; charset=utf-8");
7559
8957
  reply.code(200).send(bundleToMarkdown(bundle));
@@ -7866,6 +9264,122 @@ function parseRegisterBody(body) {
7866
9264
  };
7867
9265
  }
7868
9266
 
9267
+ // src/daemon/routes/transformers.ts
9268
+ var NAME_RE2 = /^[A-Za-z0-9._-]+$/;
9269
+ function registerTransformerRoutes(app, transformers) {
9270
+ app.get("/v1/transformers", async () => {
9271
+ return { transformers: transformers.list() };
9272
+ });
9273
+ app.get("/v1/transformers/:name", async (request, reply) => {
9274
+ const name = request.params.name;
9275
+ const info = transformers.get(name);
9276
+ if (!info) {
9277
+ reply.code(404).send({ error: `unknown transformer: ${name}` });
9278
+ return;
9279
+ }
9280
+ return info;
9281
+ });
9282
+ app.post("/v1/transformers", async (request, reply) => {
9283
+ const body = request.body ?? {};
9284
+ const parsed = parseRegisterBody2(body);
9285
+ if ("error" in parsed) {
9286
+ reply.code(400).send({ error: parsed.error });
9287
+ return;
9288
+ }
9289
+ try {
9290
+ const info = transformers.register(parsed.config);
9291
+ reply.code(201).send(info);
9292
+ } catch (err) {
9293
+ sendError2(reply, err);
9294
+ }
9295
+ });
9296
+ app.delete("/v1/transformers/:name", async (request, reply) => {
9297
+ const name = request.params.name;
9298
+ try {
9299
+ await transformers.unregister(name);
9300
+ reply.code(204).send();
9301
+ } catch (err) {
9302
+ sendError2(reply, err);
9303
+ }
9304
+ });
9305
+ app.post("/v1/transformers/:name/start", async (request, reply) => {
9306
+ const name = request.params.name;
9307
+ try {
9308
+ const info = await transformers.startByName(name);
9309
+ reply.code(200).send(info);
9310
+ } catch (err) {
9311
+ sendError2(reply, err);
9312
+ }
9313
+ });
9314
+ app.post("/v1/transformers/:name/stop", async (request, reply) => {
9315
+ const name = request.params.name;
9316
+ try {
9317
+ const info = await transformers.stopByName(name);
9318
+ reply.code(200).send(info);
9319
+ } catch (err) {
9320
+ sendError2(reply, err);
9321
+ }
9322
+ });
9323
+ app.post("/v1/transformers/:name/restart", async (request, reply) => {
9324
+ const name = request.params.name;
9325
+ try {
9326
+ const info = await transformers.restartByName(name);
9327
+ reply.code(200).send(info);
9328
+ } catch (err) {
9329
+ sendError2(reply, err);
9330
+ }
9331
+ });
9332
+ }
9333
+ function sendError2(reply, err) {
9334
+ const code = err.code;
9335
+ const message = err.message ?? "unknown error";
9336
+ if (code === "NOT_FOUND") {
9337
+ reply.code(404).send({ error: message });
9338
+ return;
9339
+ }
9340
+ if (code === "CONFLICT") {
9341
+ reply.code(409).send({ error: message });
9342
+ return;
9343
+ }
9344
+ reply.code(500).send({ error: message });
9345
+ }
9346
+ function parseRegisterBody2(body) {
9347
+ const name = body.name;
9348
+ if (typeof name !== "string" || !NAME_RE2.test(name)) {
9349
+ return { error: "name must match [A-Za-z0-9._-]+" };
9350
+ }
9351
+ const command = body.command;
9352
+ if (command !== void 0 && (!Array.isArray(command) || command.some((c) => typeof c !== "string"))) {
9353
+ return { error: "command must be string[]" };
9354
+ }
9355
+ const args = body.args;
9356
+ if (args !== void 0 && (!Array.isArray(args) || args.some((a) => typeof a !== "string"))) {
9357
+ return { error: "args must be string[]" };
9358
+ }
9359
+ const env = body.env;
9360
+ if (env !== void 0 && (typeof env !== "object" || env === null || Array.isArray(env))) {
9361
+ return { error: "env must be an object of string\u2192string" };
9362
+ }
9363
+ if (env && Object.values(env).some(
9364
+ (v) => typeof v !== "string"
9365
+ )) {
9366
+ return { error: "env values must be strings" };
9367
+ }
9368
+ const enabled = body.enabled;
9369
+ if (enabled !== void 0 && typeof enabled !== "boolean") {
9370
+ return { error: "enabled must be a boolean" };
9371
+ }
9372
+ return {
9373
+ config: {
9374
+ name,
9375
+ command: command ?? [],
9376
+ args: args ?? [],
9377
+ env: env ?? {},
9378
+ enabled: enabled === void 0 ? true : enabled
9379
+ }
9380
+ };
9381
+ }
9382
+
7869
9383
  // src/daemon/routes/config.ts
7870
9384
  function registerConfigRoutes(app, defaults) {
7871
9385
  app.get("/v1/config", async () => {
@@ -7880,19 +9394,19 @@ function registerConfigRoutes(app, defaults) {
7880
9394
  import { z as z6 } from "zod";
7881
9395
 
7882
9396
  // src/core/password.ts
7883
- import * as fs13 from "fs/promises";
7884
- import * as path10 from "path";
9397
+ import * as fs14 from "fs/promises";
9398
+ import * as path11 from "path";
7885
9399
  import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
7886
9400
  import { promisify } from "util";
7887
9401
  var scryptAsync = promisify(scrypt);
7888
9402
  function passwordHashPath() {
7889
- return path10.join(paths.home(), "password-hash");
9403
+ return path11.join(paths.home(), "password-hash");
7890
9404
  }
7891
9405
  var DEFAULT_N = 1 << 15;
7892
9406
  var MAX_MEM = 128 * 1024 * 1024;
7893
9407
  async function hasPassword() {
7894
9408
  try {
7895
- const text = await fs13.readFile(passwordHashPath(), "utf8");
9409
+ const text = await fs14.readFile(passwordHashPath(), "utf8");
7896
9410
  return text.trim().length > 0;
7897
9411
  } catch (err) {
7898
9412
  const e = err;
@@ -7908,7 +9422,7 @@ async function verifyPassword(plaintext) {
7908
9422
  }
7909
9423
  let line;
7910
9424
  try {
7911
- line = (await fs13.readFile(passwordHashPath(), "utf8")).trim();
9425
+ line = (await fs14.readFile(passwordHashPath(), "utf8")).trim();
7912
9426
  } catch (err) {
7913
9427
  const e = err;
7914
9428
  if (e.code === "ENOENT") {
@@ -8102,6 +9616,8 @@ function wsToMessageStream(ws) {
8102
9616
  }
8103
9617
 
8104
9618
  // src/daemon/acp-ws.ts
9619
+ import * as os4 from "os";
9620
+ import * as path12 from "path";
8105
9621
  function registerAcpWsEndpoint(app, deps) {
8106
9622
  app.get("/acp", { websocket: true }, async (socket, request) => {
8107
9623
  const token = tokenFromUpgradeRequest({
@@ -8112,10 +9628,12 @@ function registerAcpWsEndpoint(app, deps) {
8112
9628
  socket.close(4401, "Unauthorized");
8113
9629
  return;
8114
9630
  }
9631
+ const processIdentity = deps.processRegistry?.resolve(token);
8115
9632
  const stream = wsToMessageStream(socket);
8116
9633
  const connection = new JsonRpcConnection(stream);
8117
9634
  const state = {
8118
9635
  clientId: `hydra_client_${nanoid2(12)}`,
9636
+ processIdentity,
8119
9637
  attached: /* @__PURE__ */ new Map()
8120
9638
  };
8121
9639
  connection.onClose(() => {
@@ -8136,14 +9654,150 @@ function registerAcpWsEndpoint(app, deps) {
8136
9654
  }
8137
9655
  };
8138
9656
  connection.onRequest("initialize", async (raw) => {
8139
- InitializeParams.parse(raw ?? {});
9657
+ const params = InitializeParams.parse(raw ?? {});
9658
+ const version = params.clientInfo?.version;
9659
+ if (version && processIdentity) {
9660
+ if (processIdentity.kind === "extension") {
9661
+ deps.onExtensionVersion?.(processIdentity.name, version);
9662
+ } else {
9663
+ deps.onTransformerVersion?.(processIdentity.name, version);
9664
+ }
9665
+ }
8140
9666
  return buildInitializeResult();
8141
9667
  });
9668
+ if (processIdentity?.kind === "transformer") {
9669
+ connection.onRequest("transformer/initialize", async (raw) => {
9670
+ const params = raw ?? {};
9671
+ const intercepts = Array.isArray(params.intercepts) ? params.intercepts.filter(
9672
+ (v) => typeof v === "string"
9673
+ ) : [];
9674
+ if (deps.transformers) {
9675
+ deps.transformers.registerConnection(
9676
+ processIdentity.name,
9677
+ connection,
9678
+ intercepts
9679
+ );
9680
+ }
9681
+ return { ack: true };
9682
+ });
9683
+ connection.onClose(() => {
9684
+ deps.transformers?.deregisterConnection(processIdentity.name);
9685
+ });
9686
+ connection.onRequest("hydra-acp/emit_message", async (raw) => {
9687
+ const params = raw ?? {};
9688
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
9689
+ const method = typeof params.method === "string" ? params.method : void 0;
9690
+ const envelope = params.envelope;
9691
+ const route = params.route;
9692
+ if (!sessionId || !method) {
9693
+ throw Object.assign(new Error("emit_message requires sessionId and method"), { code: -32602 });
9694
+ }
9695
+ const session = deps.manager.get(sessionId);
9696
+ if (!session) {
9697
+ throw Object.assign(new Error(`session ${sessionId} not found`), { code: JsonRpcErrorCodes.SessionNotFound });
9698
+ }
9699
+ const respondsTo = typeof params.respondsTo === "string" ? params.respondsTo : void 0;
9700
+ if (respondsTo) {
9701
+ session.dischargeClaim(respondsTo, envelope);
9702
+ return { ok: true };
9703
+ }
9704
+ if (route === "chain") {
9705
+ await session.emitToChain(processIdentity.name, method, envelope);
9706
+ return { ok: true };
9707
+ }
9708
+ if (route === "daemon") {
9709
+ await session.emitToChain(processIdentity.name, method, envelope);
9710
+ return { ok: true };
9711
+ }
9712
+ throw Object.assign(new Error(`unsupported route: ${JSON.stringify(route)}`), { code: -32602 });
9713
+ });
9714
+ connection.onRequest("hydra-acp/spawn_child_session", async (raw) => {
9715
+ const params = raw ?? {};
9716
+ const agentId = typeof params.agentId === "string" ? params.agentId : deps.defaultAgent;
9717
+ const cwd = typeof params.cwd === "string" ? params.cwd : void 0;
9718
+ const parentSessionId = typeof params.parentSessionId === "string" ? params.parentSessionId : void 0;
9719
+ if (!cwd) {
9720
+ throw Object.assign(new Error("spawn_child_session requires cwd"), { code: -32602 });
9721
+ }
9722
+ const child = await deps.manager.create({
9723
+ agentId,
9724
+ cwd,
9725
+ parentSessionId,
9726
+ transformChain: []
9727
+ // children start with no chain by default
9728
+ });
9729
+ return { childSessionId: child.sessionId };
9730
+ });
9731
+ connection.onRequest("hydra-acp/await_child", async (raw) => {
9732
+ const params = raw ?? {};
9733
+ const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
9734
+ const until = params.until === "idle" ? "idle" : "turn_complete";
9735
+ const timeoutMs = typeof params.timeoutMs === "number" ? Math.min(params.timeoutMs, 30 * 6e4) : 5 * 6e4;
9736
+ if (!childSessionId) {
9737
+ throw Object.assign(new Error("await_child requires childSessionId"), { code: -32602 });
9738
+ }
9739
+ const child = deps.manager.get(childSessionId);
9740
+ if (!child) {
9741
+ throw Object.assign(
9742
+ new Error(`child session ${childSessionId} not found`),
9743
+ { code: JsonRpcErrorCodes.SessionNotFound }
9744
+ );
9745
+ }
9746
+ return new Promise((resolve3) => {
9747
+ const entries = [];
9748
+ let unsubscribe;
9749
+ const finish = () => {
9750
+ clearTimeout(timer);
9751
+ unsubscribe?.();
9752
+ resolve3({ entries });
9753
+ };
9754
+ unsubscribe = child.onBroadcast((entry) => {
9755
+ entries.push(entry);
9756
+ if (until === "turn_complete") {
9757
+ const upd = entry.params?.update;
9758
+ if (upd?.sessionUpdate === "turn_complete") {
9759
+ finish();
9760
+ }
9761
+ }
9762
+ });
9763
+ const timer = setTimeout(finish, timeoutMs);
9764
+ if (typeof timer.unref === "function") {
9765
+ timer.unref();
9766
+ }
9767
+ child.onClose(() => finish());
9768
+ });
9769
+ });
9770
+ connection.onRequest("hydra-acp/close_child_session", async (raw) => {
9771
+ const params = raw ?? {};
9772
+ const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
9773
+ if (!childSessionId) {
9774
+ throw Object.assign(new Error("close_child_session requires childSessionId"), { code: -32602 });
9775
+ }
9776
+ const child = deps.manager.get(childSessionId);
9777
+ if (child) {
9778
+ await child.close({ deleteRecord: false });
9779
+ }
9780
+ return { ok: true };
9781
+ });
9782
+ connection.onRequest("hydra-acp/keep_alive", async (raw) => {
9783
+ const params = raw ?? {};
9784
+ const token2 = typeof params.token === "string" ? params.token : void 0;
9785
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId : void 0;
9786
+ const estimatedRemainingMs = typeof params.estimatedRemainingMs === "number" ? params.estimatedRemainingMs : void 0;
9787
+ if (token2 && sessionId) {
9788
+ const session = deps.manager.get(sessionId);
9789
+ session?.keepAliveClaim(token2, estimatedRemainingMs);
9790
+ }
9791
+ return { ok: true };
9792
+ });
9793
+ }
8142
9794
  connection.onRequest("session/new", async (raw) => {
8143
9795
  const params = SessionNewParams.parse(raw);
8144
9796
  const hydraMeta = extractHydraMeta(
8145
9797
  raw?._meta
8146
9798
  );
9799
+ const transformerNames = Array.isArray(hydraMeta.transformers) && hydraMeta.transformers.every((t) => typeof t === "string") ? hydraMeta.transformers : deps.manager.defaultTransformers ?? [];
9800
+ const transformChain = deps.transformers?.resolveChain(transformerNames) ?? [];
8147
9801
  const session = await deps.manager.create({
8148
9802
  cwd: params.cwd,
8149
9803
  agentId: params.agentId ?? deps.defaultAgent,
@@ -8151,7 +9805,8 @@ function registerAcpWsEndpoint(app, deps) {
8151
9805
  title: hydraMeta.name,
8152
9806
  agentArgs: hydraMeta.agentArgs,
8153
9807
  model: hydraMeta.model,
8154
- onInstallProgress: makeInstallProgressForwarder(connection)
9808
+ onInstallProgress: makeInstallProgressForwarder(connection),
9809
+ transformChain
8155
9810
  });
8156
9811
  const client = bindClientToSession(connection, session, state);
8157
9812
  const { entries: replay } = await session.attach(client, "full");
@@ -8183,6 +9838,14 @@ function registerAcpWsEndpoint(app, deps) {
8183
9838
  });
8184
9839
  connection.onRequest("session/attach", async (raw) => {
8185
9840
  const params = SessionAttachParams.parse(raw);
9841
+ const attachVersion = params.clientInfo?.version;
9842
+ if (attachVersion && processIdentity) {
9843
+ if (processIdentity.kind === "extension") {
9844
+ deps.onExtensionVersion?.(processIdentity.name, attachVersion);
9845
+ } else {
9846
+ deps.onTransformerVersion?.(processIdentity.name, attachVersion);
9847
+ }
9848
+ }
8186
9849
  const hydraHints = extractHydraMeta(params._meta).resume;
8187
9850
  const readonly = params.readonly === true;
8188
9851
  app.log.info(
@@ -8428,6 +10091,51 @@ function registerAcpWsEndpoint(app, deps) {
8428
10091
  }
8429
10092
  return session.amendPrompt(att.clientId, params);
8430
10093
  });
10094
+ connection.onRequest("hydra-acp/stream_open", async (raw) => {
10095
+ const params = StreamOpenParams.parse(raw);
10096
+ denyIfReadonly(params.sessionId, "hydra-acp/stream_open");
10097
+ const session = deps.manager.get(params.sessionId);
10098
+ if (!session) {
10099
+ const err = new Error(`session ${params.sessionId} not found`);
10100
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10101
+ throw err;
10102
+ }
10103
+ const openOpts = {};
10104
+ if (params.mode !== void 0) {
10105
+ openOpts.mode = params.mode;
10106
+ }
10107
+ if (params.capacityBytes !== void 0) {
10108
+ openOpts.capacityBytes = params.capacityBytes;
10109
+ }
10110
+ if (params.fileCapBytes !== void 0) {
10111
+ openOpts.fileCapBytes = params.fileCapBytes;
10112
+ }
10113
+ if ((params.mode ?? "memory") === "file") {
10114
+ openOpts.filePathFor = (sid) => path12.join(os4.tmpdir(), `hydra-stdin-${sid}.log`);
10115
+ }
10116
+ return session.openStream(openOpts);
10117
+ });
10118
+ connection.onRequest("hydra-acp/stream_write", async (raw) => {
10119
+ const params = StreamWriteParams.parse(raw);
10120
+ denyIfReadonly(params.sessionId, "hydra-acp/stream_write");
10121
+ const session = deps.manager.get(params.sessionId);
10122
+ if (!session) {
10123
+ const err = new Error(`session ${params.sessionId} not found`);
10124
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10125
+ throw err;
10126
+ }
10127
+ return session.streamWrite(params.chunk, params.eof);
10128
+ });
10129
+ connection.onRequest("hydra-acp/stream_read", async (raw) => {
10130
+ const params = StreamReadParams.parse(raw);
10131
+ const session = deps.manager.get(params.sessionId);
10132
+ if (!session) {
10133
+ const err = new Error(`session ${params.sessionId} not found`);
10134
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10135
+ throw err;
10136
+ }
10137
+ return session.streamRead(params.cursor, params.maxBytes, params.waitMs);
10138
+ });
8431
10139
  connection.onRequest("session/load", async (raw) => {
8432
10140
  const rawObj = raw ?? {};
8433
10141
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -8717,6 +10425,9 @@ function buildResponseMeta(session) {
8717
10425
  if (session.turnStartedAt !== void 0) {
8718
10426
  ours.turnStartedAt = session.turnStartedAt;
8719
10427
  }
10428
+ if (session.agentCapabilities !== void 0) {
10429
+ ours.agentCapabilities = session.agentCapabilities;
10430
+ }
8720
10431
  const queue = session.queueSnapshot();
8721
10432
  if (queue.length > 0) {
8722
10433
  ours.queue = queue;
@@ -8781,10 +10492,10 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
8781
10492
  async function startDaemon(config, serviceToken) {
8782
10493
  ensureLoopbackOrTls(config);
8783
10494
  const httpsOptions = config.daemon.tls ? {
8784
- key: await fsp5.readFile(config.daemon.tls.key),
8785
- cert: await fsp5.readFile(config.daemon.tls.cert)
10495
+ key: await fsp8.readFile(config.daemon.tls.key),
10496
+ cert: await fsp8.readFile(config.daemon.tls.cert)
8786
10497
  } : void 0;
8787
- await fsp5.mkdir(paths.home(), { recursive: true });
10498
+ await fsp8.mkdir(paths.home(), { recursive: true });
8788
10499
  const { stream: logStream, fileStream } = await buildLogStream(
8789
10500
  config.daemon.logLevel
8790
10501
  );
@@ -8809,9 +10520,11 @@ async function startDaemon(config, serviceToken) {
8809
10520
  });
8810
10521
  const sessionTokenStore = await SessionTokenStore.load();
8811
10522
  const authRateLimiter = new AuthRateLimiter();
10523
+ const processRegistry = new ProcessTokenRegistry();
8812
10524
  const validator = new CompositeTokenValidator([
8813
10525
  new StaticTokenValidator(serviceToken),
8814
- new SessionTokenValidator(sessionTokenStore)
10526
+ new SessionTokenValidator(sessionTokenStore),
10527
+ processRegistry
8815
10528
  ]);
8816
10529
  const auth = bearerAuth({ validator });
8817
10530
  app.addHook("onRequest", async (request, reply) => {
@@ -8848,18 +10561,28 @@ async function startDaemon(config, serviceToken) {
8848
10561
  const manager = new SessionManager(registry, spawner, void 0, {
8849
10562
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
8850
10563
  defaultModels: config.defaultModels,
10564
+ defaultTransformers: config.defaultTransformers,
8851
10565
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
8852
10566
  logger: agentLogger,
8853
10567
  npmRegistry: config.npmRegistry
8854
10568
  });
8855
- const extensions = new ExtensionManager(extensionList(config));
10569
+ const extensions = new ExtensionManager(extensionList(config), void 0, {
10570
+ tokenRegistry: processRegistry
10571
+ });
10572
+ const transformers = new TransformerManager(transformerList(config), void 0, {
10573
+ tokenRegistry: processRegistry
10574
+ });
8856
10575
  registerHealthRoutes(app, HYDRA_VERSION);
8857
10576
  registerSessionRoutes(app, manager, {
8858
10577
  agentId: config.defaultAgent,
8859
- cwd: config.defaultCwd
10578
+ cwd: config.defaultCwd,
10579
+ publicHost: config.daemon.publicHost,
10580
+ host: config.daemon.host,
10581
+ port: config.daemon.port
8860
10582
  });
8861
10583
  registerAgentRoutes(app, registry, manager, { npmRegistry: config.npmRegistry });
8862
10584
  registerExtensionRoutes(app, extensions);
10585
+ registerTransformerRoutes(app, transformers);
8863
10586
  registerConfigRoutes(app, {
8864
10587
  defaultAgent: config.defaultAgent,
8865
10588
  defaultCwd: config.defaultCwd
@@ -8871,13 +10594,17 @@ async function startDaemon(config, serviceToken) {
8871
10594
  registerAcpWsEndpoint(app, {
8872
10595
  validator,
8873
10596
  manager,
8874
- defaultAgent: config.defaultAgent
10597
+ defaultAgent: config.defaultAgent,
10598
+ processRegistry,
10599
+ onExtensionVersion: (name, version) => extensions.reportVersion(name, version),
10600
+ onTransformerVersion: (name, version) => transformers.reportVersion(name, version),
10601
+ transformers
8875
10602
  });
8876
10603
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
8877
10604
  const address = app.server.address();
8878
10605
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
8879
- await fsp5.mkdir(paths.home(), { recursive: true });
8880
- await fsp5.writeFile(
10606
+ await fsp8.mkdir(paths.home(), { recursive: true });
10607
+ await fsp8.writeFile(
8881
10608
  paths.pidFile(),
8882
10609
  JSON.stringify({
8883
10610
  pid: process.pid,
@@ -8889,15 +10616,18 @@ async function startDaemon(config, serviceToken) {
8889
10616
  );
8890
10617
  const scheme = config.daemon.tls ? "https" : "http";
8891
10618
  const wsScheme = config.daemon.tls ? "wss" : "ws";
8892
- extensions.setContext({
10619
+ const processContext = {
8893
10620
  daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
8894
10621
  daemonHost: config.daemon.host,
8895
10622
  daemonPort: boundPort,
8896
10623
  serviceToken,
8897
10624
  daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
8898
10625
  hydraHome: paths.home()
8899
- });
10626
+ };
10627
+ extensions.setContext(processContext);
10628
+ transformers.setContext(processContext);
8900
10629
  await extensions.start();
10630
+ await transformers.start();
8901
10631
  void manager.resurrectPendingQueues().catch((err) => {
8902
10632
  app.log.warn(
8903
10633
  `queue replay scan failed: ${err.message}`
@@ -8907,6 +10637,7 @@ async function startDaemon(config, serviceToken) {
8907
10637
  clearInterval(sweepInterval);
8908
10638
  await sessionTokenStore.flush();
8909
10639
  await extensions.stop();
10640
+ await transformers.stop();
8910
10641
  await manager.closeAll();
8911
10642
  await manager.flushMetaWrites();
8912
10643
  setBinaryInstallLogger(null);
@@ -8914,7 +10645,7 @@ async function startDaemon(config, serviceToken) {
8914
10645
  setAgentPruneLogger(null);
8915
10646
  await app.close();
8916
10647
  try {
8917
- fs14.unlinkSync(paths.pidFile());
10648
+ fs15.unlinkSync(paths.pidFile());
8918
10649
  } catch {
8919
10650
  }
8920
10651
  try {
@@ -8922,7 +10653,7 @@ async function startDaemon(config, serviceToken) {
8922
10653
  } catch {
8923
10654
  }
8924
10655
  };
8925
- return { app, manager, registry, extensions, shutdown };
10656
+ return { app, manager, registry, extensions, transformers, shutdown };
8926
10657
  }
8927
10658
  async function buildLogStream(level) {
8928
10659
  const fileStream = await createPinoRoll({