@hydra-acp/cli 0.1.48 → 0.1.49

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
@@ -1757,7 +1757,10 @@ var JsonRpcConnection = class _JsonRpcConnection {
1757
1757
  // every entry would be re-appended to history.jsonl, doubling the log
1758
1758
  // each time the session was woken up.
1759
1759
  drainBuffered(method) {
1760
+ const buf = this.bufferedNotifications.get(method);
1761
+ const count = buf?.length ?? 0;
1760
1762
  this.bufferedNotifications.delete(method);
1763
+ return count;
1761
1764
  }
1762
1765
  onClose(handler) {
1763
1766
  this.closeHandlers.push(handler);
@@ -2453,6 +2456,8 @@ var Session = class {
2453
2456
  listSessions;
2454
2457
  logger;
2455
2458
  transformChain;
2459
+ extensionCommands;
2460
+ extensionCommandsUnsub;
2456
2461
  // Outstanding "processing" claims: token → claim waiting for respondsTo discharge.
2457
2462
  pendingClaims = /* @__PURE__ */ new Map();
2458
2463
  agentChangeHandlers = [];
@@ -2548,6 +2553,14 @@ var Session = class {
2548
2553
  this.listSessions = init.listSessions;
2549
2554
  this.logger = init.logger;
2550
2555
  this.transformChain = init.transformChain ?? [];
2556
+ this.extensionCommands = init.extensionCommands;
2557
+ if (this.extensionCommands) {
2558
+ this.extensionCommandsUnsub = this.extensionCommands.onChange(() => {
2559
+ if (!this.closed) {
2560
+ this.broadcastMergedCommands();
2561
+ }
2562
+ });
2563
+ }
2551
2564
  if (init.firstPromptSeeded) {
2552
2565
  this.firstPromptSeeded = true;
2553
2566
  }
@@ -2562,18 +2575,11 @@ var Session = class {
2562
2575
  this.notifyChain("session.opened", {});
2563
2576
  }
2564
2577
  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
2578
  this.recordAndBroadcast("session/update", {
2573
2579
  sessionId: this.upstreamSessionId,
2574
2580
  update: {
2575
2581
  sessionUpdate: "available_commands_update",
2576
- availableCommands: merged
2582
+ availableCommands: this.mergedAvailableCommands()
2577
2583
  }
2578
2584
  });
2579
2585
  }
@@ -3736,6 +3742,9 @@ var Session = class {
3736
3742
  if (!trimmed || trimmed === this.currentModel) {
3737
3743
  return true;
3738
3744
  }
3745
+ this.logger?.info(
3746
+ `live current_model_update: sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
3747
+ );
3739
3748
  this.currentModel = trimmed;
3740
3749
  for (const handler of this.modelHandlers) {
3741
3750
  try {
@@ -3781,6 +3790,9 @@ var Session = class {
3781
3790
  if (typeof cv === "string") {
3782
3791
  const trimmed = cv.trim();
3783
3792
  if (trimmed && trimmed !== this.currentModel) {
3793
+ this.logger?.info(
3794
+ `live config_option_update(model): sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
3795
+ );
3784
3796
  this.currentModel = trimmed;
3785
3797
  for (const handler of this.modelHandlers) {
3786
3798
  try {
@@ -3949,6 +3961,9 @@ var Session = class {
3949
3961
  this.broadcastAvailableModes();
3950
3962
  }
3951
3963
  setAgentAdvertisedModels(models) {
3964
+ this.logger?.info(
3965
+ `setAgentAdvertisedModels: sessionId=${this.sessionId} currentModel=${JSON.stringify(this.currentModel)} newList=[${models.map((m) => m.modelId).join(",")}]`
3966
+ );
3952
3967
  if (sameAdvertisedModels(this.agentAdvertisedModels, models)) {
3953
3968
  this.broadcastAvailableModels();
3954
3969
  return;
@@ -3980,6 +3995,38 @@ var Session = class {
3980
3995
  onModeChange(handler) {
3981
3996
  this.modeHandlers.push(handler);
3982
3997
  }
3998
+ // Apply a model change initiated by a client request (session/set_model)
3999
+ // when the agent doesn't emit a current_model_update notification, or
4000
+ // emits a non-spec shape (e.g. config_option_update). Fires modelHandlers
4001
+ // (persistence) and broadcasts a synthetic current_model_update so all
4002
+ // attached clients — including the originator — repaint immediately.
4003
+ applyModelChange(modelId) {
4004
+ const trimmed = modelId.trim();
4005
+ if (!trimmed || trimmed === this.currentModel) {
4006
+ return;
4007
+ }
4008
+ this.logger?.info(
4009
+ `applyModelChange: sessionId=${this.sessionId} ${JSON.stringify(this.currentModel)} \u2192 ${JSON.stringify(trimmed)}`
4010
+ );
4011
+ this.currentModel = trimmed;
4012
+ for (const handler of this.modelHandlers) {
4013
+ try {
4014
+ handler(trimmed);
4015
+ } catch {
4016
+ }
4017
+ }
4018
+ const update = {
4019
+ sessionUpdate: "current_model_update",
4020
+ currentModel: trimmed
4021
+ };
4022
+ if (this.agentAdvertisedModels.length > 0) {
4023
+ update.availableModels = [...this.agentAdvertisedModels];
4024
+ }
4025
+ this.recordAndBroadcast("session/update", {
4026
+ sessionId: this.upstreamSessionId,
4027
+ update
4028
+ });
4029
+ }
3983
4030
  // Apply a mode change initiated by a client request (session/set_mode)
3984
4031
  // when the agent doesn't emit a current_mode_update notification on its
3985
4032
  // own. Fires modeHandlers so the persistence hook and any other listeners
@@ -4003,11 +4050,31 @@ var Session = class {
4003
4050
  onUsageChange(handler) {
4004
4051
  this.usageHandlers.push(handler);
4005
4052
  }
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.
4053
+ // Returns a freshly merged command list (hydra ∪ extension ∪ agent) for
4054
+ // callers that need a snapshot — notably acp-ws.ts's buildResponseMeta
4055
+ // when assembling the attach response. Order: built-in hydra verbs,
4056
+ // top-level daemon verbs (/model, /sessions, /help), extension-registered
4057
+ // entries, then whatever the agent advertised.
4009
4058
  mergedAvailableCommands() {
4010
- return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
4059
+ const out = [
4060
+ ...hydraCommandsAsAdvertised(),
4061
+ { name: "model <model-id>", description: "Switch model; omit arg to list available models" },
4062
+ { name: "sessions", description: "List all sessions" },
4063
+ { name: "help", description: "Show available commands" }
4064
+ ];
4065
+ if (this.extensionCommands) {
4066
+ for (const { name, command } of this.extensionCommands.list()) {
4067
+ const head = `hydra ${name} ${command.verb}`;
4068
+ const display = command.argsHint ? `${head} ${command.argsHint}` : head;
4069
+ const entry = { name: display };
4070
+ if (command.description) {
4071
+ entry.description = command.description;
4072
+ }
4073
+ out.push(entry);
4074
+ }
4075
+ }
4076
+ out.push(...this.agentAdvertisedCommands);
4077
+ return out;
4011
4078
  }
4012
4079
  // The agent's own advertised commands (not merged with hydra verbs).
4013
4080
  // Used by SessionManager to persist into meta.json so cold resurrect
@@ -4059,39 +4126,118 @@ var Session = class {
4059
4126
  // caller's promise resolves like a normal turn. To add a verb: append
4060
4127
  // an entry to HYDRA_COMMANDS (drives validation + client advertising)
4061
4128
  // and a dispatch case in the switch below.
4129
+ //
4130
+ // Extensions/transformers can also bind verbs via the
4131
+ // ExtensionCommandRegistry: "/hydra <process-name> <verb> [args]" routes
4132
+ // to that process's WS connection. Built-in hydra verbs win on name
4133
+ // collision so an extension can never shadow them.
4062
4134
  async handleSlashCommand(text) {
4063
4135
  const rest = text.slice("/hydra".length).trim();
4064
4136
  const match = rest.match(/^(\S+)(?:\s+([\s\S]*))?$/);
4065
- const verb = match?.[1] ?? "";
4066
- const arg = (match?.[2] ?? "").trim();
4067
- if (verb === "") {
4137
+ const first = match?.[1] ?? "";
4138
+ const remainder = (match?.[2] ?? "").trim();
4139
+ if (first === "") {
4068
4140
  return { stopReason: "end_turn" };
4069
4141
  }
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;
4142
+ if (HYDRA_COMMANDS.some((c) => c.verb === first)) {
4143
+ switch (first) {
4144
+ case "title":
4145
+ return this.runTitleCommand(remainder);
4146
+ case "agent":
4147
+ return this.runAgentCommand(remainder);
4148
+ case "kill":
4149
+ return this.runKillCommand();
4150
+ case "restart":
4151
+ return this.runRestartCommand();
4152
+ default: {
4153
+ const err2 = new Error(
4154
+ `no dispatcher for /hydra verb ${first}`
4155
+ );
4156
+ err2.code = JsonRpcErrorCodes.InternalError;
4157
+ throw err2;
4158
+ }
4159
+ }
4077
4160
  }
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;
4161
+ if (this.extensionCommands?.has(first)) {
4162
+ return this.runExtensionCommand(first, remainder);
4163
+ }
4164
+ const known = HYDRA_COMMANDS.map((c) => c.verb);
4165
+ if (this.extensionCommands) {
4166
+ const seen = /* @__PURE__ */ new Set();
4167
+ for (const { name } of this.extensionCommands.list()) {
4168
+ if (!seen.has(name)) {
4169
+ known.push(name);
4170
+ seen.add(name);
4171
+ }
4093
4172
  }
4094
4173
  }
4174
+ const err = new Error(
4175
+ `unknown /hydra verb: ${first} (known: ${known.join(", ")})`
4176
+ );
4177
+ err.code = JsonRpcErrorCodes.InvalidParams;
4178
+ throw err;
4179
+ }
4180
+ // "/hydra <name> <verb> [args]" — name matches a registered extension
4181
+ // or transformer. We split the remainder into verb + args, validate the
4182
+ // verb against what the process advertised, and forward as a
4183
+ // hydra-acp/extension_command request on the process's WS connection.
4184
+ // The reply's text (if any) is broadcast as a synthetic
4185
+ // agent_message_chunk so it appears in the conversation alongside the
4186
+ // user's invocation.
4187
+ runExtensionCommand(name, remainder) {
4188
+ return this.enqueuePrompt(async () => {
4189
+ const entry = this.extensionCommands?.get(name);
4190
+ if (!entry) {
4191
+ return this.emitExtensionReply(
4192
+ `extension "${name}" is no longer connected`
4193
+ );
4194
+ }
4195
+ const m = remainder.match(/^(\S+)(?:\s+([\s\S]*))?$/);
4196
+ const verb = m?.[1] ?? "";
4197
+ const args = (m?.[2] ?? "").trim();
4198
+ if (verb === "") {
4199
+ const verbs = entry.commands.map((c) => c.verb).join(", ");
4200
+ return this.emitExtensionReply(
4201
+ `/hydra ${name} requires a verb (known: ${verbs || "(none)"})`
4202
+ );
4203
+ }
4204
+ if (!entry.commands.some((c) => c.verb === verb)) {
4205
+ const verbs = entry.commands.map((c) => c.verb).join(", ");
4206
+ return this.emitExtensionReply(
4207
+ `unknown verb "${verb}" for ${name} (known: ${verbs || "(none)"})`
4208
+ );
4209
+ }
4210
+ let reply;
4211
+ try {
4212
+ reply = await entry.connection.request("hydra-acp/extension_command", {
4213
+ sessionId: this.sessionId,
4214
+ verb,
4215
+ args
4216
+ });
4217
+ } catch (err) {
4218
+ return this.emitExtensionReply(
4219
+ `${name} ${verb}: ${err.message}`
4220
+ );
4221
+ }
4222
+ const text = reply && typeof reply === "object" && typeof reply.text === "string" ? reply.text : "";
4223
+ if (text.length > 0) {
4224
+ return this.emitExtensionReply(text);
4225
+ }
4226
+ return { stopReason: "end_turn" };
4227
+ });
4228
+ }
4229
+ emitExtensionReply(text) {
4230
+ this.recordAndBroadcast("session/update", {
4231
+ sessionId: this.upstreamSessionId,
4232
+ update: {
4233
+ sessionUpdate: "agent_message_chunk",
4234
+ content: { type: "text", text: `
4235
+ ${text}
4236
+ ` },
4237
+ _meta: { "hydra-acp": { synthetic: true } }
4238
+ }
4239
+ });
4240
+ return { stopReason: "end_turn" };
4095
4241
  }
4096
4242
  async handleSessionsCommand() {
4097
4243
  let text;
@@ -4154,11 +4300,15 @@ ${text}
4154
4300
  if (models.length === 0) {
4155
4301
  body = current ? `Current model: ${current}` : "_(no models advertised yet)_";
4156
4302
  } else {
4303
+ const inList = current ? models.some((m) => m.modelId === current) : true;
4157
4304
  const lines = models.map((m) => {
4158
4305
  const marker = m.modelId === current ? " \u25C0" : "";
4159
4306
  const desc = m.name && m.name !== m.modelId ? ` ${m.name}` : "";
4160
4307
  return `${m.modelId}${marker}${desc}`;
4161
4308
  });
4309
+ if (!inList && current) {
4310
+ lines.unshift(`${current} \u25C0`);
4311
+ }
4162
4312
  body = lines.join("\n");
4163
4313
  }
4164
4314
  this.recordAndBroadcast("session/update", {
@@ -4586,6 +4736,10 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
4586
4736
  }
4587
4737
  this.closed = true;
4588
4738
  this.cancelIdleTimer();
4739
+ if (this.extensionCommandsUnsub) {
4740
+ this.extensionCommandsUnsub();
4741
+ this.extensionCommandsUnsub = void 0;
4742
+ }
4589
4743
  if (this.currentEntry?.kind === "user") {
4590
4744
  this.broadcastTurnComplete(
4591
4745
  this.currentEntry.clientId,
@@ -5711,6 +5865,7 @@ var SessionManager = class {
5711
5865
  this.defaultTransformers = options.defaultTransformers ?? [];
5712
5866
  this.logger = options.logger;
5713
5867
  this.npmRegistry = options.npmRegistry;
5868
+ this.extensionCommands = options.extensionCommands;
5714
5869
  }
5715
5870
  registry;
5716
5871
  sessions = /* @__PURE__ */ new Map();
@@ -5729,6 +5884,7 @@ var SessionManager = class {
5729
5884
  metaWriteQueues = /* @__PURE__ */ new Map();
5730
5885
  logger;
5731
5886
  npmRegistry;
5887
+ extensionCommands;
5732
5888
  async create(params) {
5733
5889
  const fresh = await this.bootstrapAgent({
5734
5890
  agentId: params.agentId,
@@ -5782,7 +5938,8 @@ var SessionManager = class {
5782
5938
  agentModes: fresh.initialModes,
5783
5939
  agentModels: fresh.initialModels,
5784
5940
  transformChain: params.transformChain,
5785
- parentSessionId: params.parentSessionId
5941
+ parentSessionId: params.parentSessionId,
5942
+ extensionCommands: this.extensionCommands
5786
5943
  });
5787
5944
  await this.attachManagerHooks(session);
5788
5945
  return session;
@@ -5853,12 +6010,14 @@ var SessionManager = class {
5853
6010
  }
5854
6011
  let loadResult;
5855
6012
  try {
6013
+ const loadMeta = buildSessionLoadMeta(params.agentId, params.currentModel);
5856
6014
  loadResult = await agent.connection.request(
5857
6015
  "session/load",
5858
6016
  {
5859
6017
  sessionId: params.upstreamSessionId,
5860
6018
  cwd: params.cwd,
5861
- mcpServers: []
6019
+ mcpServers: [],
6020
+ ...loadMeta && { _meta: loadMeta }
5862
6021
  }
5863
6022
  );
5864
6023
  } catch (err) {
@@ -5874,7 +6033,10 @@ var SessionManager = class {
5874
6033
  () => void 0
5875
6034
  );
5876
6035
  } else {
5877
- agent.connection.drainBuffered("session/update");
6036
+ const drain1Count = agent.connection.drainBuffered("session/update");
6037
+ this.logger?.info(
6038
+ `resurrect: drain1 dropped ${drain1Count} buffered session/update(s) for sessionId=${params.hydraSessionId}`
6039
+ );
5878
6040
  }
5879
6041
  const agentReportedMode = extractInitialCurrentMode(loadResult ?? {});
5880
6042
  const advertisedModes = params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {}));
@@ -5892,6 +6054,30 @@ var SessionManager = class {
5892
6054
  this.logger?.info(
5893
6055
  `resurrect: effectiveMode=${JSON.stringify(effectiveMode)} for sessionId=${params.hydraSessionId}`
5894
6056
  );
6057
+ const agentReportedModel = extractInitialModel(loadResult ?? {});
6058
+ const advertisedModels = nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})) ?? params.agentModels;
6059
+ this.logger?.info(
6060
+ `resurrect: sessionId=${params.hydraSessionId} persistedModel=${JSON.stringify(params.currentModel)} agentReportedModel=${JSON.stringify(agentReportedModel)} advertisedModels=${JSON.stringify(advertisedModels?.map((m) => m.modelId))}`
6061
+ );
6062
+ if (params.pendingHistorySync !== true) {
6063
+ const drain2Count = agent.connection.drainBuffered("session/update");
6064
+ this.logger?.info(
6065
+ `resurrect: drain2 (post-mode-restore) dropped ${drain2Count} buffered session/update(s) for sessionId=${params.hydraSessionId}`
6066
+ );
6067
+ }
6068
+ const effectiveModel = await restoreCurrentModel({
6069
+ agent,
6070
+ upstreamSessionId: params.upstreamSessionId,
6071
+ persistedModel: params.currentModel,
6072
+ agentReportedModel,
6073
+ logger: this.logger
6074
+ });
6075
+ if (params.pendingHistorySync !== true) {
6076
+ const drain3Count = agent.connection.drainBuffered("session/update");
6077
+ this.logger?.info(
6078
+ `resurrect: drain3 (post-model-restore) dropped ${drain3Count} buffered session/update(s) for sessionId=${params.hydraSessionId}`
6079
+ );
6080
+ }
5895
6081
  const session = new Session({
5896
6082
  sessionId: params.hydraSessionId,
5897
6083
  cwd: params.cwd,
@@ -5908,11 +6094,7 @@ var SessionManager = class {
5908
6094
  listSessions: () => this.list(),
5909
6095
  historyStore: this.histories,
5910
6096
  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 ?? {}),
6097
+ currentModel: effectiveModel,
5916
6098
  currentMode: effectiveMode,
5917
6099
  currentUsage: params.currentUsage,
5918
6100
  agentCommands: params.agentCommands,
@@ -5921,13 +6103,14 @@ var SessionManager = class {
5921
6103
  // snapshot — the proxy's available models can change between daemon
5922
6104
  // restarts (quota resets, rollouts), so meta.json is intentionally
5923
6105
  // treated as a cold fallback here, not the authoritative source.
5924
- agentModels: nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})) ?? params.agentModels,
6106
+ agentModels: advertisedModels,
5925
6107
  // Only gate the first-prompt title heuristic when we actually have
5926
6108
  // a title to preserve. A title-less session (lost to a write race
5927
6109
  // or never seeded) should re-derive from the next prompt rather
5928
6110
  // than stay stuck.
5929
6111
  firstPromptSeeded: !!params.title,
5930
- createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
6112
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6113
+ extensionCommands: this.extensionCommands
5931
6114
  });
5932
6115
  await this.attachManagerHooks(session);
5933
6116
  return session;
@@ -5946,7 +6129,11 @@ var SessionManager = class {
5946
6129
  cwd,
5947
6130
  agentArgs: params.agentArgs,
5948
6131
  mcpServers: [],
5949
- onInstallProgress: params.onInstallProgress
6132
+ onInstallProgress: params.onInstallProgress,
6133
+ // Pass the persisted model so bootstrapAgent calls session/set_model
6134
+ // during session/new — the only context where the agent reliably
6135
+ // honours the switch.
6136
+ model: params.currentModel
5950
6137
  });
5951
6138
  const advertisedModes = params.agentModes ?? fresh.initialModes;
5952
6139
  const effectiveMode = await restoreCurrentMode({
@@ -5957,6 +6144,15 @@ var SessionManager = class {
5957
6144
  advertisedModes,
5958
6145
  logger: this.logger
5959
6146
  });
6147
+ const advertisedModels = params.agentModels ?? fresh.initialModels;
6148
+ const effectiveModel = await restoreCurrentModel({
6149
+ agent: fresh.agent,
6150
+ upstreamSessionId: fresh.upstreamSessionId,
6151
+ persistedModel: params.currentModel,
6152
+ agentReportedModel: fresh.initialModel,
6153
+ logger: this.logger
6154
+ });
6155
+ fresh.agent.connection.drainBuffered("session/update");
5960
6156
  const session = new Session({
5961
6157
  sessionId: params.hydraSessionId,
5962
6158
  cwd,
@@ -5973,16 +6169,15 @@ var SessionManager = class {
5973
6169
  listSessions: () => this.list(),
5974
6170
  historyStore: this.histories,
5975
6171
  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,
6172
+ currentModel: effectiveModel,
5979
6173
  currentMode: effectiveMode,
5980
6174
  currentUsage: params.currentUsage,
5981
6175
  agentCommands: params.agentCommands,
5982
6176
  agentModes: advertisedModes,
5983
- agentModels: params.agentModels ?? fresh.initialModels,
6177
+ agentModels: advertisedModels,
5984
6178
  firstPromptSeeded: !!params.title,
5985
- createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
6179
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6180
+ extensionCommands: this.extensionCommands
5986
6181
  });
5987
6182
  await this.attachManagerHooks(session);
5988
6183
  void session.seedFromImport().catch(() => void 0);
@@ -6834,6 +7029,13 @@ function usageSnapshotToPersisted(usage) {
6834
7029
  function persistedUsageToSnapshot(usage) {
6835
7030
  return usage ? { ...usage } : void 0;
6836
7031
  }
7032
+ function buildSessionLoadMeta(agentId, model) {
7033
+ if (!model)
7034
+ return void 0;
7035
+ if (agentId === "claude-acp")
7036
+ return { claudeCode: { options: { model } } };
7037
+ return void 0;
7038
+ }
6837
7039
  function extractInitialModel(result) {
6838
7040
  const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
6839
7041
  if (direct) {
@@ -7013,6 +7215,33 @@ async function restoreCurrentMode(opts) {
7013
7215
  return agentReportedMode;
7014
7216
  }
7015
7217
  }
7218
+ async function restoreCurrentModel(opts) {
7219
+ const { agent, upstreamSessionId, persistedModel, agentReportedModel, logger } = opts;
7220
+ if (!persistedModel) {
7221
+ return agentReportedModel;
7222
+ }
7223
+ if (persistedModel === agentReportedModel) {
7224
+ return persistedModel;
7225
+ }
7226
+ try {
7227
+ logger?.info(
7228
+ `resurrect: pushing persisted modelId=${JSON.stringify(persistedModel)} to agent (agentReported=${JSON.stringify(agentReportedModel)})`
7229
+ );
7230
+ await agent.connection.request("session/set_model", {
7231
+ sessionId: upstreamSessionId,
7232
+ modelId: persistedModel
7233
+ });
7234
+ logger?.info(
7235
+ `resurrect: session/set_model accepted, effectiveModel=${JSON.stringify(persistedModel)}`
7236
+ );
7237
+ return persistedModel;
7238
+ } catch (err) {
7239
+ logger?.warn(
7240
+ `resurrect: session/set_model rejected by agent for modelId=${JSON.stringify(persistedModel)} (${err.message}); session will use ${JSON.stringify(agentReportedModel)}`
7241
+ );
7242
+ return agentReportedModel;
7243
+ }
7244
+ }
7016
7245
  function parseModesList(list) {
7017
7246
  if (!Array.isArray(list)) {
7018
7247
  return [];
@@ -7966,6 +8195,55 @@ function withCode3(err, code) {
7966
8195
  return err;
7967
8196
  }
7968
8197
 
8198
+ // src/core/extension-commands.ts
8199
+ var ExtensionCommandRegistry = class {
8200
+ entries = /* @__PURE__ */ new Map();
8201
+ changeHandlers = [];
8202
+ register(name, connection, commands) {
8203
+ this.entries.set(name, { connection, commands: [...commands] });
8204
+ this.fireChanged();
8205
+ }
8206
+ clear(name) {
8207
+ if (this.entries.delete(name)) {
8208
+ this.fireChanged();
8209
+ }
8210
+ }
8211
+ get(name) {
8212
+ return this.entries.get(name);
8213
+ }
8214
+ has(name) {
8215
+ return this.entries.has(name);
8216
+ }
8217
+ // Snapshot of every (name, command) pair. Order is stable per-name
8218
+ // (insertion order of the map and the original commands list).
8219
+ list() {
8220
+ const out = [];
8221
+ for (const [name, entry] of this.entries) {
8222
+ for (const command of entry.commands) {
8223
+ out.push({ name, command });
8224
+ }
8225
+ }
8226
+ return out;
8227
+ }
8228
+ onChange(handler) {
8229
+ this.changeHandlers.push(handler);
8230
+ return () => {
8231
+ const i = this.changeHandlers.indexOf(handler);
8232
+ if (i >= 0) {
8233
+ this.changeHandlers.splice(i, 1);
8234
+ }
8235
+ };
8236
+ }
8237
+ fireChanged() {
8238
+ for (const h of this.changeHandlers) {
8239
+ try {
8240
+ h();
8241
+ } catch {
8242
+ }
8243
+ }
8244
+ }
8245
+ };
8246
+
7969
8247
  // src/core/agent-prune.ts
7970
8248
  import * as fsp7 from "fs/promises";
7971
8249
  import * as path9 from "path";
@@ -9131,6 +9409,376 @@ function isLoopbackHost(host) {
9131
9409
  return host === "127.0.0.1" || host === "::1" || host === "localhost" || host === "[::1]";
9132
9410
  }
9133
9411
 
9412
+ // src/core/history-search.ts
9413
+ function parseQuery(raw) {
9414
+ const trimmed = raw.trim();
9415
+ if (trimmed.length === 0) {
9416
+ return { operator: "OR", terms: [] };
9417
+ }
9418
+ const tokenRe = /\w+:"[^"]*"|"[^"]*"|\S+/g;
9419
+ const tokens = [];
9420
+ let m;
9421
+ while ((m = tokenRe.exec(trimmed)) !== null) {
9422
+ tokens.push(m[0]);
9423
+ }
9424
+ let operator = "OR";
9425
+ let sawAnd = false;
9426
+ let sawOr = false;
9427
+ const termTokens = [];
9428
+ for (const tok of tokens) {
9429
+ const upper = tok.toUpperCase();
9430
+ if (upper === "AND") {
9431
+ sawAnd = true;
9432
+ } else if (upper === "OR") {
9433
+ sawOr = true;
9434
+ } else {
9435
+ termTokens.push(tok);
9436
+ }
9437
+ }
9438
+ if (sawAnd) {
9439
+ operator = "AND";
9440
+ } else if (sawOr) {
9441
+ operator = "OR";
9442
+ }
9443
+ const terms = termTokens.map((tok) => parseTermToken(tok)).filter((t) => t.term.length > 0);
9444
+ return { operator, terms };
9445
+ }
9446
+ function parseTermToken(tok) {
9447
+ const pq = /^(\w+):"([^"]*)"$/.exec(tok);
9448
+ if (pq) {
9449
+ return { scope: prefixToScope(pq[1]), term: pq[2] };
9450
+ }
9451
+ const q = /^"([^"]*)"$/.exec(tok);
9452
+ if (q) {
9453
+ return { scope: "all", term: q[1] };
9454
+ }
9455
+ const pb = /^(prompt|response|tool):([\s\S]*)$/i.exec(tok);
9456
+ if (pb) {
9457
+ return { scope: prefixToScope(pb[1]), term: pb[2].trim() };
9458
+ }
9459
+ return { scope: "all", term: tok.trim() };
9460
+ }
9461
+ function prefixToScope(prefix) {
9462
+ switch (prefix.toLowerCase()) {
9463
+ case "prompt":
9464
+ return "user";
9465
+ case "response":
9466
+ return "agent";
9467
+ case "tool":
9468
+ return "tool";
9469
+ default:
9470
+ return "all";
9471
+ }
9472
+ }
9473
+ function scopeMatchesKind(scope, kind) {
9474
+ if (scope === "all") {
9475
+ return true;
9476
+ }
9477
+ if (scope === "user") {
9478
+ return kind === "user";
9479
+ }
9480
+ if (scope === "agent") {
9481
+ return kind === "agent" || kind === "thought";
9482
+ }
9483
+ return kind === "tool" || kind === "tool-input";
9484
+ }
9485
+ var DEFAULT_MAX_SNIPPETS_PER_SESSION = 5;
9486
+ var DEFAULT_MAX_SESSIONS = 200;
9487
+ var SNIPPET_SIDE = 30;
9488
+ async function searchHistories(manager, query, opts = {}) {
9489
+ const parsed = parseQuery(query);
9490
+ if (parsed.terms.length === 0) {
9491
+ return { query, truncated: false, results: [] };
9492
+ }
9493
+ const maxPerSession = opts.maxSnippetsPerSession ?? DEFAULT_MAX_SNIPPETS_PER_SESSION;
9494
+ const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
9495
+ const allow = opts.sessionIds ? new Set(opts.sessionIds) : null;
9496
+ const all = await manager.list();
9497
+ const candidates = allow ? all.filter((s) => allow.has(s.sessionId)) : all;
9498
+ const results = [];
9499
+ let truncated = false;
9500
+ for (const candidate of candidates) {
9501
+ if (results.length >= maxSessions) {
9502
+ truncated = true;
9503
+ break;
9504
+ }
9505
+ const entries = await manager.loadHistory(candidate.sessionId).catch(
9506
+ () => []
9507
+ );
9508
+ const found = scanSessionEntries(entries, parsed, maxPerSession);
9509
+ if (found.snippets.length === 0) {
9510
+ continue;
9511
+ }
9512
+ const hit = {
9513
+ sessionId: candidate.sessionId,
9514
+ cwd: candidate.cwd,
9515
+ status: candidate.status,
9516
+ updatedAt: candidate.updatedAt,
9517
+ totalMatches: found.totalMatches,
9518
+ snippets: found.snippets
9519
+ };
9520
+ if (candidate.title !== void 0) {
9521
+ hit.title = candidate.title;
9522
+ }
9523
+ results.push(hit);
9524
+ }
9525
+ return { query, truncated, results };
9526
+ }
9527
+ function scanSessionEntries(entries, query, maxSnippets) {
9528
+ if (query.terms.length === 0) {
9529
+ return { totalMatches: 0, snippets: [] };
9530
+ }
9531
+ let totalMatches = 0;
9532
+ const snippets = [];
9533
+ for (const { scope, term } of query.terms) {
9534
+ const result = scanForTerm(entries, term, scope, maxSnippets - snippets.length);
9535
+ if (query.operator === "AND" && result.totalMatches === 0) {
9536
+ return { totalMatches: 0, snippets: [] };
9537
+ }
9538
+ totalMatches += result.totalMatches;
9539
+ snippets.push(...result.snippets);
9540
+ }
9541
+ return { totalMatches, snippets };
9542
+ }
9543
+ function scanForTerm(entries, term, scope, snippetBudget) {
9544
+ const needle = term.toLowerCase();
9545
+ let totalMatches = 0;
9546
+ const snippets = [];
9547
+ for (const entry of entries) {
9548
+ const fragments = extractSearchableFragments(entry).filter(
9549
+ (f) => scopeMatchesKind(scope, f.kind)
9550
+ );
9551
+ for (const frag of fragments) {
9552
+ const hay = frag.text.toLowerCase();
9553
+ let idx = hay.indexOf(needle);
9554
+ if (idx === -1) {
9555
+ continue;
9556
+ }
9557
+ let occurrences = 0;
9558
+ while (idx !== -1) {
9559
+ occurrences++;
9560
+ idx = hay.indexOf(needle, idx + needle.length);
9561
+ }
9562
+ totalMatches += occurrences;
9563
+ if (snippets.length < snippetBudget) {
9564
+ const first = hay.indexOf(needle);
9565
+ const snippet = {
9566
+ kind: frag.kind,
9567
+ text: buildSnippet(frag.text, first, needle.length),
9568
+ recordedAt: entry.recordedAt
9569
+ };
9570
+ if (frag.toolName !== void 0) {
9571
+ snippet.toolName = frag.toolName;
9572
+ }
9573
+ snippets.push(snippet);
9574
+ }
9575
+ }
9576
+ }
9577
+ return { totalMatches, snippets };
9578
+ }
9579
+ function extractSearchableFragments(entry) {
9580
+ if (entry.method !== "session/update") {
9581
+ return [];
9582
+ }
9583
+ const params = entry.params;
9584
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
9585
+ return [];
9586
+ }
9587
+ const update = params.update;
9588
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
9589
+ return [];
9590
+ }
9591
+ const u = update;
9592
+ const tag = typeof u.sessionUpdate === "string" ? u.sessionUpdate : u.kind;
9593
+ if (typeof tag !== "string") {
9594
+ return [];
9595
+ }
9596
+ switch (tag) {
9597
+ case "agent_message_chunk": {
9598
+ const text = readContentText(u.content);
9599
+ return text ? [{ kind: "agent", text }] : [];
9600
+ }
9601
+ case "agent_thought":
9602
+ case "agent_thought_chunk": {
9603
+ const text = typeof u.text === "string" ? sanitizeWireText(u.text) : readContentText(u.content);
9604
+ return text ? [{ kind: "thought", text }] : [];
9605
+ }
9606
+ case "user_message_chunk": {
9607
+ if (isCompatPromptReceived(u)) {
9608
+ return [];
9609
+ }
9610
+ const text = readContentText(u.content);
9611
+ return text ? [{ kind: "user", text }] : [];
9612
+ }
9613
+ case "prompt_received": {
9614
+ const text = readPromptText(u.prompt);
9615
+ return text ? [{ kind: "user", text }] : [];
9616
+ }
9617
+ case "tool_call":
9618
+ case "tool_call_update": {
9619
+ return extractToolFragments(u);
9620
+ }
9621
+ default:
9622
+ return [];
9623
+ }
9624
+ }
9625
+ function extractToolFragments(u) {
9626
+ const toolName = readString2(u, "name");
9627
+ const title = readString2(u, "title");
9628
+ const out = [];
9629
+ if (title !== void 0) {
9630
+ const sanitized = sanitizeSingleLine(title);
9631
+ if (sanitized.length > 0) {
9632
+ const frag = { kind: "tool", text: sanitized };
9633
+ if (toolName !== void 0) {
9634
+ frag.toolName = toolName;
9635
+ }
9636
+ out.push(frag);
9637
+ }
9638
+ }
9639
+ if (toolName !== void 0 && toolName !== title) {
9640
+ const sanitized = sanitizeSingleLine(toolName);
9641
+ if (sanitized.length > 0) {
9642
+ out.push({ kind: "tool", toolName, text: sanitized });
9643
+ }
9644
+ }
9645
+ const rawInput = u.rawInput;
9646
+ if (rawInput && typeof rawInput === "object") {
9647
+ const serialized = safeStringify(rawInput);
9648
+ if (serialized.length > 0) {
9649
+ const frag = {
9650
+ kind: "tool-input",
9651
+ text: sanitizeSingleLine(serialized)
9652
+ };
9653
+ if (toolName !== void 0) {
9654
+ frag.toolName = toolName;
9655
+ }
9656
+ out.push(frag);
9657
+ }
9658
+ }
9659
+ const locations = u.locations;
9660
+ if (Array.isArray(locations) && locations.length > 0) {
9661
+ const serialized = safeStringify(locations);
9662
+ if (serialized.length > 0) {
9663
+ const frag = {
9664
+ kind: "tool-input",
9665
+ text: sanitizeSingleLine(serialized)
9666
+ };
9667
+ if (toolName !== void 0) {
9668
+ frag.toolName = toolName;
9669
+ }
9670
+ out.push(frag);
9671
+ }
9672
+ }
9673
+ const errorText = extractToolErrorText(u);
9674
+ if (errorText !== null) {
9675
+ const frag = { kind: "tool", text: errorText };
9676
+ if (toolName !== void 0) {
9677
+ frag.toolName = toolName;
9678
+ }
9679
+ out.push(frag);
9680
+ }
9681
+ return out;
9682
+ }
9683
+ function extractToolErrorText(u) {
9684
+ const content = u.content;
9685
+ if (Array.isArray(content)) {
9686
+ for (const block of content) {
9687
+ if (!block || typeof block !== "object") {
9688
+ continue;
9689
+ }
9690
+ const b = block;
9691
+ const inner = b.content;
9692
+ if (!inner || typeof inner !== "object") {
9693
+ continue;
9694
+ }
9695
+ const i = inner;
9696
+ if (i.type === "text" && typeof i.text === "string") {
9697
+ const s = sanitizeSingleLine(i.text);
9698
+ if (s.length > 0) {
9699
+ return s;
9700
+ }
9701
+ }
9702
+ }
9703
+ }
9704
+ const rawOutput = u.rawOutput;
9705
+ if (rawOutput && typeof rawOutput === "object") {
9706
+ const err = rawOutput.error;
9707
+ if (typeof err === "string") {
9708
+ const s = sanitizeSingleLine(err);
9709
+ if (s.length > 0) {
9710
+ return s;
9711
+ }
9712
+ }
9713
+ }
9714
+ return null;
9715
+ }
9716
+ function isCompatPromptReceived(u) {
9717
+ const meta = u._meta;
9718
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
9719
+ return false;
9720
+ }
9721
+ const hydra = meta["hydra-acp"];
9722
+ if (!hydra || typeof hydra !== "object" || Array.isArray(hydra)) {
9723
+ return false;
9724
+ }
9725
+ return hydra.compatFor === "prompt_received";
9726
+ }
9727
+ function readContentText(content) {
9728
+ if (typeof content === "string") {
9729
+ return sanitizeWireText(content);
9730
+ }
9731
+ if (!content || typeof content !== "object" || Array.isArray(content)) {
9732
+ return "";
9733
+ }
9734
+ const c = content;
9735
+ if (typeof c.text === "string") {
9736
+ return sanitizeWireText(c.text);
9737
+ }
9738
+ return "";
9739
+ }
9740
+ function readPromptText(prompt) {
9741
+ if (!Array.isArray(prompt)) {
9742
+ return "";
9743
+ }
9744
+ const parts = [];
9745
+ for (const block of prompt) {
9746
+ const text = readContentText(block);
9747
+ if (text.length > 0) {
9748
+ parts.push(text);
9749
+ }
9750
+ }
9751
+ return parts.join("");
9752
+ }
9753
+ function readString2(u, key) {
9754
+ const v = u[key];
9755
+ return typeof v === "string" ? v : void 0;
9756
+ }
9757
+ function safeStringify(value) {
9758
+ try {
9759
+ return JSON.stringify(value);
9760
+ } catch {
9761
+ return "";
9762
+ }
9763
+ }
9764
+ function buildSnippet(text, matchIdx, matchLen) {
9765
+ const flat = text.replace(/\s+/g, " ").trim();
9766
+ if (flat.length === 0) {
9767
+ return "";
9768
+ }
9769
+ const flatLower = flat.toLowerCase();
9770
+ const needleSlice = text.slice(matchIdx, matchIdx + matchLen).toLowerCase().replace(/\s+/g, " ").trim();
9771
+ let pos = needleSlice.length > 0 ? flatLower.indexOf(needleSlice) : 0;
9772
+ if (pos === -1) {
9773
+ pos = 0;
9774
+ }
9775
+ const start = Math.max(0, pos - SNIPPET_SIDE);
9776
+ const end = Math.min(flat.length, pos + needleSlice.length + SNIPPET_SIDE);
9777
+ const head = start > 0 ? "\u2026" : "";
9778
+ const tail = end < flat.length ? "\u2026" : "";
9779
+ return `${head}${flat.slice(start, end)}${tail}`;
9780
+ }
9781
+
9134
9782
  // src/daemon/routes/sessions.ts
9135
9783
  function resolveHydraHost(defaults) {
9136
9784
  if (defaults.publicHost && defaults.publicHost.length > 0) {
@@ -9147,6 +9795,17 @@ function registerSessionRoutes(app, manager, defaults) {
9147
9795
  const sessions = await manager.list({ cwd: query?.cwd });
9148
9796
  return { sessions };
9149
9797
  });
9798
+ app.get("/v1/sessions/search", async (request, reply) => {
9799
+ const query = request.query;
9800
+ const q = query?.q ?? "";
9801
+ if (q.trim().length === 0) {
9802
+ reply.code(400).send({ error: "q is required" });
9803
+ return reply;
9804
+ }
9805
+ const ids = query?.sessionIds ? query.sessionIds.split(",").filter((s) => s.length > 0) : void 0;
9806
+ const out = await searchHistories(manager, q, { sessionIds: ids });
9807
+ return out;
9808
+ });
9150
9809
  app.post("/v1/sessions", async (request, reply) => {
9151
9810
  const body = request.body ?? {};
9152
9811
  const cwd = expandHome(body.cwd ?? defaults.cwd);
@@ -9978,6 +10637,34 @@ function registerAcpWsEndpoint(app, deps) {
9978
10637
  }
9979
10638
  return buildInitializeResult();
9980
10639
  });
10640
+ if (processIdentity && deps.extensionCommands) {
10641
+ const registry = deps.extensionCommands;
10642
+ connection.onRequest("hydra-acp/register_commands", async (raw) => {
10643
+ const params = raw ?? {};
10644
+ const commands = Array.isArray(params.commands) ? params.commands.map((c) => {
10645
+ if (!c || typeof c !== "object") {
10646
+ return void 0;
10647
+ }
10648
+ const obj = c;
10649
+ if (typeof obj.verb !== "string" || obj.verb.length === 0) {
10650
+ return void 0;
10651
+ }
10652
+ const spec = { verb: obj.verb };
10653
+ if (typeof obj.argsHint === "string") {
10654
+ spec.argsHint = obj.argsHint;
10655
+ }
10656
+ if (typeof obj.description === "string") {
10657
+ spec.description = obj.description;
10658
+ }
10659
+ return spec;
10660
+ }).filter((s) => s !== void 0) : [];
10661
+ registry.register(processIdentity.name, connection, commands);
10662
+ return { ok: true, registered: commands.length };
10663
+ });
10664
+ connection.onClose(() => {
10665
+ registry.clear(processIdentity.name);
10666
+ });
10667
+ }
9981
10668
  if (processIdentity?.kind === "transformer") {
9982
10669
  connection.onRequest("transformer/initialize", async (raw) => {
9983
10670
  const params = raw ?? {};
@@ -10525,7 +11212,10 @@ function registerAcpWsEndpoint(app, deps) {
10525
11212
  return null;
10526
11213
  }
10527
11214
  app.log.info(decision.logMessage);
10528
- return decision.session.forwardRequest("session/set_model", rawParams);
11215
+ const { modelId } = rawParams;
11216
+ const result = await decision.session.forwardRequest("session/set_model", rawParams);
11217
+ decision.session.applyModelChange(modelId);
11218
+ return result;
10529
11219
  });
10530
11220
  connection.onRequest("session/set_mode", async (rawParams) => {
10531
11221
  const params = rawParams;
@@ -10916,13 +11606,15 @@ async function startDaemon(config, serviceToken) {
10916
11606
  stderrTailBytes: config.daemon.agentStderrTailBytes,
10917
11607
  logger: agentLogger
10918
11608
  });
11609
+ const extensionCommands = new ExtensionCommandRegistry();
10919
11610
  const manager = new SessionManager(registry, spawner, void 0, {
10920
11611
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
10921
11612
  defaultModels: config.defaultModels,
10922
11613
  defaultTransformers: config.defaultTransformers,
10923
11614
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
10924
11615
  logger: agentLogger,
10925
- npmRegistry: config.npmRegistry
11616
+ npmRegistry: config.npmRegistry,
11617
+ extensionCommands
10926
11618
  });
10927
11619
  const extensions = new ExtensionManager(extensionList(config), void 0, {
10928
11620
  tokenRegistry: processRegistry
@@ -10956,7 +11648,8 @@ async function startDaemon(config, serviceToken) {
10956
11648
  processRegistry,
10957
11649
  onExtensionVersion: (name, version) => extensions.reportVersion(name, version),
10958
11650
  onTransformerVersion: (name, version) => transformers.reportVersion(name, version),
10959
- transformers
11651
+ transformers,
11652
+ extensionCommands
10960
11653
  });
10961
11654
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
10962
11655
  const address = app.server.address();