@hydra-acp/cli 0.1.45 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -173,7 +173,7 @@ var DaemonConfig = z.object({
173
173
  // Compaction trims to this many on a periodic basis; reads also slice
174
174
  // to the tail at this length as a defensive measure against older
175
175
  // daemons that may have written unbounded files.
176
- sessionHistoryMaxEntries: z.number().int().positive().default(1e3),
176
+ sessionHistoryMaxEntries: z.number().int().positive().default(1e4),
177
177
  // Bytes of trailing agent stderr buffered per AgentInstance so the
178
178
  // daemon can include it in the diagnostic message when a spawn fails.
179
179
  // Bump if your agents emit large tracebacks you want surfaced.
@@ -233,7 +233,13 @@ var TuiConfig = z.object({
233
233
  // streaming lines beneath the live thinking block. Set false to
234
234
  // suppress them — the TUI hotkey ^T toggles this at runtime without
235
235
  // persisting back to config.
236
- showThoughts: z.boolean().default(true)
236
+ showThoughts: z.boolean().default(true),
237
+ // Cap on entries kept in the cross-session global prompt-history file
238
+ // (~/.hydra-acp/prompt-history). This is the ^P / ^R recall list
239
+ // shared across all sessions; it's append-only on disk, so long-lived
240
+ // installs can grow past this — it's enforced at load time and per
241
+ // append in memory.
242
+ promptHistoryMaxEntries: z.number().int().positive().default(2e3)
237
243
  });
238
244
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
239
245
  var ExtensionBody = z.object({
@@ -287,7 +293,8 @@ var HydraConfig = z.object({
287
293
  cwdColumnMaxWidth: 24,
288
294
  progressIndicator: true,
289
295
  defaultEnterAction: "amend",
290
- showThoughts: true
296
+ showThoughts: true,
297
+ promptHistoryMaxEntries: 2e3
291
298
  })
292
299
  });
293
300
  function extensionList(config) {
@@ -2284,6 +2291,11 @@ var HYDRA_COMMANDS = [
2284
2291
  verb: "kill",
2285
2292
  name: "hydra kill",
2286
2293
  description: "Close this session (kills the agent; record is kept so it can be resumed later)"
2294
+ },
2295
+ {
2296
+ verb: "restart",
2297
+ name: "hydra restart",
2298
+ description: "Restart the agent with a fresh session/new while preserving conversation history (useful when the proxy has changed available models)"
2287
2299
  }
2288
2300
  ];
2289
2301
  var VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
@@ -2370,7 +2382,9 @@ var Session = class {
2370
2382
  // stale-prone for snapshot-shaped events).
2371
2383
  currentModel;
2372
2384
  currentMode;
2373
- currentUsage;
2385
+ // Raw per-agent-life usage. Never read directly outside this class —
2386
+ // always access via the currentUsage getter which adds cumulativeCost.
2387
+ _currentUsage;
2374
2388
  updatedAt;
2375
2389
  createdAt;
2376
2390
  clients = /* @__PURE__ */ new Map();
@@ -2436,6 +2450,7 @@ var Session = class {
2436
2450
  // and noisy state churn keep a quiet session alive forever.
2437
2451
  lastRecordedAt;
2438
2452
  spawnReplacementAgent;
2453
+ listSessions;
2439
2454
  logger;
2440
2455
  transformChain;
2441
2456
  // Outstanding "processing" claims: token → claim waiting for respondsTo discharge.
@@ -2467,6 +2482,23 @@ var Session = class {
2467
2482
  modeHandlers = [];
2468
2483
  usageHandlers = [];
2469
2484
  cumulativeCost = 0;
2485
+ // Total cost across all agent lives. costAmount in the returned snapshot
2486
+ // is cumulativeCost + the current agent's raw amount so every consumer
2487
+ // gets the right figure without knowing about the internal split.
2488
+ // cumulativeCost is stripped from the return value so it never leaks
2489
+ // into persistence paths via session.currentUsage.
2490
+ get currentUsage() {
2491
+ if (!this._currentUsage && !this.cumulativeCost) {
2492
+ return void 0;
2493
+ }
2494
+ const base = this._currentUsage ?? {};
2495
+ const total = this.cumulativeCost + (base.costAmount ?? 0);
2496
+ return {
2497
+ ...base,
2498
+ costAmount: total || void 0,
2499
+ cumulativeCost: void 0
2500
+ };
2501
+ }
2470
2502
  // Set by amendPrompt at the start of a cancel-and-resubmit dance.
2471
2503
  // broadcastTurnComplete reads it to attach the _meta.amended marker
2472
2504
  // to the cancelled turn's turn_complete notification, and to fire the
@@ -2499,7 +2531,7 @@ var Session = class {
2499
2531
  this.title = init.title;
2500
2532
  this.currentModel = init.currentModel;
2501
2533
  this.currentMode = init.currentMode;
2502
- this.currentUsage = init.currentUsage;
2534
+ this._currentUsage = init.currentUsage;
2503
2535
  this.cumulativeCost = init.currentUsage?.cumulativeCost ?? 0;
2504
2536
  if (init.agentCommands && init.agentCommands.length > 0) {
2505
2537
  this.agentAdvertisedCommands = [...init.agentCommands];
@@ -2513,6 +2545,7 @@ var Session = class {
2513
2545
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
2514
2546
  this.idleEventTimeoutMs = init.idleEventTimeoutMs ?? 3e4;
2515
2547
  this.spawnReplacementAgent = init.spawnReplacementAgent;
2548
+ this.listSessions = init.listSessions;
2516
2549
  this.logger = init.logger;
2517
2550
  this.transformChain = init.transformChain ?? [];
2518
2551
  if (init.firstPromptSeeded) {
@@ -2531,6 +2564,9 @@ var Session = class {
2531
2564
  broadcastMergedCommands() {
2532
2565
  const merged = [
2533
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" },
2534
2570
  ...this.agentAdvertisedCommands
2535
2571
  ];
2536
2572
  this.recordAndBroadcast("session/update", {
@@ -2595,7 +2631,8 @@ var Session = class {
2595
2631
  // then recordAndBroadcast. All state mutation happens after the chain exits.
2596
2632
  // See forwardRequest for originatedBy / startIdx semantics.
2597
2633
  async runResponseChain(params, originatedBy = /* @__PURE__ */ new Set(), startIdx = 0) {
2598
- let envelope = params;
2634
+ const rawParams = params;
2635
+ let envelope = this.injectCumulativeCost(params);
2599
2636
  for (let i = startIdx; i < this.transformChain.length; i++) {
2600
2637
  const t = this.transformChain[i];
2601
2638
  if (originatedBy.has(t.name)) {
@@ -2683,8 +2720,8 @@ var Session = class {
2683
2720
  this.recordAndBroadcast("session/update", envelope);
2684
2721
  return;
2685
2722
  }
2686
- if (this.maybeApplyAgentUsage(envelope)) {
2687
- this.recordAndBroadcast("session/update", this.injectCumulativeCost(envelope));
2723
+ if (this.maybeApplyAgentUsage(rawParams)) {
2724
+ this.recordAndBroadcast("session/update", envelope);
2688
2725
  return;
2689
2726
  }
2690
2727
  this.maybeApplyAgentSessionInfo(envelope);
@@ -2877,8 +2914,8 @@ var Session = class {
2877
2914
  recordedAt
2878
2915
  });
2879
2916
  }
2880
- if (this.currentUsage !== void 0 || this.cumulativeCost > 0) {
2881
- const u = this.currentUsage ?? {};
2917
+ if (this.currentUsage !== void 0) {
2918
+ const u = this.currentUsage;
2882
2919
  const update = {
2883
2920
  sessionUpdate: "usage_update"
2884
2921
  };
@@ -2890,8 +2927,8 @@ var Session = class {
2890
2927
  }
2891
2928
  if (typeof u.costAmount === "number" || typeof u.costCurrency === "string") {
2892
2929
  const cost = {};
2893
- if (typeof u.costAmount === "number" || this.cumulativeCost) {
2894
- cost.amount = this.cumulativeCost + (u.costAmount ?? 0);
2930
+ if (typeof u.costAmount === "number") {
2931
+ cost.amount = u.costAmount;
2895
2932
  }
2896
2933
  if (typeof u.costCurrency === "string") {
2897
2934
  cost.currency = u.costCurrency;
@@ -3430,6 +3467,23 @@ var Session = class {
3430
3467
  sessionId: this.upstreamSessionId
3431
3468
  });
3432
3469
  }
3470
+ // Add a transformer to this session's chain retroactively. No-ops if it's
3471
+ // already present. Fires session.opened on the new transformer so it gets
3472
+ // the same lifecycle signal it would have received at session creation.
3473
+ addTransformer(ref) {
3474
+ const existing = this.transformChain.findIndex((t) => t.name === ref.name);
3475
+ if (existing >= 0) {
3476
+ this.transformChain[existing] = ref;
3477
+ } else {
3478
+ this.transformChain.push(ref);
3479
+ }
3480
+ if (ref.intercepts.has("lifecycle:session.opened")) {
3481
+ void ref.connection.notify("transformer/session_event", {
3482
+ event: "session.opened",
3483
+ sessionId: this.sessionId
3484
+ }).catch(() => void 0);
3485
+ }
3486
+ }
3433
3487
  // Walk the request-side chain then forward to the agent.
3434
3488
  // originatedBy: transformer names already in the lineage — skipped for loop
3435
3489
  // prevention and to implement resume-routing on re-entry from emit_message.
@@ -3754,6 +3808,9 @@ var Session = class {
3754
3808
  if (!trimmed || trimmed === this.currentMode) {
3755
3809
  return true;
3756
3810
  }
3811
+ this.logger?.info(
3812
+ `current_mode_update: sessionId=${this.sessionId} ${JSON.stringify(this.currentMode)} \u2192 ${JSON.stringify(trimmed)}`
3813
+ );
3757
3814
  this.currentMode = trimmed;
3758
3815
  for (const handler of this.modeHandlers) {
3759
3816
  try {
@@ -3773,7 +3830,7 @@ var Session = class {
3773
3830
  if (update.sessionUpdate !== "usage_update") {
3774
3831
  return false;
3775
3832
  }
3776
- const next = { ...this.currentUsage ?? {} };
3833
+ const next = { ...this._currentUsage ?? {} };
3777
3834
  let changed = false;
3778
3835
  if (typeof update.used === "number" && next.used !== update.used) {
3779
3836
  next.used = update.used;
@@ -3797,10 +3854,11 @@ var Session = class {
3797
3854
  if (!changed) {
3798
3855
  return true;
3799
3856
  }
3800
- this.currentUsage = next;
3857
+ this._currentUsage = next;
3858
+ const total = this.currentUsage ?? next;
3801
3859
  for (const handler of this.usageHandlers) {
3802
3860
  try {
3803
- handler(next);
3861
+ handler(total);
3804
3862
  } catch {
3805
3863
  }
3806
3864
  }
@@ -3810,16 +3868,16 @@ var Session = class {
3810
3868
  // next agent life starts accumulating from $0. Fires usageHandlers so
3811
3869
  // meta.json is updated before the new agent starts emitting.
3812
3870
  accumulateAndResetCost() {
3813
- const amount = this.currentUsage?.costAmount;
3871
+ const amount = this._currentUsage?.costAmount;
3814
3872
  if (!amount)
3815
3873
  return;
3816
3874
  this.cumulativeCost += amount;
3817
3875
  const next = {
3818
- ...this.currentUsage ?? {},
3876
+ ...this._currentUsage ?? {},
3819
3877
  cumulativeCost: this.cumulativeCost,
3820
3878
  costAmount: void 0
3821
3879
  };
3822
- this.currentUsage = next;
3880
+ this._currentUsage = next;
3823
3881
  for (const handler of this.usageHandlers) {
3824
3882
  try {
3825
3883
  handler(next);
@@ -3854,16 +3912,9 @@ var Session = class {
3854
3912
  }
3855
3913
  };
3856
3914
  }
3857
- // Total cost across all agent lives for this hydra session. Used by
3858
- // session/list so list rows show the accumulated figure.
3915
+ // Deprecated alias currentUsage already returns the total.
3859
3916
  get totalUsage() {
3860
- if (!this.currentUsage && !this.cumulativeCost)
3861
- return void 0;
3862
- const base = this.currentUsage ?? {};
3863
- return {
3864
- ...base,
3865
- costAmount: this.cumulativeCost + (this.currentUsage?.costAmount ?? 0)
3866
- };
3917
+ return this.currentUsage;
3867
3918
  }
3868
3919
  // Update the cached agent command list, fire persist handlers, and
3869
3920
  // broadcast the merged list to attached clients. Idempotent on a
@@ -3929,6 +3980,26 @@ var Session = class {
3929
3980
  onModeChange(handler) {
3930
3981
  this.modeHandlers.push(handler);
3931
3982
  }
3983
+ // Apply a mode change initiated by a client request (session/set_mode)
3984
+ // when the agent doesn't emit a current_mode_update notification on its
3985
+ // own. Fires modeHandlers so the persistence hook and any other listeners
3986
+ // see the change, identical to the agent-notification path.
3987
+ applyModeChange(modeId) {
3988
+ const trimmed = modeId.trim();
3989
+ if (!trimmed || trimmed === this.currentMode) {
3990
+ return;
3991
+ }
3992
+ this.logger?.info(
3993
+ `applyModeChange: sessionId=${this.sessionId} ${JSON.stringify(this.currentMode)} \u2192 ${JSON.stringify(trimmed)}`
3994
+ );
3995
+ this.currentMode = trimmed;
3996
+ for (const handler of this.modeHandlers) {
3997
+ try {
3998
+ handler(trimmed);
3999
+ } catch {
4000
+ }
4001
+ }
4002
+ }
3932
4003
  onUsageChange(handler) {
3933
4004
  this.usageHandlers.push(handler);
3934
4005
  }
@@ -4011,6 +4082,8 @@ var Session = class {
4011
4082
  return this.runAgentCommand(arg);
4012
4083
  case "kill":
4013
4084
  return this.runKillCommand();
4085
+ case "restart":
4086
+ return this.runRestartCommand();
4014
4087
  default: {
4015
4088
  const err = new Error(
4016
4089
  `no dispatcher for /hydra verb ${verb}`
@@ -4020,6 +4093,92 @@ var Session = class {
4020
4093
  }
4021
4094
  }
4022
4095
  }
4096
+ async handleSessionsCommand() {
4097
+ let text;
4098
+ if (!this.listSessions) {
4099
+ text = "_(session listing not available)_";
4100
+ } else {
4101
+ const sessions = await this.listSessions();
4102
+ if (sessions.length === 0) {
4103
+ text = "_(no sessions)_";
4104
+ } else {
4105
+ const lines = sessions.map((s) => {
4106
+ const id = s.sessionId.replace(/^hydra_session_/, "").slice(0, 12);
4107
+ const model = s.currentModel ? ` \xB7 ${s.currentModel}` : "";
4108
+ const marker = s.sessionId === this.sessionId ? " \u25C0" : "";
4109
+ const title = s.title ? ` ${s.title}` : "";
4110
+ return `\`${id}\` ${s.cwd}${model}${marker}${title}`;
4111
+ });
4112
+ text = `Sessions (${sessions.length}):
4113
+ ${lines.join("\n")}`;
4114
+ }
4115
+ }
4116
+ this.recordAndBroadcast("session/update", {
4117
+ sessionId: this.upstreamSessionId,
4118
+ update: {
4119
+ sessionUpdate: "agent_message_chunk",
4120
+ content: { type: "text", text: `
4121
+ ${text}
4122
+ ` },
4123
+ _meta: { "hydra-acp": { synthetic: true } }
4124
+ }
4125
+ });
4126
+ return { stopReason: "end_turn" };
4127
+ }
4128
+ handleHelpCommand() {
4129
+ const commands = this.mergedAvailableCommands();
4130
+ const lines = commands.map((c) => {
4131
+ const desc = c.description ? ` ${c.description}` : "";
4132
+ return `\`/${c.name}\`${desc}`;
4133
+ });
4134
+ const text = lines.length > 0 ? `Available commands:
4135
+ ${lines.join("\n")}` : "_(no commands advertised)_";
4136
+ this.recordAndBroadcast("session/update", {
4137
+ sessionId: this.upstreamSessionId,
4138
+ update: {
4139
+ sessionUpdate: "agent_message_chunk",
4140
+ content: { type: "text", text: `
4141
+ ${text}
4142
+ ` },
4143
+ _meta: { "hydra-acp": { synthetic: true } }
4144
+ }
4145
+ });
4146
+ return Promise.resolve({ stopReason: "end_turn" });
4147
+ }
4148
+ async handleModelCommand(text) {
4149
+ const arg = text.slice("/model".length).trim();
4150
+ if (arg === "") {
4151
+ const models = this.agentAdvertisedModels;
4152
+ const current = this.currentModel;
4153
+ let body;
4154
+ if (models.length === 0) {
4155
+ body = current ? `Current model: ${current}` : "_(no models advertised yet)_";
4156
+ } else {
4157
+ const lines = models.map((m) => {
4158
+ const marker = m.modelId === current ? " \u25C0" : "";
4159
+ const desc = m.name && m.name !== m.modelId ? ` ${m.name}` : "";
4160
+ return `${m.modelId}${marker}${desc}`;
4161
+ });
4162
+ body = lines.join("\n");
4163
+ }
4164
+ this.recordAndBroadcast("session/update", {
4165
+ sessionId: this.upstreamSessionId,
4166
+ update: {
4167
+ sessionUpdate: "agent_message_chunk",
4168
+ content: { type: "text", text: `
4169
+ ${body}
4170
+ ` },
4171
+ _meta: { "hydra-acp": { synthetic: true } }
4172
+ }
4173
+ });
4174
+ return { stopReason: "end_turn" };
4175
+ }
4176
+ await this.forwardRequest("session/set_model", {
4177
+ sessionId: this.sessionId,
4178
+ modelId: arg
4179
+ });
4180
+ return { stopReason: "end_turn" };
4181
+ }
4023
4182
  // Runs as a normal queued prompt (so it serializes after any in-flight
4024
4183
  // turn). With an arg, sets the title directly. Without one, runs a
4025
4184
  // suppressed sub-prompt to the agent and uses its reply as the title.
@@ -4141,6 +4300,57 @@ var Session = class {
4141
4300
  await this.close({ deleteRecord: false });
4142
4301
  return { stopReason: "end_turn" };
4143
4302
  }
4303
+ // Restart the underlying agent with a fresh session/new, preserving the
4304
+ // conversation history. Unlike /hydra agent, this allows the same agentId
4305
+ // — useful when the proxy has changed available models (e.g. opus became
4306
+ // available after a quota reset) and the resumed session is locked to a
4307
+ // stale model list.
4308
+ runRestartCommand() {
4309
+ if (!this.spawnReplacementAgent) {
4310
+ throw withCode(
4311
+ new Error("agent restart not configured for this session"),
4312
+ JsonRpcErrorCodes.InternalError
4313
+ );
4314
+ }
4315
+ const spawnAgent = this.spawnReplacementAgent;
4316
+ const agentId = this.agentId;
4317
+ return this.enqueuePrompt(async () => {
4318
+ const transcript = await this.buildSwitchTranscript(agentId);
4319
+ const fresh = await spawnAgent({
4320
+ agentId,
4321
+ cwd: this.cwd,
4322
+ agentArgs: this.agentArgs
4323
+ });
4324
+ this.accumulateAndResetCost();
4325
+ this.wireAgent(fresh.agent);
4326
+ const oldAgent = this.agent;
4327
+ this.agent = fresh.agent;
4328
+ this.upstreamSessionId = fresh.upstreamSessionId;
4329
+ this.agentMeta = fresh.agentMeta;
4330
+ this.agentCapabilities = fresh.agentCapabilities;
4331
+ this.agentAdvertisedCommands = [];
4332
+ this.broadcastMergedCommands();
4333
+ if (this.agentAdvertisedModels.length > 0) {
4334
+ this.setAgentAdvertisedModels([]);
4335
+ }
4336
+ if (this.agentAdvertisedModes.length > 0) {
4337
+ this.setAgentAdvertisedModes([]);
4338
+ }
4339
+ await oldAgent.kill().catch(() => void 0);
4340
+ if (transcript) {
4341
+ await this.runInternalPrompt(transcript).catch(() => void 0);
4342
+ }
4343
+ this.broadcastAgentSwitch(agentId, agentId);
4344
+ const info = { agentId, upstreamSessionId: this.upstreamSessionId };
4345
+ for (const handler of this.agentChangeHandlers) {
4346
+ try {
4347
+ handler(info);
4348
+ } catch {
4349
+ }
4350
+ }
4351
+ return { stopReason: "end_turn" };
4352
+ });
4353
+ }
4144
4354
  // Walk the persisted history and produce a labeled transcript suitable
4145
4355
  // for handing to a fresh agent. Includes user prompts, agent replies,
4146
4356
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
@@ -4757,6 +4967,22 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
4757
4967
  return entry.task();
4758
4968
  }
4759
4969
  this.broadcastPromptReceived(entry);
4970
+ const promptText = extractPromptText(entry.prompt).trim();
4971
+ if (promptText === "/model" || promptText.startsWith("/model ") || promptText === "/sessions" || promptText === "/help") {
4972
+ let result;
4973
+ if (promptText === "/sessions") {
4974
+ result = await this.handleSessionsCommand();
4975
+ } else if (promptText === "/help") {
4976
+ result = await this.handleHelpCommand();
4977
+ } else {
4978
+ result = await this.handleModelCommand(promptText);
4979
+ }
4980
+ if (!this.closed) {
4981
+ this.broadcastTurnComplete(entry.clientId, result, entry.messageId, entry.wasAmend);
4982
+ }
4983
+ this.clearAmendIfMatches(entry.messageId);
4984
+ return result;
4985
+ }
4760
4986
  let response;
4761
4987
  try {
4762
4988
  response = await this.agent.connection.request(
@@ -5548,6 +5774,7 @@ var SessionManager = class {
5548
5774
  idleEventTimeoutMs: this.idleEventTimeoutMs,
5549
5775
  logger: this.logger,
5550
5776
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
5777
+ listSessions: () => this.list(),
5551
5778
  historyStore: this.histories,
5552
5779
  historyMaxEntries: this.sessionHistoryMaxEntries,
5553
5780
  currentModel: fresh.initialModel,
@@ -5649,6 +5876,22 @@ var SessionManager = class {
5649
5876
  } else {
5650
5877
  agent.connection.drainBuffered("session/update");
5651
5878
  }
5879
+ const agentReportedMode = extractInitialCurrentMode(loadResult ?? {});
5880
+ const advertisedModes = params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {}));
5881
+ this.logger?.info(
5882
+ `resurrect: sessionId=${params.hydraSessionId} persistedMode=${JSON.stringify(params.currentMode)} agentReportedMode=${JSON.stringify(agentReportedMode)} advertisedModes=${JSON.stringify(advertisedModes?.map((m) => m.id))}`
5883
+ );
5884
+ const effectiveMode = await restoreCurrentMode({
5885
+ agent,
5886
+ upstreamSessionId: params.upstreamSessionId,
5887
+ persistedMode: params.currentMode,
5888
+ agentReportedMode,
5889
+ advertisedModes,
5890
+ logger: this.logger
5891
+ });
5892
+ this.logger?.info(
5893
+ `resurrect: effectiveMode=${JSON.stringify(effectiveMode)} for sessionId=${params.hydraSessionId}`
5894
+ );
5652
5895
  const session = new Session({
5653
5896
  sessionId: params.hydraSessionId,
5654
5897
  cwd: params.cwd,
@@ -5662,6 +5905,7 @@ var SessionManager = class {
5662
5905
  idleTimeoutMs: this.idleTimeoutMs,
5663
5906
  logger: this.logger,
5664
5907
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
5908
+ listSessions: () => this.list(),
5665
5909
  historyStore: this.histories,
5666
5910
  historyMaxEntries: this.sessionHistoryMaxEntries,
5667
5911
  // Prefer what we previously stored from a current_model_update; if
@@ -5669,11 +5913,15 @@ var SessionManager = class {
5669
5913
  // this fix), fall back to the model the agent ships in its
5670
5914
  // session/load response body.
5671
5915
  currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
5672
- currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
5916
+ currentMode: effectiveMode,
5673
5917
  currentUsage: params.currentUsage,
5674
5918
  agentCommands: params.agentCommands,
5675
- agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
5676
- agentModels: params.agentModels ?? nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})),
5919
+ agentModes: advertisedModes,
5920
+ // Always prefer the fresh list from session/load over the persisted
5921
+ // snapshot — the proxy's available models can change between daemon
5922
+ // restarts (quota resets, rollouts), so meta.json is intentionally
5923
+ // treated as a cold fallback here, not the authoritative source.
5924
+ agentModels: nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})) ?? params.agentModels,
5677
5925
  // Only gate the first-prompt title heuristic when we actually have
5678
5926
  // a title to preserve. A title-less session (lost to a write race
5679
5927
  // or never seeded) should re-derive from the next prompt rather
@@ -5700,6 +5948,15 @@ var SessionManager = class {
5700
5948
  mcpServers: [],
5701
5949
  onInstallProgress: params.onInstallProgress
5702
5950
  });
5951
+ const advertisedModes = params.agentModes ?? fresh.initialModes;
5952
+ const effectiveMode = await restoreCurrentMode({
5953
+ agent: fresh.agent,
5954
+ upstreamSessionId: fresh.upstreamSessionId,
5955
+ persistedMode: params.currentMode,
5956
+ agentReportedMode: fresh.initialMode,
5957
+ advertisedModes,
5958
+ logger: this.logger
5959
+ });
5703
5960
  const session = new Session({
5704
5961
  sessionId: params.hydraSessionId,
5705
5962
  cwd,
@@ -5713,15 +5970,16 @@ var SessionManager = class {
5713
5970
  idleTimeoutMs: this.idleTimeoutMs,
5714
5971
  logger: this.logger,
5715
5972
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
5973
+ listSessions: () => this.list(),
5716
5974
  historyStore: this.histories,
5717
5975
  historyMaxEntries: this.sessionHistoryMaxEntries,
5718
5976
  // Prefer the stored value (set by a previous current_model_update);
5719
5977
  // fall back to whatever the agent ships in its session/new response.
5720
5978
  currentModel: params.currentModel ?? fresh.initialModel,
5721
- currentMode: params.currentMode ?? fresh.initialMode,
5979
+ currentMode: effectiveMode,
5722
5980
  currentUsage: params.currentUsage,
5723
5981
  agentCommands: params.agentCommands,
5724
- agentModes: params.agentModes ?? fresh.initialModes,
5982
+ agentModes: advertisedModes,
5725
5983
  agentModels: params.agentModels ?? fresh.initialModels,
5726
5984
  firstPromptSeeded: !!params.title,
5727
5985
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
@@ -5923,13 +6181,15 @@ var SessionManager = class {
5923
6181
  modelId: desired
5924
6182
  });
5925
6183
  initialModel = desired;
5926
- } catch {
6184
+ } catch (err) {
6185
+ this.logger?.warn(
6186
+ `defaultModels[${params.agentId}]=${JSON.stringify(desired)} rejected by agent (${err.message}); session will use ${JSON.stringify(initialModel)}`
6187
+ );
5927
6188
  }
5928
6189
  } else {
5929
6190
  const known = initialModels.map((m) => m.modelId).join(", ");
5930
- process.stderr.write(
5931
- `hydra-acp: defaultModels[${params.agentId}]=${JSON.stringify(desired)} is not in the agent's availableModels (${known}); skipping session/set_model
5932
- `
6191
+ this.logger?.warn(
6192
+ `defaultModels[${params.agentId}]=${JSON.stringify(desired)} not in agent's availableModels ([${known}]); skipping session/set_model, session will use ${JSON.stringify(initialModel)}`
5933
6193
  );
5934
6194
  }
5935
6195
  }
@@ -6106,6 +6366,9 @@ var SessionManager = class {
6106
6366
  get(sessionId) {
6107
6367
  return this.sessions.get(sessionId);
6108
6368
  }
6369
+ liveSessions() {
6370
+ return this.sessions.values();
6371
+ }
6109
6372
  // Snapshot of which agent versions are currently in use by live
6110
6373
  // sessions, keyed by agentId. Read by the registry-fetch prune sweep
6111
6374
  // so it can skip install dirs that still back a running process.
@@ -6167,7 +6430,7 @@ var SessionManager = class {
6167
6430
  title: session.title,
6168
6431
  agentId: session.agentId,
6169
6432
  currentModel: session.currentModel,
6170
- currentUsage: session.totalUsage,
6433
+ currentUsage: session.currentUsage,
6171
6434
  parentSessionId: session.parentSessionId,
6172
6435
  updatedAt: used,
6173
6436
  attachedClients: session.attachedCount,
@@ -6319,6 +6582,7 @@ var SessionManager = class {
6319
6582
  currentMode: args.bundle.session.currentMode,
6320
6583
  currentUsage: args.bundle.session.currentUsage,
6321
6584
  agentCommands: args.bundle.session.agentCommands,
6585
+ agentModes: args.bundle.session.agentModes,
6322
6586
  createdAt: args.preservedCreatedAt ?? now,
6323
6587
  // Fallback path for historyMtimeIso (used when the history file
6324
6588
  // is missing). Keep this consistent with the utimes stamp above.
@@ -6708,6 +6972,47 @@ function extractInitialCurrentMode(result) {
6708
6972
  }
6709
6973
  return void 0;
6710
6974
  }
6975
+ async function restoreCurrentMode(opts) {
6976
+ const {
6977
+ agent,
6978
+ upstreamSessionId,
6979
+ persistedMode,
6980
+ agentReportedMode,
6981
+ advertisedModes,
6982
+ logger
6983
+ } = opts;
6984
+ if (!persistedMode) {
6985
+ return agentReportedMode;
6986
+ }
6987
+ if (persistedMode === agentReportedMode) {
6988
+ return persistedMode;
6989
+ }
6990
+ if (advertisedModes && advertisedModes.length > 0 && !advertisedModes.some((m) => m.id === persistedMode)) {
6991
+ const known = advertisedModes.map((m) => m.id).join(", ");
6992
+ logger?.warn(
6993
+ `resurrect: persisted currentMode=${JSON.stringify(persistedMode)} not in agent's availableModes ([${known}]); skipping session/set_mode, session will use ${JSON.stringify(agentReportedMode)}`
6994
+ );
6995
+ return agentReportedMode;
6996
+ }
6997
+ try {
6998
+ logger?.info(
6999
+ `resurrect: pushing persisted modeId=${JSON.stringify(persistedMode)} to agent (agentReported=${JSON.stringify(agentReportedMode)})`
7000
+ );
7001
+ await agent.connection.request("session/set_mode", {
7002
+ sessionId: upstreamSessionId,
7003
+ modeId: persistedMode
7004
+ });
7005
+ logger?.info(
7006
+ `resurrect: session/set_mode accepted, effectiveMode=${JSON.stringify(persistedMode)}`
7007
+ );
7008
+ return persistedMode;
7009
+ } catch (err) {
7010
+ logger?.warn(
7011
+ `resurrect: session/set_mode rejected by agent for modeId=${JSON.stringify(persistedMode)} (${err.message}); session will use ${JSON.stringify(agentReportedMode)}`
7012
+ );
7013
+ return agentReportedMode;
7014
+ }
7015
+ }
6711
7016
  function parseModesList(list) {
6712
7017
  if (!Array.isArray(list)) {
6713
7018
  return [];
@@ -8513,7 +8818,15 @@ function mapModel(u) {
8513
8818
  if (!model) {
8514
8819
  return null;
8515
8820
  }
8516
- return { kind: "model-changed", model: sanitizeSingleLine(model) };
8821
+ const raw = u.availableModels;
8822
+ const availableModels = Array.isArray(raw) ? raw.map(
8823
+ (m) => typeof m === "object" && m !== null ? m.modelId : typeof m === "string" ? m : void 0
8824
+ ).filter((id) => typeof id === "string" && id.length > 0) : void 0;
8825
+ return {
8826
+ kind: "model-changed",
8827
+ model: sanitizeSingleLine(model),
8828
+ ...availableModels && availableModels.length > 0 ? { availableModels } : {}
8829
+ };
8517
8830
  }
8518
8831
  function mapTurnComplete(u) {
8519
8832
  const stopReason = readString(u, "stopReason");
@@ -9677,6 +9990,14 @@ function registerAcpWsEndpoint(app, deps) {
9677
9990
  connection,
9678
9991
  intercepts
9679
9992
  );
9993
+ if (deps.manager?.defaultTransformers.includes(processIdentity.name)) {
9994
+ const ref = deps.transformers.resolveChain([processIdentity.name])[0];
9995
+ if (ref) {
9996
+ for (const session of deps.manager.liveSessions()) {
9997
+ session.addTransformer(ref);
9998
+ }
9999
+ }
10000
+ }
9680
10001
  }
9681
10002
  return { ack: true };
9682
10003
  });
@@ -9892,16 +10213,13 @@ function registerAcpWsEndpoint(app, deps) {
9892
10213
  let resurrectParams = fromDisk;
9893
10214
  if (hydraHints) {
9894
10215
  resurrectParams = {
10216
+ ...fromDisk,
9895
10217
  hydraSessionId: params.sessionId,
9896
10218
  upstreamSessionId: hydraHints.upstreamSessionId,
9897
10219
  agentId: hydraHints.agentId,
9898
10220
  cwd: hydraHints.cwd,
9899
- title: hydraHints.title ?? fromDisk?.title,
9900
- agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
9901
- currentModel: fromDisk?.currentModel,
9902
- currentMode: fromDisk?.currentMode,
9903
- agentCommands: fromDisk?.agentCommands,
9904
- createdAt: fromDisk?.createdAt
10221
+ ...hydraHints.title !== void 0 ? { title: hydraHints.title } : {},
10222
+ ...hydraHints.agentArgs !== void 0 ? { agentArgs: hydraHints.agentArgs } : {}
9905
10223
  };
9906
10224
  }
9907
10225
  if (!resurrectParams) {
@@ -9915,6 +10233,7 @@ function registerAcpWsEndpoint(app, deps) {
9915
10233
  ...resurrectParams,
9916
10234
  onInstallProgress: makeInstallProgressForwarder(connection)
9917
10235
  });
10236
+ wireDefaultTransformers(session, deps);
9918
10237
  }
9919
10238
  const client = bindClientToSession(
9920
10239
  connection,
@@ -10004,6 +10323,7 @@ function registerAcpWsEndpoint(app, deps) {
10004
10323
  `session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
10005
10324
  );
10006
10325
  session = await deps.manager.resurrect(fromDisk);
10326
+ wireDefaultTransformers(session, deps);
10007
10327
  const client = bindClientToSession(
10008
10328
  connection,
10009
10329
  session,
@@ -10156,6 +10476,7 @@ function registerAcpWsEndpoint(app, deps) {
10156
10476
  throw err;
10157
10477
  }
10158
10478
  session = await deps.manager.resurrect(fromDisk);
10479
+ wireDefaultTransformers(session, deps);
10159
10480
  }
10160
10481
  const client = bindClientToSession(connection, session, state);
10161
10482
  const { entries: replay } = await session.attach(client, "pending_only");
@@ -10206,6 +10527,32 @@ function registerAcpWsEndpoint(app, deps) {
10206
10527
  app.log.info(decision.logMessage);
10207
10528
  return decision.session.forwardRequest("session/set_model", rawParams);
10208
10529
  });
10530
+ connection.onRequest("session/set_mode", async (rawParams) => {
10531
+ const params = rawParams;
10532
+ const sessionIdField = params?.sessionId;
10533
+ if (typeof sessionIdField === "string") {
10534
+ denyIfReadonly(sessionIdField, "session/set_mode");
10535
+ }
10536
+ if (!params || typeof params.sessionId !== "string") {
10537
+ const err = new Error("session/set_mode requires string sessionId");
10538
+ err.code = JsonRpcErrorCodes.InvalidParams;
10539
+ throw err;
10540
+ }
10541
+ if (typeof params.modeId !== "string") {
10542
+ const err = new Error("session/set_mode requires string modeId");
10543
+ err.code = JsonRpcErrorCodes.InvalidParams;
10544
+ throw err;
10545
+ }
10546
+ const session = deps.manager.get(params.sessionId);
10547
+ if (!session) {
10548
+ const err = new Error(`session ${params.sessionId} not found`);
10549
+ err.code = JsonRpcErrorCodes.SessionNotFound;
10550
+ throw err;
10551
+ }
10552
+ const result = await session.forwardRequest("session/set_mode", rawParams);
10553
+ session.applyModeChange(params.modeId);
10554
+ return result;
10555
+ });
10209
10556
  connection.setDefaultHandler(async (rawParams, method) => {
10210
10557
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
10211
10558
  const err = new Error(`Method not found: ${method}`);
@@ -10478,6 +10825,17 @@ function buildInitializeResult() {
10478
10825
  })
10479
10826
  };
10480
10827
  }
10828
+ function wireDefaultTransformers(session, deps) {
10829
+ if (!deps.transformers || !deps.manager) {
10830
+ return;
10831
+ }
10832
+ for (const name of deps.manager.defaultTransformers) {
10833
+ const ref = deps.transformers.resolveChain([name])[0];
10834
+ if (ref) {
10835
+ session.addTransformer(ref);
10836
+ }
10837
+ }
10838
+ }
10481
10839
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
10482
10840
  void state;
10483
10841
  void session;