@hydra-acp/cli 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/dist/cli.js +2983 -655
  2. package/dist/index.d.ts +145 -13
  3. package/dist/index.js +1404 -81
  4. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1304,6 +1304,9 @@ function extractHydraMeta(meta) {
1304
1304
  if (typeof obj.promptUpdating === "boolean") {
1305
1305
  out.promptUpdating = obj.promptUpdating;
1306
1306
  }
1307
+ if (typeof obj.mcpStdin === "boolean") {
1308
+ out.mcpStdin = obj.mcpStdin;
1309
+ }
1307
1310
  if (typeof obj.promptAmending === "boolean") {
1308
1311
  out.promptAmending = obj.promptAmending;
1309
1312
  }
@@ -1419,6 +1422,10 @@ var SessionListEntry = z3.object({
1419
1422
  importedFromUpstreamSessionId: z3.string().optional(),
1420
1423
  // Set when this session was spawned as a child by a transformer.
1421
1424
  parentSessionId: z3.string().optional(),
1425
+ // clientInfo from the process that issued session/new. Lets list views
1426
+ // hide cat-style ancillary sessions by default while letting an
1427
+ // override flag surface them.
1428
+ originatingClient: z3.object({ name: z3.string(), version: z3.string().optional() }).optional(),
1422
1429
  updatedAt: z3.string(),
1423
1430
  attachedClients: z3.number().int().nonnegative(),
1424
1431
  status: z3.enum(["live", "cold"]).default("live"),
@@ -1757,7 +1764,10 @@ var JsonRpcConnection = class _JsonRpcConnection {
1757
1764
  // every entry would be re-appended to history.jsonl, doubling the log
1758
1765
  // each time the session was woken up.
1759
1766
  drainBuffered(method) {
1767
+ const buf = this.bufferedNotifications.get(method);
1768
+ const count = buf?.length ?? 0;
1760
1769
  this.bufferedNotifications.delete(method);
1770
+ return count;
1761
1771
  }
1762
1772
  onClose(handler) {
1763
1773
  this.closeHandlers.push(handler);
@@ -2018,14 +2028,30 @@ import { customAlphabet } from "nanoid";
2018
2028
 
2019
2029
  // src/core/stream-buffer.ts
2020
2030
  import * as fsp3 from "fs/promises";
2021
- var DEFAULT_CAPACITY_BYTES = 16 * 1024 * 1024;
2031
+ var DEFAULT_CAPACITY_BYTES = 64 * 1024 * 1024;
2032
+ var INITIAL_CAPACITY_BYTES = 1 * 1024 * 1024;
2022
2033
  var STREAM_READ_MAX_BYTES = 64 * 1024;
2023
2034
  var STREAM_WAIT_MAX_MS = 6e4;
2035
+ var STREAM_GREP_DEFAULT_MATCHES = 100;
2036
+ var STREAM_GREP_MAX_MATCHES = 1e3;
2037
+ var STREAM_GREP_DEFAULT_BYTES = 64 * 1024;
2038
+ var STREAM_GREP_MAX_BYTES = 256 * 1024;
2039
+ var STREAM_GREP_MAX_CONTEXT = 20;
2040
+ function escapeRegex(s) {
2041
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2042
+ }
2024
2043
  var SessionStreamBuffer = class {
2025
2044
  storage;
2026
- capacityBytes;
2045
+ // The configured cap. Eviction begins once writeCursor exceeds this.
2046
+ maxCapacityBytes;
2047
+ // The size of the currently-allocated `storage`. Starts at
2048
+ // INITIAL_CAPACITY_BYTES (clamped to maxCapacityBytes) and doubles on
2049
+ // demand. Once it reaches maxCapacityBytes the ring behaves like a
2050
+ // fixed-size buffer; before then, writeCursor < currentCapacityBytes
2051
+ // always, so no wrap-around math is in play.
2052
+ currentCapacityBytes;
2027
2053
  // Absolute monotonic byte offset of the next byte to be written. Also
2028
- // the count of bytes ever appended. `writeCursor - capacityBytes`
2054
+ // the count of bytes ever appended. `writeCursor - currentCapacityBytes`
2029
2055
  // (clamped at 0) is the oldest still-resident byte's cursor.
2030
2056
  writeCursor = 0;
2031
2057
  closed = false;
@@ -2040,24 +2066,33 @@ var SessionStreamBuffer = class {
2040
2066
  // calls don't interleave their writes.
2041
2067
  fileWriteChain = Promise.resolve();
2042
2068
  constructor(opts = {}) {
2043
- this.capacityBytes = opts.capacityBytes ?? DEFAULT_CAPACITY_BYTES;
2044
- if (this.capacityBytes <= 0) {
2069
+ this.maxCapacityBytes = opts.capacityBytes ?? DEFAULT_CAPACITY_BYTES;
2070
+ if (this.maxCapacityBytes <= 0) {
2045
2071
  throw new Error("capacityBytes must be > 0");
2046
2072
  }
2047
- this.storage = Buffer.alloc(this.capacityBytes);
2073
+ this.currentCapacityBytes = Math.min(
2074
+ INITIAL_CAPACITY_BYTES,
2075
+ this.maxCapacityBytes
2076
+ );
2077
+ this.storage = Buffer.alloc(this.currentCapacityBytes);
2048
2078
  this.filePath = opts.filePath;
2049
2079
  this.fileCapBytes = opts.fileCapBytes ?? Number.POSITIVE_INFINITY;
2050
2080
  this.onFileCapReached = opts.onFileCapReached;
2051
2081
  this.logWriteError = opts.logWriteError;
2052
2082
  }
2053
2083
  get capacity() {
2054
- return this.capacityBytes;
2084
+ return this.maxCapacityBytes;
2085
+ }
2086
+ // Currently-allocated storage size (for observability / tests). May be
2087
+ // anywhere between INITIAL_CAPACITY_BYTES and capacity.
2088
+ get allocatedBytes() {
2089
+ return this.currentCapacityBytes;
2055
2090
  }
2056
2091
  get writeCursorPos() {
2057
2092
  return this.writeCursor;
2058
2093
  }
2059
2094
  get oldestAvailable() {
2060
- return Math.max(0, this.writeCursor - this.capacityBytes);
2095
+ return Math.max(0, this.writeCursor - this.currentCapacityBytes);
2061
2096
  }
2062
2097
  get isClosed() {
2063
2098
  return this.closed;
@@ -2202,6 +2237,136 @@ var SessionStreamBuffer = class {
2202
2237
  this.waiters.push(waiter);
2203
2238
  });
2204
2239
  }
2240
+ // Scan the resident region line-by-line, returning lines that match
2241
+ // `pattern`. Server-side filtering so the agent doesn't have to pull
2242
+ // and decode 64 KiB base64 windows just to grep a multi-MB log.
2243
+ //
2244
+ // Lines are split on `\n` (LF). A trailing partial line (no LF) is
2245
+ // skipped when the buffer is still open — its bytes might be the
2246
+ // start of a longer line that's still being written — but is treated
2247
+ // as a final full line once the buffer is closed.
2248
+ //
2249
+ // Caps: max 1000 matches and 256 KiB of output bytes per call. The
2250
+ // agent should re-call with `cursor = nextCursor` to resume when
2251
+ // `truncated:true`.
2252
+ grep(opts) {
2253
+ const oldest = this.oldestAvailable;
2254
+ const requested = opts.cursor;
2255
+ let start = requested ?? oldest;
2256
+ let gap = 0;
2257
+ if (requested !== void 0 && requested < oldest) {
2258
+ gap = oldest - requested;
2259
+ start = oldest;
2260
+ }
2261
+ if (start > this.writeCursor) {
2262
+ start = this.writeCursor;
2263
+ }
2264
+ const slice = this.sliceFromRing(start, this.writeCursor - start);
2265
+ const useRegex = opts.regex ?? true;
2266
+ const flags = opts.caseInsensitive === true ? "i" : "";
2267
+ const re = useRegex ? new RegExp(opts.pattern, flags) : new RegExp(escapeRegex(opts.pattern), flags);
2268
+ const invert = opts.invert ?? false;
2269
+ const maxMatches = Math.max(
2270
+ 1,
2271
+ Math.min(
2272
+ opts.maxMatches ?? STREAM_GREP_DEFAULT_MATCHES,
2273
+ STREAM_GREP_MAX_MATCHES
2274
+ )
2275
+ );
2276
+ const maxBytes = Math.max(
2277
+ 1,
2278
+ Math.min(opts.maxBytes ?? STREAM_GREP_DEFAULT_BYTES, STREAM_GREP_MAX_BYTES)
2279
+ );
2280
+ const contextBefore = Math.max(
2281
+ 0,
2282
+ Math.min(opts.contextBefore ?? 0, STREAM_GREP_MAX_CONTEXT)
2283
+ );
2284
+ const contextAfter = Math.max(
2285
+ 0,
2286
+ Math.min(opts.contextAfter ?? 0, STREAM_GREP_MAX_CONTEXT)
2287
+ );
2288
+ const matches = [];
2289
+ const beforeRing = [];
2290
+ const pendingAfter = [];
2291
+ let bytesUsed = 0;
2292
+ let truncated = false;
2293
+ let lineStartByte = 0;
2294
+ let resumeFromLineStart = 0;
2295
+ const processLine = (lineCursor, lineText) => {
2296
+ for (const pa of pendingAfter) {
2297
+ if (pa.remaining > 0) {
2298
+ if (pa.match.after === void 0) {
2299
+ pa.match.after = [];
2300
+ }
2301
+ pa.match.after.push({ cursor: lineCursor, line: lineText });
2302
+ pa.remaining--;
2303
+ bytesUsed += lineText.length;
2304
+ }
2305
+ }
2306
+ while (pendingAfter.length > 0 && pendingAfter[0].remaining === 0) {
2307
+ pendingAfter.shift();
2308
+ }
2309
+ const matched = re.test(lineText) !== invert;
2310
+ if (matched && matches.length < maxMatches) {
2311
+ const m = { cursor: lineCursor, line: lineText };
2312
+ if (contextBefore > 0 && beforeRing.length > 0) {
2313
+ m.before = beforeRing.slice();
2314
+ for (const b of m.before) {
2315
+ bytesUsed += b.line.length;
2316
+ }
2317
+ }
2318
+ bytesUsed += lineText.length;
2319
+ matches.push(m);
2320
+ if (contextAfter > 0) {
2321
+ pendingAfter.push({ match: m, remaining: contextAfter });
2322
+ }
2323
+ }
2324
+ if (contextBefore > 0) {
2325
+ beforeRing.push({ cursor: lineCursor, line: lineText });
2326
+ while (beforeRing.length > contextBefore) {
2327
+ beforeRing.shift();
2328
+ }
2329
+ }
2330
+ const hitMaxMatches = matches.length >= maxMatches && pendingAfter.length === 0;
2331
+ const hitMaxBytes = bytesUsed >= maxBytes;
2332
+ return hitMaxMatches || hitMaxBytes;
2333
+ };
2334
+ for (let i = 0; i < slice.length; i++) {
2335
+ if (slice[i] !== 10) {
2336
+ continue;
2337
+ }
2338
+ const lineText = slice.subarray(lineStartByte, i).toString("utf8");
2339
+ const lineCursor = start + lineStartByte;
2340
+ lineStartByte = i + 1;
2341
+ resumeFromLineStart = lineStartByte;
2342
+ if (processLine(lineCursor, lineText)) {
2343
+ truncated = true;
2344
+ break;
2345
+ }
2346
+ }
2347
+ if (!truncated && lineStartByte < slice.length && this.closed) {
2348
+ const lineText = slice.subarray(lineStartByte).toString("utf8");
2349
+ const lineCursor = start + lineStartByte;
2350
+ if (processLine(lineCursor, lineText)) {
2351
+ truncated = true;
2352
+ }
2353
+ resumeFromLineStart = slice.length;
2354
+ }
2355
+ const nextCursor = Math.min(start + resumeFromLineStart, this.writeCursor);
2356
+ const result = {
2357
+ matches,
2358
+ truncated,
2359
+ nextCursor,
2360
+ scannedBytes: resumeFromLineStart
2361
+ };
2362
+ if (gap > 0) {
2363
+ result.gap = gap;
2364
+ }
2365
+ if (this.closed && nextCursor >= this.writeCursor) {
2366
+ result.eof = true;
2367
+ }
2368
+ return result;
2369
+ }
2205
2370
  wakeWaiters(outcome) {
2206
2371
  if (this.waiters.length === 0) {
2207
2372
  return;
@@ -2212,15 +2377,42 @@ var SessionStreamBuffer = class {
2212
2377
  w.resolve(outcome);
2213
2378
  }
2214
2379
  }
2380
+ // Grow `storage` if needed to fit `additionalBytes` more bytes without
2381
+ // wrapping. Caps at maxCapacityBytes; once we're at the cap, callers
2382
+ // fall back to ring-wrap behavior. Doubles each grow so we amortize.
2383
+ // Only called before we've ever wrapped (writeCursor < currentCapacity
2384
+ // always holds while we're growing), so the existing bytes live at
2385
+ // storage[0..writeCursor] and we can just copy them flat.
2386
+ growIfNeeded(additionalBytes) {
2387
+ if (this.currentCapacityBytes >= this.maxCapacityBytes) {
2388
+ return;
2389
+ }
2390
+ const needed = this.writeCursor + additionalBytes;
2391
+ if (needed <= this.currentCapacityBytes) {
2392
+ return;
2393
+ }
2394
+ let next = this.currentCapacityBytes;
2395
+ while (next < needed && next < this.maxCapacityBytes) {
2396
+ next = Math.min(this.maxCapacityBytes, next * 2);
2397
+ }
2398
+ if (next === this.currentCapacityBytes) {
2399
+ return;
2400
+ }
2401
+ const newStorage = Buffer.alloc(next);
2402
+ this.storage.copy(newStorage, 0, 0, this.writeCursor);
2403
+ this.storage = newStorage;
2404
+ this.currentCapacityBytes = next;
2405
+ }
2215
2406
  writeRing(chunk) {
2216
2407
  const len = chunk.length;
2217
- if (len >= this.capacityBytes) {
2218
- const tailStart = len - this.capacityBytes;
2408
+ this.growIfNeeded(len);
2409
+ if (len >= this.currentCapacityBytes) {
2410
+ const tailStart = len - this.currentCapacityBytes;
2219
2411
  chunk.copy(this.storage, 0, tailStart, len);
2220
2412
  return;
2221
2413
  }
2222
- const offset = this.writeCursor % this.capacityBytes;
2223
- const tailRoom = this.capacityBytes - offset;
2414
+ const offset = this.writeCursor % this.currentCapacityBytes;
2415
+ const tailRoom = this.currentCapacityBytes - offset;
2224
2416
  if (len <= tailRoom) {
2225
2417
  chunk.copy(this.storage, offset, 0, len);
2226
2418
  } else {
@@ -2233,8 +2425,8 @@ var SessionStreamBuffer = class {
2233
2425
  return Buffer.alloc(0);
2234
2426
  }
2235
2427
  const out = Buffer.alloc(length);
2236
- const offset = fromCursor % this.capacityBytes;
2237
- const tailLen = Math.min(length, this.capacityBytes - offset);
2428
+ const offset = fromCursor % this.currentCapacityBytes;
2429
+ const tailLen = Math.min(length, this.currentCapacityBytes - offset);
2238
2430
  this.storage.copy(out, 0, offset, offset + tailLen);
2239
2431
  if (tailLen < length) {
2240
2432
  this.storage.copy(out, tailLen, 0, length - tailLen);
@@ -2376,6 +2568,7 @@ var Session = class {
2376
2568
  agentCapabilities;
2377
2569
  agentArgs;
2378
2570
  parentSessionId;
2571
+ originatingClient;
2379
2572
  title;
2380
2573
  // Snapshot state delivered to attaching clients via the attach
2381
2574
  // response _meta rather than via history replay (which would be
@@ -2453,6 +2646,8 @@ var Session = class {
2453
2646
  listSessions;
2454
2647
  logger;
2455
2648
  transformChain;
2649
+ extensionCommands;
2650
+ extensionCommandsUnsub;
2456
2651
  // Outstanding "processing" claims: token → claim waiting for respondsTo discharge.
2457
2652
  pendingClaims = /* @__PURE__ */ new Map();
2458
2653
  agentChangeHandlers = [];
@@ -2528,6 +2723,7 @@ var Session = class {
2528
2723
  this.agentCapabilities = init.agentCapabilities;
2529
2724
  this.agentArgs = init.agentArgs;
2530
2725
  this.parentSessionId = init.parentSessionId;
2726
+ this.originatingClient = init.originatingClient;
2531
2727
  this.title = init.title;
2532
2728
  this.currentModel = init.currentModel;
2533
2729
  this.currentMode = init.currentMode;
@@ -2548,6 +2744,14 @@ var Session = class {
2548
2744
  this.listSessions = init.listSessions;
2549
2745
  this.logger = init.logger;
2550
2746
  this.transformChain = init.transformChain ?? [];
2747
+ this.extensionCommands = init.extensionCommands;
2748
+ if (this.extensionCommands) {
2749
+ this.extensionCommandsUnsub = this.extensionCommands.onChange(() => {
2750
+ if (!this.closed) {
2751
+ this.broadcastMergedCommands();
2752
+ }
2753
+ });
2754
+ }
2551
2755
  if (init.firstPromptSeeded) {
2552
2756
  this.firstPromptSeeded = true;
2553
2757
  }
@@ -2562,18 +2766,11 @@ var Session = class {
2562
2766
  this.notifyChain("session.opened", {});
2563
2767
  }
2564
2768
  broadcastMergedCommands() {
2565
- const merged = [
2566
- ...hydraCommandsAsAdvertised(),
2567
- { name: "model <model-id>", description: "Switch model; omit arg to list available models" },
2568
- { name: "sessions", description: "List all sessions" },
2569
- { name: "help", description: "Show available commands" },
2570
- ...this.agentAdvertisedCommands
2571
- ];
2572
2769
  this.recordAndBroadcast("session/update", {
2573
2770
  sessionId: this.upstreamSessionId,
2574
2771
  update: {
2575
2772
  sessionUpdate: "available_commands_update",
2576
- availableCommands: merged
2773
+ availableCommands: this.mergedAvailableCommands()
2577
2774
  }
2578
2775
  });
2579
2776
  }
@@ -3736,6 +3933,9 @@ var Session = class {
3736
3933
  if (!trimmed || trimmed === this.currentModel) {
3737
3934
  return true;
3738
3935
  }
3936
+ this.logger?.info(
3937
+ `live current_model_update: sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
3938
+ );
3739
3939
  this.currentModel = trimmed;
3740
3940
  for (const handler of this.modelHandlers) {
3741
3941
  try {
@@ -3781,6 +3981,9 @@ var Session = class {
3781
3981
  if (typeof cv === "string") {
3782
3982
  const trimmed = cv.trim();
3783
3983
  if (trimmed && trimmed !== this.currentModel) {
3984
+ this.logger?.info(
3985
+ `live config_option_update(model): sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
3986
+ );
3784
3987
  this.currentModel = trimmed;
3785
3988
  for (const handler of this.modelHandlers) {
3786
3989
  try {
@@ -3949,6 +4152,9 @@ var Session = class {
3949
4152
  this.broadcastAvailableModes();
3950
4153
  }
3951
4154
  setAgentAdvertisedModels(models) {
4155
+ this.logger?.info(
4156
+ `setAgentAdvertisedModels: sessionId=${this.sessionId} currentModel=${JSON.stringify(this.currentModel)} newList=[${models.map((m) => m.modelId).join(",")}]`
4157
+ );
3952
4158
  if (sameAdvertisedModels(this.agentAdvertisedModels, models)) {
3953
4159
  this.broadcastAvailableModels();
3954
4160
  return;
@@ -3980,6 +4186,38 @@ var Session = class {
3980
4186
  onModeChange(handler) {
3981
4187
  this.modeHandlers.push(handler);
3982
4188
  }
4189
+ // Apply a model change initiated by a client request (session/set_model)
4190
+ // when the agent doesn't emit a current_model_update notification, or
4191
+ // emits a non-spec shape (e.g. config_option_update). Fires modelHandlers
4192
+ // (persistence) and broadcasts a synthetic current_model_update so all
4193
+ // attached clients — including the originator — repaint immediately.
4194
+ applyModelChange(modelId) {
4195
+ const trimmed = modelId.trim();
4196
+ if (!trimmed || trimmed === this.currentModel) {
4197
+ return;
4198
+ }
4199
+ this.logger?.info(
4200
+ `applyModelChange: sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
4201
+ );
4202
+ this.currentModel = trimmed;
4203
+ for (const handler of this.modelHandlers) {
4204
+ try {
4205
+ handler(trimmed);
4206
+ } catch {
4207
+ }
4208
+ }
4209
+ const update = {
4210
+ sessionUpdate: "current_model_update",
4211
+ currentModel: trimmed
4212
+ };
4213
+ if (this.agentAdvertisedModels.length > 0) {
4214
+ update.availableModels = [...this.agentAdvertisedModels];
4215
+ }
4216
+ this.recordAndBroadcast("session/update", {
4217
+ sessionId: this.upstreamSessionId,
4218
+ update
4219
+ });
4220
+ }
3983
4221
  // Apply a mode change initiated by a client request (session/set_mode)
3984
4222
  // when the agent doesn't emit a current_mode_update notification on its
3985
4223
  // own. Fires modeHandlers so the persistence hook and any other listeners
@@ -4003,11 +4241,31 @@ var Session = class {
4003
4241
  onUsageChange(handler) {
4004
4242
  this.usageHandlers.push(handler);
4005
4243
  }
4006
- // Returns a freshly merged command list (hydra ∪ agent) for callers
4007
- // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
4008
- // assembling the attach response.
4244
+ // Returns a freshly merged command list (hydra ∪ extension ∪ agent) for
4245
+ // callers that need a snapshot — notably acp-ws.ts's buildResponseMeta
4246
+ // when assembling the attach response. Order: built-in hydra verbs,
4247
+ // top-level daemon verbs (/model, /sessions, /help), extension-registered
4248
+ // entries, then whatever the agent advertised.
4009
4249
  mergedAvailableCommands() {
4010
- return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
4250
+ const out = [
4251
+ ...hydraCommandsAsAdvertised(),
4252
+ { name: "model <model-id>", description: "Switch model; omit arg to list available models" },
4253
+ { name: "sessions", description: "List all sessions" },
4254
+ { name: "help", description: "Show available commands" }
4255
+ ];
4256
+ if (this.extensionCommands) {
4257
+ for (const { name, command } of this.extensionCommands.list()) {
4258
+ const head = `hydra ${name} ${command.verb}`;
4259
+ const display = command.argsHint ? `${head} ${command.argsHint}` : head;
4260
+ const entry = { name: display };
4261
+ if (command.description) {
4262
+ entry.description = command.description;
4263
+ }
4264
+ out.push(entry);
4265
+ }
4266
+ }
4267
+ out.push(...this.agentAdvertisedCommands);
4268
+ return out;
4011
4269
  }
4012
4270
  // The agent's own advertised commands (not merged with hydra verbs).
4013
4271
  // Used by SessionManager to persist into meta.json so cold resurrect
@@ -4059,39 +4317,118 @@ var Session = class {
4059
4317
  // caller's promise resolves like a normal turn. To add a verb: append
4060
4318
  // an entry to HYDRA_COMMANDS (drives validation + client advertising)
4061
4319
  // and a dispatch case in the switch below.
4320
+ //
4321
+ // Extensions/transformers can also bind verbs via the
4322
+ // ExtensionCommandRegistry: "/hydra <process-name> <verb> [args]" routes
4323
+ // to that process's WS connection. Built-in hydra verbs win on name
4324
+ // collision so an extension can never shadow them.
4062
4325
  async handleSlashCommand(text) {
4063
4326
  const rest = text.slice("/hydra".length).trim();
4064
4327
  const match = rest.match(/^(\S+)(?:\s+([\s\S]*))?$/);
4065
- const verb = match?.[1] ?? "";
4066
- const arg = (match?.[2] ?? "").trim();
4067
- if (verb === "") {
4328
+ const first = match?.[1] ?? "";
4329
+ const remainder = (match?.[2] ?? "").trim();
4330
+ if (first === "") {
4068
4331
  return { stopReason: "end_turn" };
4069
4332
  }
4070
- if (!HYDRA_COMMANDS.some((c) => c.verb === verb)) {
4071
- const known = HYDRA_COMMANDS.map((c) => c.verb).join(", ");
4072
- const err = new Error(
4073
- `unknown /hydra verb: ${verb} (known: ${known})`
4074
- );
4075
- err.code = JsonRpcErrorCodes.InvalidParams;
4076
- throw err;
4333
+ if (HYDRA_COMMANDS.some((c) => c.verb === first)) {
4334
+ switch (first) {
4335
+ case "title":
4336
+ return this.runTitleCommand(remainder);
4337
+ case "agent":
4338
+ return this.runAgentCommand(remainder);
4339
+ case "kill":
4340
+ return this.runKillCommand();
4341
+ case "restart":
4342
+ return this.runRestartCommand();
4343
+ default: {
4344
+ const err2 = new Error(
4345
+ `no dispatcher for /hydra verb ${first}`
4346
+ );
4347
+ err2.code = JsonRpcErrorCodes.InternalError;
4348
+ throw err2;
4349
+ }
4350
+ }
4077
4351
  }
4078
- switch (verb) {
4079
- case "title":
4080
- return this.runTitleCommand(arg);
4081
- case "agent":
4082
- return this.runAgentCommand(arg);
4083
- case "kill":
4084
- return this.runKillCommand();
4085
- case "restart":
4086
- return this.runRestartCommand();
4087
- default: {
4088
- const err = new Error(
4089
- `no dispatcher for /hydra verb ${verb}`
4090
- );
4091
- err.code = JsonRpcErrorCodes.InternalError;
4092
- throw err;
4352
+ if (this.extensionCommands?.has(first)) {
4353
+ return this.runExtensionCommand(first, remainder);
4354
+ }
4355
+ const known = HYDRA_COMMANDS.map((c) => c.verb);
4356
+ if (this.extensionCommands) {
4357
+ const seen = /* @__PURE__ */ new Set();
4358
+ for (const { name } of this.extensionCommands.list()) {
4359
+ if (!seen.has(name)) {
4360
+ known.push(name);
4361
+ seen.add(name);
4362
+ }
4093
4363
  }
4094
4364
  }
4365
+ const err = new Error(
4366
+ `unknown /hydra verb: ${first} (known: ${known.join(", ")})`
4367
+ );
4368
+ err.code = JsonRpcErrorCodes.InvalidParams;
4369
+ throw err;
4370
+ }
4371
+ // "/hydra <name> <verb> [args]" — name matches a registered extension
4372
+ // or transformer. We split the remainder into verb + args, validate the
4373
+ // verb against what the process advertised, and forward as a
4374
+ // hydra-acp/extension_command request on the process's WS connection.
4375
+ // The reply's text (if any) is broadcast as a synthetic
4376
+ // agent_message_chunk so it appears in the conversation alongside the
4377
+ // user's invocation.
4378
+ runExtensionCommand(name, remainder) {
4379
+ return this.enqueuePrompt(async () => {
4380
+ const entry = this.extensionCommands?.get(name);
4381
+ if (!entry) {
4382
+ return this.emitExtensionReply(
4383
+ `extension "${name}" is no longer connected`
4384
+ );
4385
+ }
4386
+ const m = remainder.match(/^(\S+)(?:\s+([\s\S]*))?$/);
4387
+ const verb = m?.[1] ?? "";
4388
+ const args = (m?.[2] ?? "").trim();
4389
+ if (verb === "") {
4390
+ const verbs = entry.commands.map((c) => c.verb).join(", ");
4391
+ return this.emitExtensionReply(
4392
+ `/hydra ${name} requires a verb (known: ${verbs || "(none)"})`
4393
+ );
4394
+ }
4395
+ if (!entry.commands.some((c) => c.verb === verb)) {
4396
+ const verbs = entry.commands.map((c) => c.verb).join(", ");
4397
+ return this.emitExtensionReply(
4398
+ `unknown verb "${verb}" for ${name} (known: ${verbs || "(none)"})`
4399
+ );
4400
+ }
4401
+ let reply;
4402
+ try {
4403
+ reply = await entry.connection.request("hydra-acp/extension_command", {
4404
+ sessionId: this.sessionId,
4405
+ verb,
4406
+ args
4407
+ });
4408
+ } catch (err) {
4409
+ return this.emitExtensionReply(
4410
+ `${name} ${verb}: ${err.message}`
4411
+ );
4412
+ }
4413
+ const text = reply && typeof reply === "object" && typeof reply.text === "string" ? reply.text : "";
4414
+ if (text.length > 0) {
4415
+ return this.emitExtensionReply(text);
4416
+ }
4417
+ return { stopReason: "end_turn" };
4418
+ });
4419
+ }
4420
+ emitExtensionReply(text) {
4421
+ this.recordAndBroadcast("session/update", {
4422
+ sessionId: this.upstreamSessionId,
4423
+ update: {
4424
+ sessionUpdate: "agent_message_chunk",
4425
+ content: { type: "text", text: `
4426
+ ${text}
4427
+ ` },
4428
+ _meta: { "hydra-acp": { synthetic: true } }
4429
+ }
4430
+ });
4431
+ return { stopReason: "end_turn" };
4095
4432
  }
4096
4433
  async handleSessionsCommand() {
4097
4434
  let text;
@@ -4154,11 +4491,15 @@ ${text}
4154
4491
  if (models.length === 0) {
4155
4492
  body = current ? `Current model: ${current}` : "_(no models advertised yet)_";
4156
4493
  } else {
4494
+ const inList = current ? models.some((m) => m.modelId === current) : true;
4157
4495
  const lines = models.map((m) => {
4158
4496
  const marker = m.modelId === current ? " \u25C0" : "";
4159
4497
  const desc = m.name && m.name !== m.modelId ? ` ${m.name}` : "";
4160
4498
  return `${m.modelId}${marker}${desc}`;
4161
4499
  });
4500
+ if (!inList && current) {
4501
+ lines.unshift(`${current} \u25C0`);
4502
+ }
4162
4503
  body = lines.join("\n");
4163
4504
  }
4164
4505
  this.recordAndBroadcast("session/update", {
@@ -4570,6 +4911,43 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
4570
4911
  }
4571
4912
  return out;
4572
4913
  }
4914
+ streamTail(bytes) {
4915
+ const buf = this.requireStreamBuffer();
4916
+ const r = buf.tail(bytes);
4917
+ return {
4918
+ bytes: r.bytes.toString("base64"),
4919
+ startCursor: r.startCursor,
4920
+ endCursor: r.endCursor,
4921
+ truncated: r.truncated
4922
+ };
4923
+ }
4924
+ streamHead(bytes) {
4925
+ const buf = this.requireStreamBuffer();
4926
+ const r = buf.head(bytes);
4927
+ return {
4928
+ bytes: r.bytes.toString("base64"),
4929
+ startCursor: r.startCursor,
4930
+ endCursor: r.endCursor,
4931
+ truncated: r.truncated
4932
+ };
4933
+ }
4934
+ async streamWaitFor(cursor, timeoutMs) {
4935
+ const buf = this.requireStreamBuffer();
4936
+ return buf.waitForData(cursor, timeoutMs);
4937
+ }
4938
+ streamGrep(opts) {
4939
+ const buf = this.requireStreamBuffer();
4940
+ return buf.grep(opts);
4941
+ }
4942
+ streamInfo() {
4943
+ const buf = this.requireStreamBuffer();
4944
+ return {
4945
+ writeCursor: buf.writeCursorPos,
4946
+ oldestAvailable: buf.oldestAvailable,
4947
+ capacity: buf.capacity,
4948
+ closed: buf.isClosed
4949
+ };
4950
+ }
4573
4951
  requireStreamBuffer() {
4574
4952
  if (this.streamBuffer === void 0) {
4575
4953
  const err = new Error(
@@ -4586,6 +4964,10 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
4586
4964
  }
4587
4965
  this.closed = true;
4588
4966
  this.cancelIdleTimer();
4967
+ if (this.extensionCommandsUnsub) {
4968
+ this.extensionCommandsUnsub();
4969
+ this.extensionCommandsUnsub = void 0;
4970
+ }
4589
4971
  if (this.currentEntry?.kind === "user") {
4590
4972
  this.broadcastTurnComplete(
4591
4973
  this.currentEntry.clientId,
@@ -5325,6 +5707,10 @@ var PersistedUsage = z4.object({
5325
5707
  costCurrency: z4.string().optional(),
5326
5708
  cumulativeCost: z4.number().optional()
5327
5709
  });
5710
+ var PersistedOriginatingClient = z4.object({
5711
+ name: z4.string(),
5712
+ version: z4.string().optional()
5713
+ });
5328
5714
  var SessionRecord = z4.object({
5329
5715
  version: z4.literal(1),
5330
5716
  sessionId: z4.string(),
@@ -5375,6 +5761,10 @@ var SessionRecord = z4.object({
5375
5761
  // Set when this session was spawned as a child by a transformer via
5376
5762
  // hydra-acp/spawn_child_session. Points to the spawning session's id.
5377
5763
  parentSessionId: z4.string().optional(),
5764
+ // clientInfo from the process that issued session/new. Picker and
5765
+ // `sessions list` use this to hide cat-style ancillary sessions by
5766
+ // default; carried in meta.json so cold sessions filter the same way.
5767
+ originatingClient: PersistedOriginatingClient.optional(),
5378
5768
  createdAt: z4.string(),
5379
5769
  updatedAt: z4.string()
5380
5770
  });
@@ -5497,6 +5887,7 @@ function recordFromMemorySession(args) {
5497
5887
  agentModels: args.agentModels,
5498
5888
  pendingHistorySync: args.pendingHistorySync,
5499
5889
  parentSessionId: args.parentSessionId,
5890
+ originatingClient: args.originatingClient,
5500
5891
  createdAt: args.createdAt ?? now,
5501
5892
  updatedAt: args.updatedAt ?? now
5502
5893
  };
@@ -5711,6 +6102,7 @@ var SessionManager = class {
5711
6102
  this.defaultTransformers = options.defaultTransformers ?? [];
5712
6103
  this.logger = options.logger;
5713
6104
  this.npmRegistry = options.npmRegistry;
6105
+ this.extensionCommands = options.extensionCommands;
5714
6106
  }
5715
6107
  registry;
5716
6108
  sessions = /* @__PURE__ */ new Map();
@@ -5729,6 +6121,7 @@ var SessionManager = class {
5729
6121
  metaWriteQueues = /* @__PURE__ */ new Map();
5730
6122
  logger;
5731
6123
  npmRegistry;
6124
+ extensionCommands;
5732
6125
  async create(params) {
5733
6126
  const fresh = await this.bootstrapAgent({
5734
6127
  agentId: params.agentId,
@@ -5782,7 +6175,9 @@ var SessionManager = class {
5782
6175
  agentModes: fresh.initialModes,
5783
6176
  agentModels: fresh.initialModels,
5784
6177
  transformChain: params.transformChain,
5785
- parentSessionId: params.parentSessionId
6178
+ parentSessionId: params.parentSessionId,
6179
+ originatingClient: params.originatingClient,
6180
+ extensionCommands: this.extensionCommands
5786
6181
  });
5787
6182
  await this.attachManagerHooks(session);
5788
6183
  return session;
@@ -5853,12 +6248,14 @@ var SessionManager = class {
5853
6248
  }
5854
6249
  let loadResult;
5855
6250
  try {
6251
+ const loadMeta = buildSessionLoadMeta(params.agentId, params.currentModel);
5856
6252
  loadResult = await agent.connection.request(
5857
6253
  "session/load",
5858
6254
  {
5859
6255
  sessionId: params.upstreamSessionId,
5860
6256
  cwd: params.cwd,
5861
- mcpServers: []
6257
+ mcpServers: [],
6258
+ ...loadMeta && { _meta: loadMeta }
5862
6259
  }
5863
6260
  );
5864
6261
  } catch (err) {
@@ -5874,7 +6271,10 @@ var SessionManager = class {
5874
6271
  () => void 0
5875
6272
  );
5876
6273
  } else {
5877
- agent.connection.drainBuffered("session/update");
6274
+ const drain1Count = agent.connection.drainBuffered("session/update");
6275
+ this.logger?.info(
6276
+ `resurrect: drain1 dropped ${drain1Count} buffered session/update(s) for sessionId=${params.hydraSessionId}`
6277
+ );
5878
6278
  }
5879
6279
  const agentReportedMode = extractInitialCurrentMode(loadResult ?? {});
5880
6280
  const advertisedModes = params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {}));
@@ -5892,6 +6292,30 @@ var SessionManager = class {
5892
6292
  this.logger?.info(
5893
6293
  `resurrect: effectiveMode=${JSON.stringify(effectiveMode)} for sessionId=${params.hydraSessionId}`
5894
6294
  );
6295
+ const agentReportedModel = extractInitialModel(loadResult ?? {});
6296
+ const advertisedModels = nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})) ?? params.agentModels;
6297
+ this.logger?.info(
6298
+ `resurrect: sessionId=${params.hydraSessionId} persistedModel=${JSON.stringify(params.currentModel)} agentReportedModel=${JSON.stringify(agentReportedModel)} advertisedModels=${JSON.stringify(advertisedModels?.map((m) => m.modelId))}`
6299
+ );
6300
+ if (params.pendingHistorySync !== true) {
6301
+ const drain2Count = agent.connection.drainBuffered("session/update");
6302
+ this.logger?.info(
6303
+ `resurrect: drain2 (post-mode-restore) dropped ${drain2Count} buffered session/update(s) for sessionId=${params.hydraSessionId}`
6304
+ );
6305
+ }
6306
+ const effectiveModel = await restoreCurrentModel({
6307
+ agent,
6308
+ upstreamSessionId: params.upstreamSessionId,
6309
+ persistedModel: params.currentModel,
6310
+ agentReportedModel,
6311
+ logger: this.logger
6312
+ });
6313
+ if (params.pendingHistorySync !== true) {
6314
+ const drain3Count = agent.connection.drainBuffered("session/update");
6315
+ this.logger?.info(
6316
+ `resurrect: drain3 (post-model-restore) dropped ${drain3Count} buffered session/update(s) for sessionId=${params.hydraSessionId}`
6317
+ );
6318
+ }
5895
6319
  const session = new Session({
5896
6320
  sessionId: params.hydraSessionId,
5897
6321
  cwd: params.cwd,
@@ -5908,11 +6332,7 @@ var SessionManager = class {
5908
6332
  listSessions: () => this.list(),
5909
6333
  historyStore: this.histories,
5910
6334
  historyMaxEntries: this.sessionHistoryMaxEntries,
5911
- // Prefer what we previously stored from a current_model_update; if
5912
- // we never captured one (e.g. old opencode sessions on disk before
5913
- // this fix), fall back to the model the agent ships in its
5914
- // session/load response body.
5915
- currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
6335
+ currentModel: effectiveModel,
5916
6336
  currentMode: effectiveMode,
5917
6337
  currentUsage: params.currentUsage,
5918
6338
  agentCommands: params.agentCommands,
@@ -5921,13 +6341,15 @@ var SessionManager = class {
5921
6341
  // snapshot — the proxy's available models can change between daemon
5922
6342
  // restarts (quota resets, rollouts), so meta.json is intentionally
5923
6343
  // treated as a cold fallback here, not the authoritative source.
5924
- agentModels: nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})) ?? params.agentModels,
6344
+ agentModels: advertisedModels,
5925
6345
  // Only gate the first-prompt title heuristic when we actually have
5926
6346
  // a title to preserve. A title-less session (lost to a write race
5927
6347
  // or never seeded) should re-derive from the next prompt rather
5928
6348
  // than stay stuck.
5929
6349
  firstPromptSeeded: !!params.title,
5930
- createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
6350
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6351
+ originatingClient: params.originatingClient,
6352
+ extensionCommands: this.extensionCommands
5931
6353
  });
5932
6354
  await this.attachManagerHooks(session);
5933
6355
  return session;
@@ -5946,7 +6368,11 @@ var SessionManager = class {
5946
6368
  cwd,
5947
6369
  agentArgs: params.agentArgs,
5948
6370
  mcpServers: [],
5949
- onInstallProgress: params.onInstallProgress
6371
+ onInstallProgress: params.onInstallProgress,
6372
+ // Pass the persisted model so bootstrapAgent calls session/set_model
6373
+ // during session/new — the only context where the agent reliably
6374
+ // honours the switch.
6375
+ model: params.currentModel
5950
6376
  });
5951
6377
  const advertisedModes = params.agentModes ?? fresh.initialModes;
5952
6378
  const effectiveMode = await restoreCurrentMode({
@@ -5957,6 +6383,15 @@ var SessionManager = class {
5957
6383
  advertisedModes,
5958
6384
  logger: this.logger
5959
6385
  });
6386
+ const advertisedModels = params.agentModels ?? fresh.initialModels;
6387
+ const effectiveModel = await restoreCurrentModel({
6388
+ agent: fresh.agent,
6389
+ upstreamSessionId: fresh.upstreamSessionId,
6390
+ persistedModel: params.currentModel,
6391
+ agentReportedModel: fresh.initialModel,
6392
+ logger: this.logger
6393
+ });
6394
+ fresh.agent.connection.drainBuffered("session/update");
5960
6395
  const session = new Session({
5961
6396
  sessionId: params.hydraSessionId,
5962
6397
  cwd,
@@ -5973,16 +6408,16 @@ var SessionManager = class {
5973
6408
  listSessions: () => this.list(),
5974
6409
  historyStore: this.histories,
5975
6410
  historyMaxEntries: this.sessionHistoryMaxEntries,
5976
- // Prefer the stored value (set by a previous current_model_update);
5977
- // fall back to whatever the agent ships in its session/new response.
5978
- currentModel: params.currentModel ?? fresh.initialModel,
6411
+ currentModel: effectiveModel,
5979
6412
  currentMode: effectiveMode,
5980
6413
  currentUsage: params.currentUsage,
5981
6414
  agentCommands: params.agentCommands,
5982
6415
  agentModes: advertisedModes,
5983
- agentModels: params.agentModels ?? fresh.initialModels,
6416
+ agentModels: advertisedModels,
5984
6417
  firstPromptSeeded: !!params.title,
5985
- createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
6418
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6419
+ originatingClient: params.originatingClient,
6420
+ extensionCommands: this.extensionCommands
5986
6421
  });
5987
6422
  await this.attachManagerHooks(session);
5988
6423
  void session.seedFromImport().catch(() => void 0);
@@ -6331,7 +6766,8 @@ var SessionManager = class {
6331
6766
  agentModes: record.agentModes,
6332
6767
  agentModels: record.agentModels,
6333
6768
  createdAt: record.createdAt,
6334
- pendingHistorySync: record.pendingHistorySync
6769
+ pendingHistorySync: record.pendingHistorySync,
6770
+ originatingClient: record.originatingClient
6335
6771
  };
6336
6772
  }
6337
6773
  async clearPendingHistorySync(sessionId) {
@@ -6432,6 +6868,7 @@ var SessionManager = class {
6432
6868
  currentModel: session.currentModel,
6433
6869
  currentUsage: session.currentUsage,
6434
6870
  parentSessionId: session.parentSessionId,
6871
+ originatingClient: session.originatingClient,
6435
6872
  updatedAt: used,
6436
6873
  attachedClients: session.attachedCount,
6437
6874
  status: "live",
@@ -6461,6 +6898,7 @@ var SessionManager = class {
6461
6898
  importedFromMachine: r.importedFromMachine,
6462
6899
  importedFromUpstreamSessionId: r.importedFromUpstreamSessionId,
6463
6900
  parentSessionId: r.parentSessionId,
6901
+ originatingClient: r.originatingClient,
6464
6902
  updatedAt: used,
6465
6903
  attachedClients: 0,
6466
6904
  status: "cold",
@@ -6806,6 +7244,7 @@ function mergeForPersistence(session, existing) {
6806
7244
  agentModes,
6807
7245
  agentModels,
6808
7246
  parentSessionId: session.parentSessionId ?? existing?.parentSessionId,
7247
+ originatingClient: session.originatingClient ?? existing?.originatingClient,
6809
7248
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
6810
7249
  });
6811
7250
  }
@@ -6834,6 +7273,13 @@ function usageSnapshotToPersisted(usage) {
6834
7273
  function persistedUsageToSnapshot(usage) {
6835
7274
  return usage ? { ...usage } : void 0;
6836
7275
  }
7276
+ function buildSessionLoadMeta(agentId, model) {
7277
+ if (!model)
7278
+ return void 0;
7279
+ if (agentId === "claude-acp")
7280
+ return { claudeCode: { options: { model } } };
7281
+ return void 0;
7282
+ }
6837
7283
  function extractInitialModel(result) {
6838
7284
  const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
6839
7285
  if (direct) {
@@ -7013,6 +7459,33 @@ async function restoreCurrentMode(opts) {
7013
7459
  return agentReportedMode;
7014
7460
  }
7015
7461
  }
7462
+ async function restoreCurrentModel(opts) {
7463
+ const { agent, upstreamSessionId, persistedModel, agentReportedModel, logger } = opts;
7464
+ if (!persistedModel) {
7465
+ return agentReportedModel;
7466
+ }
7467
+ if (persistedModel === agentReportedModel) {
7468
+ return persistedModel;
7469
+ }
7470
+ try {
7471
+ logger?.info(
7472
+ `resurrect: pushing persisted modelId=${JSON.stringify(persistedModel)} to agent (agentReported=${JSON.stringify(agentReportedModel)})`
7473
+ );
7474
+ await agent.connection.request("session/set_model", {
7475
+ sessionId: upstreamSessionId,
7476
+ modelId: persistedModel
7477
+ });
7478
+ logger?.info(
7479
+ `resurrect: session/set_model accepted, effectiveModel=${JSON.stringify(persistedModel)}`
7480
+ );
7481
+ return persistedModel;
7482
+ } catch (err) {
7483
+ logger?.warn(
7484
+ `resurrect: session/set_model rejected by agent for modelId=${JSON.stringify(persistedModel)} (${err.message}); session will use ${JSON.stringify(agentReportedModel)}`
7485
+ );
7486
+ return agentReportedModel;
7487
+ }
7488
+ }
7016
7489
  function parseModesList(list) {
7017
7490
  if (!Array.isArray(list)) {
7018
7491
  return [];
@@ -7966,6 +8439,55 @@ function withCode3(err, code) {
7966
8439
  return err;
7967
8440
  }
7968
8441
 
8442
+ // src/core/extension-commands.ts
8443
+ var ExtensionCommandRegistry = class {
8444
+ entries = /* @__PURE__ */ new Map();
8445
+ changeHandlers = [];
8446
+ register(name, connection, commands) {
8447
+ this.entries.set(name, { connection, commands: [...commands] });
8448
+ this.fireChanged();
8449
+ }
8450
+ clear(name) {
8451
+ if (this.entries.delete(name)) {
8452
+ this.fireChanged();
8453
+ }
8454
+ }
8455
+ get(name) {
8456
+ return this.entries.get(name);
8457
+ }
8458
+ has(name) {
8459
+ return this.entries.has(name);
8460
+ }
8461
+ // Snapshot of every (name, command) pair. Order is stable per-name
8462
+ // (insertion order of the map and the original commands list).
8463
+ list() {
8464
+ const out = [];
8465
+ for (const [name, entry] of this.entries) {
8466
+ for (const command of entry.commands) {
8467
+ out.push({ name, command });
8468
+ }
8469
+ }
8470
+ return out;
8471
+ }
8472
+ onChange(handler) {
8473
+ this.changeHandlers.push(handler);
8474
+ return () => {
8475
+ const i = this.changeHandlers.indexOf(handler);
8476
+ if (i >= 0) {
8477
+ this.changeHandlers.splice(i, 1);
8478
+ }
8479
+ };
8480
+ }
8481
+ fireChanged() {
8482
+ for (const h of this.changeHandlers) {
8483
+ try {
8484
+ h();
8485
+ } catch {
8486
+ }
8487
+ }
8488
+ }
8489
+ };
8490
+
7969
8491
  // src/core/agent-prune.ts
7970
8492
  import * as fsp7 from "fs/promises";
7971
8493
  import * as path9 from "path";
@@ -9131,6 +9653,376 @@ function isLoopbackHost(host) {
9131
9653
  return host === "127.0.0.1" || host === "::1" || host === "localhost" || host === "[::1]";
9132
9654
  }
9133
9655
 
9656
+ // src/core/history-search.ts
9657
+ function parseQuery(raw) {
9658
+ const trimmed = raw.trim();
9659
+ if (trimmed.length === 0) {
9660
+ return { operator: "OR", terms: [] };
9661
+ }
9662
+ const tokenRe = /\w+:"[^"]*"|"[^"]*"|\S+/g;
9663
+ const tokens = [];
9664
+ let m;
9665
+ while ((m = tokenRe.exec(trimmed)) !== null) {
9666
+ tokens.push(m[0]);
9667
+ }
9668
+ let operator = "OR";
9669
+ let sawAnd = false;
9670
+ let sawOr = false;
9671
+ const termTokens = [];
9672
+ for (const tok of tokens) {
9673
+ const upper = tok.toUpperCase();
9674
+ if (upper === "AND") {
9675
+ sawAnd = true;
9676
+ } else if (upper === "OR") {
9677
+ sawOr = true;
9678
+ } else {
9679
+ termTokens.push(tok);
9680
+ }
9681
+ }
9682
+ if (sawAnd) {
9683
+ operator = "AND";
9684
+ } else if (sawOr) {
9685
+ operator = "OR";
9686
+ }
9687
+ const terms = termTokens.map((tok) => parseTermToken(tok)).filter((t) => t.term.length > 0);
9688
+ return { operator, terms };
9689
+ }
9690
+ function parseTermToken(tok) {
9691
+ const pq = /^(\w+):"([^"]*)"$/.exec(tok);
9692
+ if (pq) {
9693
+ return { scope: prefixToScope(pq[1]), term: pq[2] };
9694
+ }
9695
+ const q = /^"([^"]*)"$/.exec(tok);
9696
+ if (q) {
9697
+ return { scope: "all", term: q[1] };
9698
+ }
9699
+ const pb = /^(prompt|response|tool):([\s\S]*)$/i.exec(tok);
9700
+ if (pb) {
9701
+ return { scope: prefixToScope(pb[1]), term: pb[2].trim() };
9702
+ }
9703
+ return { scope: "all", term: tok.trim() };
9704
+ }
9705
+ function prefixToScope(prefix) {
9706
+ switch (prefix.toLowerCase()) {
9707
+ case "prompt":
9708
+ return "user";
9709
+ case "response":
9710
+ return "agent";
9711
+ case "tool":
9712
+ return "tool";
9713
+ default:
9714
+ return "all";
9715
+ }
9716
+ }
9717
+ function scopeMatchesKind(scope, kind) {
9718
+ if (scope === "all") {
9719
+ return true;
9720
+ }
9721
+ if (scope === "user") {
9722
+ return kind === "user";
9723
+ }
9724
+ if (scope === "agent") {
9725
+ return kind === "agent" || kind === "thought";
9726
+ }
9727
+ return kind === "tool" || kind === "tool-input";
9728
+ }
9729
+ var DEFAULT_MAX_SNIPPETS_PER_SESSION = 5;
9730
+ var DEFAULT_MAX_SESSIONS = 200;
9731
+ var SNIPPET_SIDE = 30;
9732
+ async function searchHistories(manager, query, opts = {}) {
9733
+ const parsed = parseQuery(query);
9734
+ if (parsed.terms.length === 0) {
9735
+ return { query, truncated: false, results: [] };
9736
+ }
9737
+ const maxPerSession = opts.maxSnippetsPerSession ?? DEFAULT_MAX_SNIPPETS_PER_SESSION;
9738
+ const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
9739
+ const allow = opts.sessionIds ? new Set(opts.sessionIds) : null;
9740
+ const all = await manager.list();
9741
+ const candidates = allow ? all.filter((s) => allow.has(s.sessionId)) : all;
9742
+ const results = [];
9743
+ let truncated = false;
9744
+ for (const candidate of candidates) {
9745
+ if (results.length >= maxSessions) {
9746
+ truncated = true;
9747
+ break;
9748
+ }
9749
+ const entries = await manager.loadHistory(candidate.sessionId).catch(
9750
+ () => []
9751
+ );
9752
+ const found = scanSessionEntries(entries, parsed, maxPerSession);
9753
+ if (found.snippets.length === 0) {
9754
+ continue;
9755
+ }
9756
+ const hit = {
9757
+ sessionId: candidate.sessionId,
9758
+ cwd: candidate.cwd,
9759
+ status: candidate.status,
9760
+ updatedAt: candidate.updatedAt,
9761
+ totalMatches: found.totalMatches,
9762
+ snippets: found.snippets
9763
+ };
9764
+ if (candidate.title !== void 0) {
9765
+ hit.title = candidate.title;
9766
+ }
9767
+ results.push(hit);
9768
+ }
9769
+ return { query, truncated, results };
9770
+ }
9771
+ function scanSessionEntries(entries, query, maxSnippets) {
9772
+ if (query.terms.length === 0) {
9773
+ return { totalMatches: 0, snippets: [] };
9774
+ }
9775
+ let totalMatches = 0;
9776
+ const snippets = [];
9777
+ for (const { scope, term } of query.terms) {
9778
+ const result = scanForTerm(entries, term, scope, maxSnippets - snippets.length);
9779
+ if (query.operator === "AND" && result.totalMatches === 0) {
9780
+ return { totalMatches: 0, snippets: [] };
9781
+ }
9782
+ totalMatches += result.totalMatches;
9783
+ snippets.push(...result.snippets);
9784
+ }
9785
+ return { totalMatches, snippets };
9786
+ }
9787
+ function scanForTerm(entries, term, scope, snippetBudget) {
9788
+ const needle = term.toLowerCase();
9789
+ let totalMatches = 0;
9790
+ const snippets = [];
9791
+ for (const entry of entries) {
9792
+ const fragments = extractSearchableFragments(entry).filter(
9793
+ (f) => scopeMatchesKind(scope, f.kind)
9794
+ );
9795
+ for (const frag of fragments) {
9796
+ const hay = frag.text.toLowerCase();
9797
+ let idx = hay.indexOf(needle);
9798
+ if (idx === -1) {
9799
+ continue;
9800
+ }
9801
+ let occurrences = 0;
9802
+ while (idx !== -1) {
9803
+ occurrences++;
9804
+ idx = hay.indexOf(needle, idx + needle.length);
9805
+ }
9806
+ totalMatches += occurrences;
9807
+ if (snippets.length < snippetBudget) {
9808
+ const first = hay.indexOf(needle);
9809
+ const snippet = {
9810
+ kind: frag.kind,
9811
+ text: buildSnippet(frag.text, first, needle.length),
9812
+ recordedAt: entry.recordedAt
9813
+ };
9814
+ if (frag.toolName !== void 0) {
9815
+ snippet.toolName = frag.toolName;
9816
+ }
9817
+ snippets.push(snippet);
9818
+ }
9819
+ }
9820
+ }
9821
+ return { totalMatches, snippets };
9822
+ }
9823
+ function extractSearchableFragments(entry) {
9824
+ if (entry.method !== "session/update") {
9825
+ return [];
9826
+ }
9827
+ const params = entry.params;
9828
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
9829
+ return [];
9830
+ }
9831
+ const update = params.update;
9832
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
9833
+ return [];
9834
+ }
9835
+ const u = update;
9836
+ const tag = typeof u.sessionUpdate === "string" ? u.sessionUpdate : u.kind;
9837
+ if (typeof tag !== "string") {
9838
+ return [];
9839
+ }
9840
+ switch (tag) {
9841
+ case "agent_message_chunk": {
9842
+ const text = readContentText(u.content);
9843
+ return text ? [{ kind: "agent", text }] : [];
9844
+ }
9845
+ case "agent_thought":
9846
+ case "agent_thought_chunk": {
9847
+ const text = typeof u.text === "string" ? sanitizeWireText(u.text) : readContentText(u.content);
9848
+ return text ? [{ kind: "thought", text }] : [];
9849
+ }
9850
+ case "user_message_chunk": {
9851
+ if (isCompatPromptReceived(u)) {
9852
+ return [];
9853
+ }
9854
+ const text = readContentText(u.content);
9855
+ return text ? [{ kind: "user", text }] : [];
9856
+ }
9857
+ case "prompt_received": {
9858
+ const text = readPromptText(u.prompt);
9859
+ return text ? [{ kind: "user", text }] : [];
9860
+ }
9861
+ case "tool_call":
9862
+ case "tool_call_update": {
9863
+ return extractToolFragments(u);
9864
+ }
9865
+ default:
9866
+ return [];
9867
+ }
9868
+ }
9869
+ function extractToolFragments(u) {
9870
+ const toolName = readString2(u, "name");
9871
+ const title = readString2(u, "title");
9872
+ const out = [];
9873
+ if (title !== void 0) {
9874
+ const sanitized = sanitizeSingleLine(title);
9875
+ if (sanitized.length > 0) {
9876
+ const frag = { kind: "tool", text: sanitized };
9877
+ if (toolName !== void 0) {
9878
+ frag.toolName = toolName;
9879
+ }
9880
+ out.push(frag);
9881
+ }
9882
+ }
9883
+ if (toolName !== void 0 && toolName !== title) {
9884
+ const sanitized = sanitizeSingleLine(toolName);
9885
+ if (sanitized.length > 0) {
9886
+ out.push({ kind: "tool", toolName, text: sanitized });
9887
+ }
9888
+ }
9889
+ const rawInput = u.rawInput;
9890
+ if (rawInput && typeof rawInput === "object") {
9891
+ const serialized = safeStringify(rawInput);
9892
+ if (serialized.length > 0) {
9893
+ const frag = {
9894
+ kind: "tool-input",
9895
+ text: sanitizeSingleLine(serialized)
9896
+ };
9897
+ if (toolName !== void 0) {
9898
+ frag.toolName = toolName;
9899
+ }
9900
+ out.push(frag);
9901
+ }
9902
+ }
9903
+ const locations = u.locations;
9904
+ if (Array.isArray(locations) && locations.length > 0) {
9905
+ const serialized = safeStringify(locations);
9906
+ if (serialized.length > 0) {
9907
+ const frag = {
9908
+ kind: "tool-input",
9909
+ text: sanitizeSingleLine(serialized)
9910
+ };
9911
+ if (toolName !== void 0) {
9912
+ frag.toolName = toolName;
9913
+ }
9914
+ out.push(frag);
9915
+ }
9916
+ }
9917
+ const errorText = extractToolErrorText(u);
9918
+ if (errorText !== null) {
9919
+ const frag = { kind: "tool", text: errorText };
9920
+ if (toolName !== void 0) {
9921
+ frag.toolName = toolName;
9922
+ }
9923
+ out.push(frag);
9924
+ }
9925
+ return out;
9926
+ }
9927
+ function extractToolErrorText(u) {
9928
+ const content = u.content;
9929
+ if (Array.isArray(content)) {
9930
+ for (const block of content) {
9931
+ if (!block || typeof block !== "object") {
9932
+ continue;
9933
+ }
9934
+ const b = block;
9935
+ const inner = b.content;
9936
+ if (!inner || typeof inner !== "object") {
9937
+ continue;
9938
+ }
9939
+ const i = inner;
9940
+ if (i.type === "text" && typeof i.text === "string") {
9941
+ const s = sanitizeSingleLine(i.text);
9942
+ if (s.length > 0) {
9943
+ return s;
9944
+ }
9945
+ }
9946
+ }
9947
+ }
9948
+ const rawOutput = u.rawOutput;
9949
+ if (rawOutput && typeof rawOutput === "object") {
9950
+ const err = rawOutput.error;
9951
+ if (typeof err === "string") {
9952
+ const s = sanitizeSingleLine(err);
9953
+ if (s.length > 0) {
9954
+ return s;
9955
+ }
9956
+ }
9957
+ }
9958
+ return null;
9959
+ }
9960
+ function isCompatPromptReceived(u) {
9961
+ const meta = u._meta;
9962
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
9963
+ return false;
9964
+ }
9965
+ const hydra = meta["hydra-acp"];
9966
+ if (!hydra || typeof hydra !== "object" || Array.isArray(hydra)) {
9967
+ return false;
9968
+ }
9969
+ return hydra.compatFor === "prompt_received";
9970
+ }
9971
+ function readContentText(content) {
9972
+ if (typeof content === "string") {
9973
+ return sanitizeWireText(content);
9974
+ }
9975
+ if (!content || typeof content !== "object" || Array.isArray(content)) {
9976
+ return "";
9977
+ }
9978
+ const c = content;
9979
+ if (typeof c.text === "string") {
9980
+ return sanitizeWireText(c.text);
9981
+ }
9982
+ return "";
9983
+ }
9984
+ function readPromptText(prompt) {
9985
+ if (!Array.isArray(prompt)) {
9986
+ return "";
9987
+ }
9988
+ const parts = [];
9989
+ for (const block of prompt) {
9990
+ const text = readContentText(block);
9991
+ if (text.length > 0) {
9992
+ parts.push(text);
9993
+ }
9994
+ }
9995
+ return parts.join("");
9996
+ }
9997
+ function readString2(u, key) {
9998
+ const v = u[key];
9999
+ return typeof v === "string" ? v : void 0;
10000
+ }
10001
+ function safeStringify(value) {
10002
+ try {
10003
+ return JSON.stringify(value);
10004
+ } catch {
10005
+ return "";
10006
+ }
10007
+ }
10008
+ function buildSnippet(text, matchIdx, matchLen) {
10009
+ const flat = text.replace(/\s+/g, " ").trim();
10010
+ if (flat.length === 0) {
10011
+ return "";
10012
+ }
10013
+ const flatLower = flat.toLowerCase();
10014
+ const needleSlice = text.slice(matchIdx, matchIdx + matchLen).toLowerCase().replace(/\s+/g, " ").trim();
10015
+ let pos = needleSlice.length > 0 ? flatLower.indexOf(needleSlice) : 0;
10016
+ if (pos === -1) {
10017
+ pos = 0;
10018
+ }
10019
+ const start = Math.max(0, pos - SNIPPET_SIDE);
10020
+ const end = Math.min(flat.length, pos + needleSlice.length + SNIPPET_SIDE);
10021
+ const head = start > 0 ? "\u2026" : "";
10022
+ const tail = end < flat.length ? "\u2026" : "";
10023
+ return `${head}${flat.slice(start, end)}${tail}`;
10024
+ }
10025
+
9134
10026
  // src/daemon/routes/sessions.ts
9135
10027
  function resolveHydraHost(defaults) {
9136
10028
  if (defaults.publicHost && defaults.publicHost.length > 0) {
@@ -9147,6 +10039,17 @@ function registerSessionRoutes(app, manager, defaults) {
9147
10039
  const sessions = await manager.list({ cwd: query?.cwd });
9148
10040
  return { sessions };
9149
10041
  });
10042
+ app.get("/v1/sessions/search", async (request, reply) => {
10043
+ const query = request.query;
10044
+ const q = query?.q ?? "";
10045
+ if (q.trim().length === 0) {
10046
+ reply.code(400).send({ error: "q is required" });
10047
+ return reply;
10048
+ }
10049
+ const ids = query?.sessionIds ? query.sessionIds.split(",").filter((s) => s.length > 0) : void 0;
10050
+ const out = await searchHistories(manager, q, { sessionIds: ids });
10051
+ return out;
10052
+ });
9150
10053
  app.post("/v1/sessions", async (request, reply) => {
9151
10054
  const body = request.body ?? {};
9152
10055
  const cwd = expandHome(body.cwd ?? defaults.cwd);
@@ -9931,6 +10834,7 @@ function wsToMessageStream(ws) {
9931
10834
  // src/daemon/acp-ws.ts
9932
10835
  import * as os4 from "os";
9933
10836
  import * as path12 from "path";
10837
+ import { randomBytes as randomBytes3 } from "crypto";
9934
10838
  function registerAcpWsEndpoint(app, deps) {
9935
10839
  app.get("/acp", { websocket: true }, async (socket, request) => {
9936
10840
  const token = tokenFromUpgradeRequest({
@@ -9968,6 +10872,12 @@ function registerAcpWsEndpoint(app, deps) {
9968
10872
  };
9969
10873
  connection.onRequest("initialize", async (raw) => {
9970
10874
  const params = InitializeParams.parse(raw ?? {});
10875
+ if (params.clientInfo?.name) {
10876
+ state.clientInfo = {
10877
+ name: params.clientInfo.name,
10878
+ ...params.clientInfo.version !== void 0 ? { version: params.clientInfo.version } : {}
10879
+ };
10880
+ }
9971
10881
  const version = params.clientInfo?.version;
9972
10882
  if (version && processIdentity) {
9973
10883
  if (processIdentity.kind === "extension") {
@@ -9978,6 +10888,34 @@ function registerAcpWsEndpoint(app, deps) {
9978
10888
  }
9979
10889
  return buildInitializeResult();
9980
10890
  });
10891
+ if (processIdentity && deps.extensionCommands) {
10892
+ const registry = deps.extensionCommands;
10893
+ connection.onRequest("hydra-acp/register_commands", async (raw) => {
10894
+ const params = raw ?? {};
10895
+ const commands = Array.isArray(params.commands) ? params.commands.map((c) => {
10896
+ if (!c || typeof c !== "object") {
10897
+ return void 0;
10898
+ }
10899
+ const obj = c;
10900
+ if (typeof obj.verb !== "string" || obj.verb.length === 0) {
10901
+ return void 0;
10902
+ }
10903
+ const spec = { verb: obj.verb };
10904
+ if (typeof obj.argsHint === "string") {
10905
+ spec.argsHint = obj.argsHint;
10906
+ }
10907
+ if (typeof obj.description === "string") {
10908
+ spec.description = obj.description;
10909
+ }
10910
+ return spec;
10911
+ }).filter((s) => s !== void 0) : [];
10912
+ registry.register(processIdentity.name, connection, commands);
10913
+ return { ok: true, registered: commands.length };
10914
+ });
10915
+ connection.onClose(() => {
10916
+ registry.clear(processIdentity.name);
10917
+ });
10918
+ }
9981
10919
  if (processIdentity?.kind === "transformer") {
9982
10920
  connection.onRequest("transformer/initialize", async (raw) => {
9983
10921
  const params = raw ?? {};
@@ -10119,16 +11057,50 @@ function registerAcpWsEndpoint(app, deps) {
10119
11057
  );
10120
11058
  const transformerNames = Array.isArray(hydraMeta.transformers) && hydraMeta.transformers.every((t) => typeof t === "string") ? hydraMeta.transformers : deps.manager.defaultTransformers ?? [];
10121
11059
  const transformChain = deps.transformers?.resolveChain(transformerNames) ?? [];
10122
- const session = await deps.manager.create({
10123
- cwd: params.cwd,
10124
- agentId: params.agentId ?? deps.defaultAgent,
10125
- mcpServers: params.mcpServers,
10126
- title: hydraMeta.name,
10127
- agentArgs: hydraMeta.agentArgs,
10128
- model: hydraMeta.model,
10129
- onInstallProgress: makeInstallProgressForwarder(connection),
10130
- transformChain
10131
- });
11060
+ let stdinToken;
11061
+ let stdinReservation;
11062
+ let augmentedMcpServers = params.mcpServers;
11063
+ if (hydraMeta.mcpStdin === true && deps.stdinMcpRegistry !== void 0 && deps.getDaemonOrigin !== void 0) {
11064
+ stdinToken = randomBytes3(32).toString("hex");
11065
+ stdinReservation = deps.stdinMcpRegistry.reserve(stdinToken);
11066
+ const url = `${deps.getDaemonOrigin()}/mcp/stdin`;
11067
+ const descriptor = {
11068
+ name: "hydra_stdin",
11069
+ type: "http",
11070
+ url,
11071
+ headers: [
11072
+ { name: "Authorization", value: `Bearer ${stdinToken}` }
11073
+ ]
11074
+ };
11075
+ augmentedMcpServers = [...params.mcpServers ?? [], descriptor];
11076
+ }
11077
+ let session;
11078
+ try {
11079
+ session = await deps.manager.create({
11080
+ cwd: params.cwd,
11081
+ agentId: params.agentId ?? deps.defaultAgent,
11082
+ mcpServers: augmentedMcpServers,
11083
+ title: hydraMeta.name,
11084
+ agentArgs: hydraMeta.agentArgs,
11085
+ model: hydraMeta.model,
11086
+ onInstallProgress: makeInstallProgressForwarder(connection),
11087
+ transformChain,
11088
+ originatingClient: state.clientInfo
11089
+ });
11090
+ } catch (err) {
11091
+ if (stdinReservation !== void 0) {
11092
+ stdinReservation.abandon(err instanceof Error ? err : void 0);
11093
+ }
11094
+ throw err;
11095
+ }
11096
+ if (stdinToken !== void 0 && stdinReservation !== void 0 && deps.stdinMcpRegistry !== void 0) {
11097
+ const token2 = stdinToken;
11098
+ const registry = deps.stdinMcpRegistry;
11099
+ stdinReservation.complete(session);
11100
+ session.onClose(() => {
11101
+ void registry.unbind(token2);
11102
+ });
11103
+ }
10132
11104
  const client = bindClientToSession(connection, session, state);
10133
11105
  const { entries: replay } = await session.attach(client, "full");
10134
11106
  state.attached.set(session.sessionId, {
@@ -10525,7 +11497,10 @@ function registerAcpWsEndpoint(app, deps) {
10525
11497
  return null;
10526
11498
  }
10527
11499
  app.log.info(decision.logMessage);
10528
- return decision.session.forwardRequest("session/set_model", rawParams);
11500
+ const { modelId } = rawParams;
11501
+ const result = await decision.session.forwardRequest("session/set_model", rawParams);
11502
+ decision.session.applyModelChange(modelId);
11503
+ return result;
10529
11504
  });
10530
11505
  connection.onRequest("session/set_mode", async (rawParams) => {
10531
11506
  const params = rawParams;
@@ -10846,6 +11821,336 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
10846
11821
  };
10847
11822
  }
10848
11823
 
11824
+ // src/daemon/mcp/stdin-registry.ts
11825
+ var StdinMcpRegistry = class {
11826
+ byToken = /* @__PURE__ */ new Map();
11827
+ // Reserve a token slot before the session exists. Used by acp-ws when
11828
+ // we need to inject the bearer into the agent's mcpServers BEFORE
11829
+ // manager.create() returns — claude-acp connects to /mcp/stdin during
11830
+ // session/new initialization (eagerly), so the route handler must be
11831
+ // able to find the token by the time the agent's first request lands.
11832
+ reserve(token) {
11833
+ if (this.byToken.has(token)) {
11834
+ throw new Error(`stdin MCP token already bound`);
11835
+ }
11836
+ let resolveSession;
11837
+ let rejectSession;
11838
+ const sessionReady = new Promise((resolve3, reject) => {
11839
+ resolveSession = resolve3;
11840
+ rejectSession = reject;
11841
+ });
11842
+ sessionReady.catch(() => void 0);
11843
+ const entry = { session: void 0, sessionReady };
11844
+ this.byToken.set(token, entry);
11845
+ return {
11846
+ complete: (session) => {
11847
+ entry.session = session;
11848
+ resolveSession(session);
11849
+ },
11850
+ abandon: (reason) => {
11851
+ this.byToken.delete(token);
11852
+ rejectSession(reason ?? new Error("stdin MCP reservation abandoned"));
11853
+ }
11854
+ };
11855
+ }
11856
+ // Convenience for callers that already have the session in hand (and
11857
+ // for tests). Equivalent to reserve() + complete() back-to-back.
11858
+ bind(token, session) {
11859
+ const { complete } = this.reserve(token);
11860
+ complete(session);
11861
+ }
11862
+ lookup(token) {
11863
+ return this.byToken.get(token);
11864
+ }
11865
+ attachTransport(token, server, transport) {
11866
+ const ep = this.byToken.get(token);
11867
+ if (!ep) {
11868
+ return;
11869
+ }
11870
+ ep.server = server;
11871
+ ep.transport = transport;
11872
+ }
11873
+ async unbind(token) {
11874
+ const ep = this.byToken.get(token);
11875
+ if (!ep) {
11876
+ return;
11877
+ }
11878
+ this.byToken.delete(token);
11879
+ if (ep.transport) {
11880
+ try {
11881
+ await ep.transport.close();
11882
+ } catch {
11883
+ }
11884
+ }
11885
+ if (ep.server) {
11886
+ try {
11887
+ await ep.server.close();
11888
+ } catch {
11889
+ }
11890
+ }
11891
+ }
11892
+ size() {
11893
+ return this.byToken.size;
11894
+ }
11895
+ };
11896
+
11897
+ // src/daemon/mcp/stdin-server.ts
11898
+ import { randomUUID } from "crypto";
11899
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11900
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11901
+ import { z as z7 } from "zod";
11902
+ var BEARER_PREFIX2 = "Bearer ";
11903
+ function extractBearer(req) {
11904
+ const header = req.headers.authorization;
11905
+ if (typeof header !== "string") {
11906
+ return void 0;
11907
+ }
11908
+ if (!header.startsWith(BEARER_PREFIX2)) {
11909
+ return void 0;
11910
+ }
11911
+ const token = header.slice(BEARER_PREFIX2.length).trim();
11912
+ return token.length > 0 ? token : void 0;
11913
+ }
11914
+ function buildMcpServer(session) {
11915
+ const server = new McpServer(
11916
+ { name: "hydra-stdin", version: "1.0.0" },
11917
+ {
11918
+ instructions: "Piped input from `hydra cat --stream` is exposed here as a byte stream. Use `tail_stdin` for the latest N bytes (good for finding the end of a log), `head_stdin` for the first N bytes (good for headers/preamble), `read_stdin` for windowed reads against an absolute byte cursor, `wait_for_more` to block until new bytes arrive past a cursor, and `stdin_info` for the current cursors/capacity/closed status. Byte payloads come back base64-encoded."
11919
+ }
11920
+ );
11921
+ server.registerTool(
11922
+ "tail_stdin",
11923
+ {
11924
+ description: "Return the most recent `bytes` bytes of piped stdin (capped server-side, default 64 KiB max). `truncated:true` means older bytes existed but have been evicted from the ring.",
11925
+ inputSchema: {
11926
+ bytes: z7.number().int().min(1).describe("How many trailing bytes to return.")
11927
+ }
11928
+ },
11929
+ async ({ bytes }) => {
11930
+ const r = session.streamTail(bytes);
11931
+ return {
11932
+ content: [
11933
+ {
11934
+ type: "text",
11935
+ text: JSON.stringify(r)
11936
+ }
11937
+ ],
11938
+ structuredContent: r
11939
+ };
11940
+ }
11941
+ );
11942
+ server.registerTool(
11943
+ "head_stdin",
11944
+ {
11945
+ description: "Return the first `bytes` bytes of piped stdin (capped server-side, default 64 KiB max). `truncated:true` means the head has already been evicted from the ring and the returned bytes start at the oldest still-resident cursor.",
11946
+ inputSchema: {
11947
+ bytes: z7.number().int().min(1).describe("How many leading bytes to return.")
11948
+ }
11949
+ },
11950
+ async ({ bytes }) => {
11951
+ const r = session.streamHead(bytes);
11952
+ return {
11953
+ content: [
11954
+ {
11955
+ type: "text",
11956
+ text: JSON.stringify(r)
11957
+ }
11958
+ ],
11959
+ structuredContent: r
11960
+ };
11961
+ }
11962
+ );
11963
+ server.registerTool(
11964
+ "read_stdin",
11965
+ {
11966
+ description: "Read up to `max_bytes` bytes starting at absolute byte `cursor`. Returns `{bytes, nextCursor, gap?, eof?}` \u2014 `gap` is the number of bytes silently skipped because the ring had evicted them; `eof:true` means the producer closed and there is nothing left to read.",
11967
+ inputSchema: {
11968
+ cursor: z7.number().int().min(0).describe(
11969
+ "Absolute byte offset to start reading from. Use 0 to read from the very beginning (may produce a gap if old bytes have been evicted)."
11970
+ ),
11971
+ max_bytes: z7.number().int().min(1).optional().describe(
11972
+ "Optional cap on how many bytes to return. Server caps at 64 KiB regardless."
11973
+ ),
11974
+ wait_ms: z7.number().int().min(0).optional().describe(
11975
+ "If no bytes are available, block up to this many ms for more (capped server-side at 60_000)."
11976
+ )
11977
+ }
11978
+ },
11979
+ async ({ cursor, max_bytes, wait_ms }) => {
11980
+ const r = await session.streamRead(cursor, max_bytes, wait_ms);
11981
+ return {
11982
+ content: [
11983
+ {
11984
+ type: "text",
11985
+ text: JSON.stringify(r)
11986
+ }
11987
+ ],
11988
+ structuredContent: r
11989
+ };
11990
+ }
11991
+ );
11992
+ server.registerTool(
11993
+ "wait_for_more",
11994
+ {
11995
+ description: "Block until bytes are available past `cursor`, the stream closes, or `timeout_ms` elapses. Returns one of {data, eof, timeout} plus the current `writeCursor`. Use this when you've consumed everything up to a cursor and want to wait for more without busy-polling.",
11996
+ inputSchema: {
11997
+ cursor: z7.number().int().min(0).describe("The cursor you've already consumed up to."),
11998
+ timeout_ms: z7.number().int().min(0).describe("Maximum ms to block (server caps at 60_000).")
11999
+ }
12000
+ },
12001
+ async ({ cursor, timeout_ms }) => {
12002
+ const outcome = await session.streamWaitFor(cursor, timeout_ms);
12003
+ const info = session.streamInfo();
12004
+ const payload = { outcome, writeCursor: info.writeCursor, closed: info.closed };
12005
+ return {
12006
+ content: [
12007
+ {
12008
+ type: "text",
12009
+ text: JSON.stringify(payload)
12010
+ }
12011
+ ],
12012
+ structuredContent: payload
12013
+ };
12014
+ }
12015
+ );
12016
+ server.registerTool(
12017
+ "grep_stdin",
12018
+ {
12019
+ description: "Scan piped stdin line-by-line and return lines matching `pattern`. Prefer this over `read_stdin` when the question is 'find lines that mention X' \u2014 it filters server-side so you don't pull and decode 64 KiB base64 windows. Returns `{matches: [{cursor, line, before?, after?}], truncated, nextCursor, gap?, scannedBytes, eof?}`. Lines come back as decoded UTF-8 strings (not base64). When `truncated:true`, re-call with `cursor: nextCursor` to resume.",
12020
+ inputSchema: {
12021
+ pattern: z7.string().min(1).describe(
12022
+ "Search pattern. Treated as a JavaScript regular expression by default (set `regex:false` for a literal substring match)."
12023
+ ),
12024
+ regex: z7.boolean().optional().describe("Default true. Pass false to treat `pattern` as a literal substring."),
12025
+ case_insensitive: z7.boolean().optional().describe("Default false. Pass true for case-insensitive matching."),
12026
+ invert: z7.boolean().optional().describe("Default false. Pass true to return lines that do NOT match the pattern."),
12027
+ max_matches: z7.number().int().min(1).optional().describe("Default 100. Capped server-side at 1000."),
12028
+ max_bytes: z7.number().int().min(1).optional().describe("Default 64 KiB output. Capped server-side at 256 KiB."),
12029
+ context_before: z7.number().int().min(0).optional().describe("Default 0. Number of lines before each match to include (capped at 20)."),
12030
+ context_after: z7.number().int().min(0).optional().describe("Default 0. Number of lines after each match to include (capped at 20)."),
12031
+ cursor: z7.number().int().min(0).optional().describe(
12032
+ "Optional absolute byte offset to start scanning from. Omit to scan from the oldest still-resident byte. Pass the `nextCursor` from a previous truncated call to resume."
12033
+ )
12034
+ }
12035
+ },
12036
+ async (args) => {
12037
+ const opts = { pattern: args.pattern };
12038
+ if (args.regex !== void 0) {
12039
+ opts.regex = args.regex;
12040
+ }
12041
+ if (args.case_insensitive !== void 0) {
12042
+ opts.caseInsensitive = args.case_insensitive;
12043
+ }
12044
+ if (args.invert !== void 0) {
12045
+ opts.invert = args.invert;
12046
+ }
12047
+ if (args.max_matches !== void 0) {
12048
+ opts.maxMatches = args.max_matches;
12049
+ }
12050
+ if (args.max_bytes !== void 0) {
12051
+ opts.maxBytes = args.max_bytes;
12052
+ }
12053
+ if (args.context_before !== void 0) {
12054
+ opts.contextBefore = args.context_before;
12055
+ }
12056
+ if (args.context_after !== void 0) {
12057
+ opts.contextAfter = args.context_after;
12058
+ }
12059
+ if (args.cursor !== void 0) {
12060
+ opts.cursor = args.cursor;
12061
+ }
12062
+ const r = session.streamGrep(opts);
12063
+ const payload = r;
12064
+ return {
12065
+ content: [{ type: "text", text: JSON.stringify(r) }],
12066
+ structuredContent: payload
12067
+ };
12068
+ }
12069
+ );
12070
+ server.registerTool(
12071
+ "stdin_info",
12072
+ {
12073
+ description: "Report cursor / capacity / closed state of the stdin ring. Cheap; safe to call repeatedly.",
12074
+ inputSchema: {}
12075
+ },
12076
+ async () => {
12077
+ const r = session.streamInfo();
12078
+ return {
12079
+ content: [
12080
+ {
12081
+ type: "text",
12082
+ text: JSON.stringify(r)
12083
+ }
12084
+ ],
12085
+ structuredContent: r
12086
+ };
12087
+ }
12088
+ );
12089
+ return server;
12090
+ }
12091
+ async function ensureTransport(token, session, registry) {
12092
+ const existing = registry.lookup(token);
12093
+ if (existing?.transport !== void 0) {
12094
+ return existing.transport;
12095
+ }
12096
+ const server = buildMcpServer(session);
12097
+ const transport = new StreamableHTTPServerTransport({
12098
+ sessionIdGenerator: () => randomUUID()
12099
+ });
12100
+ await server.connect(transport);
12101
+ registry.attachTransport(token, server, transport);
12102
+ return transport;
12103
+ }
12104
+ var SESSION_READY_TIMEOUT_MS = 1e4;
12105
+ async function handle(req, reply, registry) {
12106
+ const token = extractBearer(req);
12107
+ if (token === void 0) {
12108
+ reply.code(401).send({ error: "missing bearer token" });
12109
+ return;
12110
+ }
12111
+ const ep = registry.lookup(token);
12112
+ if (ep === void 0) {
12113
+ reply.code(404).send({ error: "unknown stdin token" });
12114
+ return;
12115
+ }
12116
+ let session;
12117
+ if (ep.session !== void 0) {
12118
+ session = ep.session;
12119
+ } else {
12120
+ let timer;
12121
+ const timeout = new Promise((resolve3) => {
12122
+ timer = setTimeout(() => resolve3(void 0), SESSION_READY_TIMEOUT_MS);
12123
+ });
12124
+ const resolved = await Promise.race([
12125
+ ep.sessionReady.catch(() => void 0),
12126
+ timeout
12127
+ ]);
12128
+ if (timer !== void 0) {
12129
+ clearTimeout(timer);
12130
+ }
12131
+ if (resolved === void 0) {
12132
+ reply.code(503).send({ error: "session not ready" });
12133
+ return;
12134
+ }
12135
+ session = resolved;
12136
+ }
12137
+ const transport = await ensureTransport(token, session, registry);
12138
+ reply.hijack();
12139
+ await transport.handleRequest(req.raw, reply.raw, req.body);
12140
+ }
12141
+ function registerStdinMcpRoutes(app, registry) {
12142
+ const opts = { config: { skipAuth: true } };
12143
+ app.post("/mcp/stdin", opts, async (req, reply) => {
12144
+ await handle(req, reply, registry);
12145
+ });
12146
+ app.get("/mcp/stdin", opts, async (req, reply) => {
12147
+ await handle(req, reply, registry);
12148
+ });
12149
+ app.delete("/mcp/stdin", opts, async (req, reply) => {
12150
+ await handle(req, reply, registry);
12151
+ });
12152
+ }
12153
+
10849
12154
  // src/daemon/server.ts
10850
12155
  async function startDaemon(config, serviceToken) {
10851
12156
  ensureLoopbackOrTls(config);
@@ -10916,13 +12221,15 @@ async function startDaemon(config, serviceToken) {
10916
12221
  stderrTailBytes: config.daemon.agentStderrTailBytes,
10917
12222
  logger: agentLogger
10918
12223
  });
12224
+ const extensionCommands = new ExtensionCommandRegistry();
10919
12225
  const manager = new SessionManager(registry, spawner, void 0, {
10920
12226
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
10921
12227
  defaultModels: config.defaultModels,
10922
12228
  defaultTransformers: config.defaultTransformers,
10923
12229
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
10924
12230
  logger: agentLogger,
10925
- npmRegistry: config.npmRegistry
12231
+ npmRegistry: config.npmRegistry,
12232
+ extensionCommands
10926
12233
  });
10927
12234
  const extensions = new ExtensionManager(extensionList(config), void 0, {
10928
12235
  tokenRegistry: processRegistry
@@ -10949,6 +12256,19 @@ async function startDaemon(config, serviceToken) {
10949
12256
  store: sessionTokenStore,
10950
12257
  rateLimiter: authRateLimiter
10951
12258
  });
12259
+ const stdinMcpRegistry = new StdinMcpRegistry();
12260
+ registerStdinMcpRoutes(app, stdinMcpRegistry);
12261
+ let daemonOriginCached;
12262
+ const getDaemonOrigin = () => {
12263
+ if (daemonOriginCached !== void 0) {
12264
+ return daemonOriginCached;
12265
+ }
12266
+ const addr = app.server.address();
12267
+ const port = addr && typeof addr === "object" ? addr.port : config.daemon.port;
12268
+ const scheme2 = config.daemon.tls ? "https" : "http";
12269
+ daemonOriginCached = `${scheme2}://${config.daemon.host}:${port}`;
12270
+ return daemonOriginCached;
12271
+ };
10952
12272
  registerAcpWsEndpoint(app, {
10953
12273
  validator,
10954
12274
  manager,
@@ -10956,7 +12276,10 @@ async function startDaemon(config, serviceToken) {
10956
12276
  processRegistry,
10957
12277
  onExtensionVersion: (name, version) => extensions.reportVersion(name, version),
10958
12278
  onTransformerVersion: (name, version) => transformers.reportVersion(name, version),
10959
- transformers
12279
+ transformers,
12280
+ extensionCommands,
12281
+ stdinMcpRegistry,
12282
+ getDaemonOrigin
10960
12283
  });
10961
12284
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
10962
12285
  const address = app.server.address();