@hydra-acp/cli 0.1.24 → 0.1.26

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
@@ -186,7 +186,15 @@ var TuiConfig = z.object({
186
186
  // on Windows Terminal, dock badge on KDE/Konsole, etc.) while a turn is
187
187
  // running. Set false if your terminal renders this obnoxiously or you
188
188
  // just don't want it.
189
- progressIndicator: z.boolean().default(true)
189
+ progressIndicator: z.boolean().default(true),
190
+ // What the unmodified Enter key does in the prompt composer.
191
+ // "enqueue" (default) — Enter enqueues the prompt (sends immediately
192
+ // when idle, queues behind an in-flight turn); Shift+Enter amends
193
+ // the in-flight turn.
194
+ // "amend" — flips the two: Enter amends the in-flight turn,
195
+ // Shift+Enter enqueues. With no turn in flight either key just
196
+ // enqueues, since there's nothing to amend.
197
+ defaultEnterAction: z.enum(["enqueue", "amend"]).default("enqueue")
190
198
  });
191
199
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
192
200
  var ExtensionBody = z.object({
@@ -230,7 +238,8 @@ var HydraConfig = z.object({
230
238
  mouse: true,
231
239
  logMaxBytes: 5 * 1024 * 1024,
232
240
  cwdColumnMaxWidth: 24,
233
- progressIndicator: true
241
+ progressIndicator: true,
242
+ defaultEnterAction: "enqueue"
234
243
  })
235
244
  });
236
245
  function extensionList(config) {
@@ -371,8 +380,10 @@ async function ensureBinary(args) {
371
380
  }
372
381
  await downloadAndExtract({
373
382
  agentId: args.agentId,
383
+ version: args.version,
374
384
  archiveUrl: args.target.archive,
375
- installDir
385
+ installDir,
386
+ onProgress: args.onProgress
376
387
  });
377
388
  if (!await fileExists(cmdPath)) {
378
389
  throw new Error(
@@ -392,9 +403,16 @@ async function downloadAndExtract(args) {
392
403
  const archivePath = await downloadTo({
393
404
  url: args.archiveUrl,
394
405
  dir: tempDir,
395
- agentId: args.agentId
406
+ agentId: args.agentId,
407
+ version: args.version,
408
+ onProgress: args.onProgress
396
409
  });
397
410
  logSink(`hydra-acp: extracting ${args.agentId}`);
411
+ safeEmit(args.onProgress, {
412
+ phase: "extract",
413
+ agentId: args.agentId,
414
+ version: args.version
415
+ });
398
416
  await extract(archivePath, tempDir);
399
417
  await fsp.unlink(archivePath).catch(() => void 0);
400
418
  try {
@@ -405,16 +423,35 @@ async function downloadAndExtract(args) {
405
423
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(
406
424
  () => void 0
407
425
  );
426
+ safeEmit(args.onProgress, {
427
+ phase: "installed",
428
+ agentId: args.agentId,
429
+ version: args.version
430
+ });
408
431
  return;
409
432
  }
410
433
  throw err;
411
434
  }
412
435
  logSink(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
436
+ safeEmit(args.onProgress, {
437
+ phase: "installed",
438
+ agentId: args.agentId,
439
+ version: args.version
440
+ });
413
441
  } catch (err) {
414
442
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
415
443
  throw err;
416
444
  }
417
445
  }
446
+ function safeEmit(cb, event) {
447
+ if (!cb) {
448
+ return;
449
+ }
450
+ try {
451
+ cb(event);
452
+ } catch {
453
+ }
454
+ }
418
455
  async function downloadTo(args) {
419
456
  const filename = inferArchiveName(args.url);
420
457
  const dest = path2.join(args.dir, filename);
@@ -427,17 +464,34 @@ async function downloadTo(args) {
427
464
  const total = Number(response.headers.get("content-length") ?? "0");
428
465
  const out = fs3.createWriteStream(dest);
429
466
  const nodeStream = Readable.fromWeb(response.body);
467
+ safeEmit(args.onProgress, {
468
+ phase: "download_start",
469
+ agentId: args.agentId,
470
+ version: args.version,
471
+ totalBytes: total
472
+ });
430
473
  let received = 0;
431
- let lastEmit = Date.now();
432
- const EMIT_INTERVAL_MS = 2e3;
474
+ let lastLogEmit = Date.now();
475
+ let lastCbEmit = 0;
476
+ const LOG_INTERVAL_MS = 2e3;
477
+ const CB_INTERVAL_MS = 150;
433
478
  nodeStream.on("data", (chunk) => {
434
479
  received += chunk.length;
435
480
  const now = Date.now();
436
- if (now - lastEmit < EMIT_INTERVAL_MS) {
437
- return;
481
+ if (now - lastCbEmit >= CB_INTERVAL_MS) {
482
+ lastCbEmit = now;
483
+ safeEmit(args.onProgress, {
484
+ phase: "download_progress",
485
+ agentId: args.agentId,
486
+ version: args.version,
487
+ receivedBytes: received,
488
+ totalBytes: total
489
+ });
490
+ }
491
+ if (now - lastLogEmit >= LOG_INTERVAL_MS) {
492
+ lastLogEmit = now;
493
+ logSink(formatProgress(args.agentId, received, total));
438
494
  }
439
- lastEmit = now;
440
- logSink(formatProgress(args.agentId, received, total));
441
495
  });
442
496
  await new Promise((resolve3, reject) => {
443
497
  nodeStream.on("error", reject);
@@ -452,6 +506,13 @@ async function downloadTo(args) {
452
506
  /* done */
453
507
  true
454
508
  ));
509
+ safeEmit(args.onProgress, {
510
+ phase: "download_done",
511
+ agentId: args.agentId,
512
+ version: args.version,
513
+ receivedBytes: received,
514
+ totalBytes: total
515
+ });
455
516
  return dest;
456
517
  }
457
518
  function formatProgress(agentId, received, total, done = false) {
@@ -550,9 +611,11 @@ async function ensureNpmPackage(args) {
550
611
  }
551
612
  await installInto({
552
613
  agentId: args.agentId,
614
+ version: args.version,
553
615
  packageSpec: args.packageSpec,
554
616
  installDir,
555
- registry: args.registry
617
+ registry: args.registry,
618
+ onProgress: args.onProgress
556
619
  });
557
620
  if (!await fileExists2(binPath)) {
558
621
  throw new Error(
@@ -568,6 +631,12 @@ async function installInto(args) {
568
631
  logSink2(
569
632
  `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
570
633
  );
634
+ safeEmit2(args.onProgress, {
635
+ phase: "install_start",
636
+ agentId: args.agentId,
637
+ version: args.version,
638
+ packageSpec: args.packageSpec
639
+ });
571
640
  await runNpmInstall({
572
641
  packageSpec: args.packageSpec,
573
642
  cwd: tempDir,
@@ -581,11 +650,21 @@ async function installInto(args) {
581
650
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
582
651
  () => void 0
583
652
  );
653
+ safeEmit2(args.onProgress, {
654
+ phase: "installed",
655
+ agentId: args.agentId,
656
+ version: args.version
657
+ });
584
658
  return;
585
659
  }
586
660
  throw err;
587
661
  }
588
662
  logSink2(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
663
+ safeEmit2(args.onProgress, {
664
+ phase: "installed",
665
+ agentId: args.agentId,
666
+ version: args.version
667
+ });
589
668
  } catch (err) {
590
669
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
591
670
  () => void 0
@@ -593,44 +672,87 @@ async function installInto(args) {
593
672
  throw err;
594
673
  }
595
674
  }
675
+ function safeEmit2(cb, event) {
676
+ if (!cb) {
677
+ return;
678
+ }
679
+ try {
680
+ cb(event);
681
+ } catch {
682
+ }
683
+ }
684
+ var ETXTBSY_RETRIES = 5;
685
+ var ETXTBSY_BACKOFF_MS = 25;
596
686
  function runNpmInstall(args) {
597
- return new Promise((resolve3, reject) => {
598
- const registryArgs = args.registry ? ["--registry", args.registry] : [];
599
- const child = spawn2(
600
- "npm",
601
- ["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
602
- {
603
- cwd: args.cwd,
604
- stdio: ["ignore", "pipe", "pipe"]
605
- }
606
- );
607
- let stderrTail = "";
608
- child.stdout?.on("data", (chunk) => {
609
- void chunk;
610
- });
611
- child.stderr?.setEncoding("utf8");
612
- child.stderr?.on("data", (chunk) => {
613
- stderrTail = (stderrTail + chunk).slice(-4096);
614
- });
615
- child.on("error", (err) => {
616
- const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
617
- reject(new Error(msg));
618
- });
619
- child.on("exit", (code, signal) => {
620
- if (code === 0) {
621
- resolve3();
687
+ return runNpmInstallOnce(args, 0);
688
+ }
689
+ async function runNpmInstallOnce(args, attempt) {
690
+ try {
691
+ await new Promise((resolve3, reject) => {
692
+ const registryArgs = args.registry ? ["--registry", args.registry] : [];
693
+ let child;
694
+ try {
695
+ child = spawn2(
696
+ "npm",
697
+ [
698
+ "install",
699
+ "--no-audit",
700
+ "--no-fund",
701
+ "--silent",
702
+ ...registryArgs,
703
+ args.packageSpec
704
+ ],
705
+ { cwd: args.cwd, stdio: ["ignore", "pipe", "pipe"] }
706
+ );
707
+ } catch (err) {
708
+ reject(err);
622
709
  return;
623
710
  }
624
- const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
625
- const tail = stderrTail.trim();
626
- reject(
627
- new Error(
628
- tail ? `npm install ${args.packageSpec} failed (${reason})
711
+ let stderrTail = "";
712
+ child.stdout?.on("data", (chunk) => {
713
+ void chunk;
714
+ });
715
+ child.stderr?.setEncoding("utf8");
716
+ child.stderr?.on("data", (chunk) => {
717
+ stderrTail = (stderrTail + chunk).slice(-4096);
718
+ });
719
+ child.on("error", (err) => {
720
+ const e = err;
721
+ if (e.code === "ENOENT") {
722
+ reject(
723
+ new Error(
724
+ `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)`
725
+ )
726
+ );
727
+ return;
728
+ }
729
+ reject(err);
730
+ });
731
+ child.on("exit", (code, signal) => {
732
+ if (code === 0) {
733
+ resolve3();
734
+ return;
735
+ }
736
+ const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
737
+ const tail = stderrTail.trim();
738
+ reject(
739
+ new Error(
740
+ tail ? `npm install ${args.packageSpec} failed (${reason})
629
741
  stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
630
- )
631
- );
742
+ )
743
+ );
744
+ });
632
745
  });
633
- });
746
+ } catch (err) {
747
+ const code = err.code;
748
+ if (code === "ETXTBSY" && attempt < ETXTBSY_RETRIES) {
749
+ await new Promise(
750
+ (r) => setTimeout(r, ETXTBSY_BACKOFF_MS * (attempt + 1))
751
+ );
752
+ return runNpmInstallOnce(args, attempt + 1);
753
+ }
754
+ throw err;
755
+ }
634
756
  }
635
757
  async function fileExists2(p) {
636
758
  try {
@@ -828,12 +950,14 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
828
950
  };
829
951
  }
830
952
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
953
+ const npmCb = options.onInstallProgress;
831
954
  const binPath = await ensureNpmPackage({
832
955
  agentId: agent.id,
833
956
  version,
834
957
  packageSpec: npx.package,
835
958
  bin,
836
- registry: options.npmRegistry
959
+ registry: options.npmRegistry,
960
+ onProgress: npmCb ? (e) => npmCb({ source: "npm", ...e }) : void 0
837
961
  });
838
962
  return {
839
963
  command: binPath,
@@ -849,10 +973,12 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
849
973
  `Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
850
974
  );
851
975
  }
976
+ const binCb = options.onInstallProgress;
852
977
  const cmdPath = await ensureBinary({
853
978
  agentId: agent.id,
854
979
  version,
855
- target
980
+ target,
981
+ onProgress: binCb ? (e) => binCb({ source: "binary", ...e }) : void 0
856
982
  });
857
983
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
858
984
  return {
@@ -1024,6 +1150,18 @@ function extractHydraMeta(meta) {
1024
1150
  if (typeof obj.promptQueueing === "boolean") {
1025
1151
  out.promptQueueing = obj.promptQueueing;
1026
1152
  }
1153
+ if (typeof obj.promptCancelling === "boolean") {
1154
+ out.promptCancelling = obj.promptCancelling;
1155
+ }
1156
+ if (typeof obj.promptUpdating === "boolean") {
1157
+ out.promptUpdating = obj.promptUpdating;
1158
+ }
1159
+ if (typeof obj.promptAmending === "boolean") {
1160
+ out.promptAmending = obj.promptAmending;
1161
+ }
1162
+ if (typeof obj.promptPipelining === "boolean") {
1163
+ out.promptPipelining = obj.promptPipelining;
1164
+ }
1027
1165
  if (Array.isArray(obj.queue)) {
1028
1166
  const entries = [];
1029
1167
  for (const raw of obj.queue) {
@@ -1073,6 +1211,29 @@ function extractHydraMeta(meta) {
1073
1211
  out.availableModes = modes;
1074
1212
  }
1075
1213
  }
1214
+ if (Array.isArray(obj.availableModels)) {
1215
+ const models = [];
1216
+ for (const raw of obj.availableModels) {
1217
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1218
+ continue;
1219
+ }
1220
+ const m = raw;
1221
+ if (typeof m.modelId !== "string") {
1222
+ continue;
1223
+ }
1224
+ const model = { modelId: m.modelId };
1225
+ if (typeof m.name === "string") {
1226
+ model.name = m.name;
1227
+ }
1228
+ if (typeof m.description === "string") {
1229
+ model.description = m.description;
1230
+ }
1231
+ models.push(model);
1232
+ }
1233
+ if (models.length > 0) {
1234
+ out.availableModels = models;
1235
+ }
1236
+ }
1076
1237
  return out;
1077
1238
  }
1078
1239
  function mergeMeta(passthrough, ours) {
@@ -1168,6 +1329,51 @@ var UpdatePromptResult = z3.object({
1168
1329
  updated: z3.boolean(),
1169
1330
  reason: z3.enum(["ok", "not_found", "already_running"])
1170
1331
  });
1332
+ var AmendPromptParams = z3.object({
1333
+ sessionId: z3.string(),
1334
+ targetMessageId: z3.string(),
1335
+ prompt: z3.array(z3.unknown()),
1336
+ replaceQueue: z3.boolean().optional(),
1337
+ onTargetCompleted: z3.enum(["reject", "send_anyway"]).optional()
1338
+ });
1339
+ var AmendPromptResult = z3.object({
1340
+ amended: z3.boolean(),
1341
+ reason: z3.enum([
1342
+ "ok",
1343
+ "target_completed",
1344
+ "target_cancelled",
1345
+ "target_not_found"
1346
+ ]),
1347
+ // Present when a prompt was sent or replaced: the amendment's id on
1348
+ // success, or the regular follow-up's id when onTargetCompleted is
1349
+ // "send_anyway" and the daemon forwarded the prompt anyway.
1350
+ messageId: z3.string().optional()
1351
+ });
1352
+ var PromptAmendedParams = z3.object({
1353
+ sessionId: z3.string(),
1354
+ cancelledMessageId: z3.string(),
1355
+ newMessageId: z3.string(),
1356
+ prompt: z3.array(z3.unknown()),
1357
+ originator: PromptOriginatorSchema,
1358
+ amendedAt: z3.number()
1359
+ });
1360
+ var AgentInstallProgressParams = z3.object({
1361
+ agentId: z3.string(),
1362
+ version: z3.string(),
1363
+ source: z3.enum(["binary", "npm"]),
1364
+ phase: z3.enum([
1365
+ "download_start",
1366
+ "download_progress",
1367
+ "download_done",
1368
+ "extract",
1369
+ "install_start",
1370
+ "installed"
1371
+ ]),
1372
+ receivedBytes: z3.number().optional(),
1373
+ totalBytes: z3.number().optional(),
1374
+ packageSpec: z3.string().optional()
1375
+ });
1376
+ var AGENT_INSTALL_PROGRESS_METHOD = "hydra-acp/agent_install_progress";
1171
1377
  var ProxyInitializeParams = z3.object({
1172
1378
  protocolVersion: z3.number().optional(),
1173
1379
  proxyInfo: z3.object({
@@ -1646,6 +1852,7 @@ function stripHydraSessionPrefix(id) {
1646
1852
  return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
1647
1853
  }
1648
1854
  var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
1855
+ var RECENTLY_TERMINAL_LIMIT = 64;
1649
1856
  var Session = class {
1650
1857
  sessionId;
1651
1858
  cwd;
@@ -1737,14 +1944,36 @@ var Session = class {
1737
1944
  // Last available_modes_update we observed from the agent. Same
1738
1945
  // pattern as commands: cache, persist, broadcast on change.
1739
1946
  agentAdvertisedModes = [];
1947
+ // Last availableModels payload we observed (from current_model_update,
1948
+ // a session/new / session/load response, or — for opencode — a
1949
+ // config_option_update where configOptions[i].id === "model").
1950
+ // Cached so a mid-session attach can synthesize a model picker
1951
+ // snapshot, and so session/set_model can validate the requested id
1952
+ // against what the agent claims to support.
1953
+ agentAdvertisedModels = [];
1740
1954
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1741
1955
  // to mirror changes into meta.json so cold-resurrect attaches can
1742
1956
  // surface the latest snapshot via the attach response _meta.
1743
1957
  agentCommandsHandlers = [];
1744
1958
  agentModesHandlers = [];
1959
+ agentModelsHandlers = [];
1745
1960
  modelHandlers = [];
1746
1961
  modeHandlers = [];
1747
1962
  usageHandlers = [];
1963
+ // Set by amendPrompt at the start of a cancel-and-resubmit dance.
1964
+ // broadcastTurnComplete reads it to attach the _meta.amended marker
1965
+ // to the cancelled turn's turn_complete notification, and to fire the
1966
+ // dedicated prompt_amended notification. Cleared when the cancelled
1967
+ // turn's task completes (runQueueEntry) OR if the amendment is
1968
+ // cancelled mid-window via cancel_prompt(M2) before drainQueue picks
1969
+ // it up.
1970
+ amendInProgress;
1971
+ // LRU of recently-terminal messageIds → stopReason. Used by
1972
+ // amendPrompt to resolve targets that completed/cancelled before
1973
+ // the amend arrived. Capped at RECENTLY_TERMINAL_LIMIT entries;
1974
+ // older entries fall out and resolve to target_not_found, which is
1975
+ // the correct behavior.
1976
+ recentlyTerminal = /* @__PURE__ */ new Map();
1748
1977
  constructor(init) {
1749
1978
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
1750
1979
  this.cwd = init.cwd;
@@ -1763,6 +1992,9 @@ var Session = class {
1763
1992
  if (init.agentModes && init.agentModes.length > 0) {
1764
1993
  this.agentAdvertisedModes = [...init.agentModes];
1765
1994
  }
1995
+ if (init.agentModels && init.agentModels.length > 0) {
1996
+ this.agentAdvertisedModels = [...init.agentModels];
1997
+ }
1766
1998
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1767
1999
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1768
2000
  this.logger = init.logger;
@@ -1800,6 +2032,23 @@ var Session = class {
1800
2032
  }
1801
2033
  });
1802
2034
  }
2035
+ // Re-broadcast our cached availableModels via current_model_update.
2036
+ // Spec shape: { currentModel, availableModels } — we only include the
2037
+ // currentModel field when we know it, so this broadcast can also fire
2038
+ // model-list updates standalone before any current model is set.
2039
+ broadcastAvailableModels() {
2040
+ const update = {
2041
+ sessionUpdate: "current_model_update",
2042
+ availableModels: [...this.agentAdvertisedModels]
2043
+ };
2044
+ if (this.currentModel !== void 0 && this.currentModel.length > 0) {
2045
+ update.currentModel = this.currentModel;
2046
+ }
2047
+ this.recordAndBroadcast("session/update", {
2048
+ sessionId: this.upstreamSessionId,
2049
+ update
2050
+ });
2051
+ }
1803
2052
  // Register session/update, session/request_permission, and onExit
1804
2053
  // handlers on an agent connection. Re-run on every /hydra agent so
1805
2054
  // the new agent is plumbed identically. The exit handler's identity
@@ -1830,6 +2079,10 @@ var Session = class {
1830
2079
  this.recordAndBroadcast("session/update", params);
1831
2080
  return;
1832
2081
  }
2082
+ if (this.maybeApplyAgentConfigOption(params)) {
2083
+ this.recordAndBroadcast("session/update", params);
2084
+ return;
2085
+ }
1833
2086
  if (this.maybeApplyAgentUsage(params)) {
1834
2087
  this.recordAndBroadcast("session/update", params);
1835
2088
  return;
@@ -1978,16 +2231,19 @@ var Session = class {
1978
2231
  recordedAt
1979
2232
  });
1980
2233
  }
1981
- if (this.currentModel !== void 0 && this.currentModel.length > 0) {
2234
+ if (this.currentModel !== void 0 && this.currentModel.length > 0 || this.agentAdvertisedModels.length > 0) {
2235
+ const update = {
2236
+ sessionUpdate: "current_model_update"
2237
+ };
2238
+ if (this.currentModel !== void 0 && this.currentModel.length > 0) {
2239
+ update.currentModel = this.currentModel;
2240
+ }
2241
+ if (this.agentAdvertisedModels.length > 0) {
2242
+ update.availableModels = [...this.agentAdvertisedModels];
2243
+ }
1982
2244
  out.push({
1983
2245
  method: "session/update",
1984
- params: {
1985
- sessionId,
1986
- update: {
1987
- sessionUpdate: "current_model_update",
1988
- currentModel: this.currentModel
1989
- }
1990
- },
2246
+ params: { sessionId, update },
1991
2247
  recordedAt
1992
2248
  });
1993
2249
  }
@@ -2174,7 +2430,7 @@ var Session = class {
2174
2430
  );
2175
2431
  }
2176
2432
  }
2177
- broadcastTurnComplete(originatorClientId, response) {
2433
+ broadcastTurnComplete(originatorClientId, response, promptMessageId, wasAmend) {
2178
2434
  const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
2179
2435
  const update = {
2180
2436
  sessionUpdate: "turn_complete",
@@ -2183,15 +2439,83 @@ var Session = class {
2183
2439
  if (stopReason !== void 0) {
2184
2440
  update.stopReason = stopReason;
2185
2441
  }
2442
+ const amend = this.amendInProgress;
2443
+ if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
2444
+ update._meta = {
2445
+ "hydra-acp": {
2446
+ amended: {
2447
+ cancelledMessageId: amend.cancelledMessageId,
2448
+ newMessageId: amend.newMessageId
2449
+ }
2450
+ }
2451
+ };
2452
+ }
2186
2453
  this.promptStartedAt = void 0;
2454
+ if (promptMessageId !== void 0 && stopReason !== void 0) {
2455
+ this.recordTerminal(promptMessageId, stopReason);
2456
+ }
2187
2457
  this.recordAndBroadcast(
2188
2458
  "session/update",
2189
2459
  {
2190
2460
  sessionId: this.sessionId,
2191
2461
  update
2192
2462
  },
2193
- originatorClientId
2463
+ wasAmend ? void 0 : originatorClientId
2194
2464
  );
2465
+ if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
2466
+ this.broadcastPromptAmended(amend);
2467
+ }
2468
+ }
2469
+ // Record that a prompt's turn has ended, with its terminal stopReason.
2470
+ // Used by amendPrompt to resolve targetMessageIds that completed/cancelled
2471
+ // before the amend arrived. LRU-trimmed at RECENTLY_TERMINAL_LIMIT.
2472
+ recordTerminal(messageId, stopReason) {
2473
+ this.recentlyTerminal.set(messageId, {
2474
+ stopReason,
2475
+ terminatedAt: Date.now()
2476
+ });
2477
+ while (this.recentlyTerminal.size > RECENTLY_TERMINAL_LIMIT) {
2478
+ const oldest = this.recentlyTerminal.keys().next().value;
2479
+ if (oldest === void 0) {
2480
+ break;
2481
+ }
2482
+ this.recentlyTerminal.delete(oldest);
2483
+ }
2484
+ }
2485
+ // Fire hydra-acp/prompt_amended for the M1→M2 linkage. The amendment's
2486
+ // current content is read live from the queue entry so any update_prompt
2487
+ // calls during the amend window are reflected. Best-effort: if M2 has
2488
+ // already been cancelled out of the queue by the time we get here, we
2489
+ // skip — the amendInProgress clearing in cancelQueuedPrompt should have
2490
+ // prevented this code path from running in that case.
2491
+ broadcastPromptAmended(amend) {
2492
+ const entry = this.findUserEntry(amend.newMessageId);
2493
+ if (!entry) {
2494
+ return;
2495
+ }
2496
+ const params = {
2497
+ sessionId: this.sessionId,
2498
+ cancelledMessageId: amend.cancelledMessageId,
2499
+ newMessageId: amend.newMessageId,
2500
+ prompt: entry.prompt,
2501
+ originator: entry.originator,
2502
+ amendedAt: Date.now()
2503
+ };
2504
+ this.broadcastQueueNotification(
2505
+ "hydra-acp/prompt_amended",
2506
+ params
2507
+ );
2508
+ }
2509
+ // Look up a user-prompt queue entry by messageId, searching both the
2510
+ // currentEntry slot and the waiting queue.
2511
+ findUserEntry(messageId) {
2512
+ if (this.currentEntry?.messageId === messageId && this.currentEntry.kind === "user") {
2513
+ return this.currentEntry;
2514
+ }
2515
+ const queued = this.promptQueue.find(
2516
+ (e) => e.messageId === messageId && e.kind === "user"
2517
+ );
2518
+ return queued?.kind === "user" ? queued : void 0;
2195
2519
  }
2196
2520
  // Total visible-or-running entries: the in-flight head (if any) plus
2197
2521
  // the queue's user-visible waiting entries. Internal entries don't
@@ -2204,9 +2528,9 @@ var Session = class {
2204
2528
  }
2205
2529
  return count;
2206
2530
  }
2207
- broadcastQueueAdded(entry) {
2531
+ broadcastQueueAdded(entry, options) {
2208
2532
  const depth = this.visibleQueueDepth();
2209
- const position = Math.max(0, depth - 1);
2533
+ const position = options?.position ?? Math.max(0, depth - 1);
2210
2534
  const params = {
2211
2535
  sessionId: this.sessionId,
2212
2536
  messageId: entry.messageId,
@@ -2216,6 +2540,11 @@ var Session = class {
2216
2540
  queueDepth: depth,
2217
2541
  enqueuedAt: entry.enqueuedAt
2218
2542
  };
2543
+ if (options?.amending !== void 0) {
2544
+ params._meta = {
2545
+ "hydra-acp": { amending: options.amending }
2546
+ };
2547
+ }
2219
2548
  this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
2220
2549
  }
2221
2550
  broadcastQueueUpdated(messageId, prompt) {
@@ -2338,6 +2667,9 @@ var Session = class {
2338
2667
  this.broadcastQueueRemoved(messageId, "cancelled");
2339
2668
  this.persistRewrite();
2340
2669
  }
2670
+ if (this.amendInProgress?.newMessageId === messageId) {
2671
+ this.amendInProgress = void 0;
2672
+ }
2341
2673
  entry.resolve({ stopReason: "cancelled" });
2342
2674
  return { cancelled: true, reason: "ok" };
2343
2675
  }
@@ -2359,6 +2691,143 @@ var Session = class {
2359
2691
  this.persistRewrite();
2360
2692
  return { updated: true, reason: "ok" };
2361
2693
  }
2694
+ // Amend the head prompt: cancel the in-flight turn and submit a
2695
+ // replacement that sits at the head of the queue. Resolves the
2696
+ // request immediately (the caller doesn't wait on cancel-settle).
2697
+ // Honours race outcomes — if the target finished or was cancelled
2698
+ // before this arrived, the request resolves with an outcome explaining
2699
+ // why and (depending on onTargetCompleted) optionally forwards as a
2700
+ // plain prompt. Queued targets are edited in place (same machinery
2701
+ // as updateQueuedPrompt).
2702
+ amendPrompt(clientId, params) {
2703
+ const client = this.clients.get(clientId);
2704
+ if (!client) {
2705
+ throw withCode(
2706
+ new Error("client not attached"),
2707
+ JsonRpcErrorCodes.SessionNotFound
2708
+ );
2709
+ }
2710
+ const { targetMessageId, prompt, replaceQueue, onTargetCompleted } = params;
2711
+ if (this.currentEntry?.messageId === targetMessageId && this.currentEntry.kind === "user" && !this.currentEntry.cancelled && this.amendInProgress === void 0) {
2712
+ return this.amendOnHead(client, prompt, targetMessageId, replaceQueue);
2713
+ }
2714
+ const queuedEntry = this.promptQueue.find(
2715
+ (e) => e.messageId === targetMessageId && e.kind === "user"
2716
+ );
2717
+ if (queuedEntry && queuedEntry.kind === "user" && !queuedEntry.cancelled) {
2718
+ queuedEntry.prompt = prompt;
2719
+ this.broadcastQueueUpdated(targetMessageId, prompt);
2720
+ this.persistRewrite();
2721
+ return { amended: true, reason: "ok", messageId: targetMessageId };
2722
+ }
2723
+ const terminal = this.recentlyTerminal.get(targetMessageId);
2724
+ if (terminal) {
2725
+ if (terminal.stopReason === "cancelled") {
2726
+ return { amended: false, reason: "target_cancelled" };
2727
+ }
2728
+ if (onTargetCompleted === "send_anyway") {
2729
+ const newMessageId = this.enqueueAmendmentAsFollowUp(client, prompt);
2730
+ return {
2731
+ amended: false,
2732
+ reason: "target_completed",
2733
+ messageId: newMessageId
2734
+ };
2735
+ }
2736
+ return { amended: false, reason: "target_completed" };
2737
+ }
2738
+ return { amended: false, reason: "target_not_found" };
2739
+ }
2740
+ // Head-of-queue amendment: splice M2 in front of any waiting entries,
2741
+ // broadcast the amend window's queue_added with the amending hint,
2742
+ // mark amendInProgress so the cancelled turn's broadcastTurnComplete
2743
+ // attaches the _meta marker and fires prompt_amended, then fire the
2744
+ // upstream session/cancel without awaiting it. drainQueue is already
2745
+ // running on the head; when its session/prompt returns, it advances
2746
+ // to M2 in the normal way.
2747
+ amendOnHead(client, prompt, targetMessageId, replaceQueue) {
2748
+ const newMessageId = generateMessageId();
2749
+ const originator = { clientId: client.clientId };
2750
+ if (client.clientInfo?.name) {
2751
+ originator.name = client.clientInfo.name;
2752
+ }
2753
+ if (client.clientInfo?.version) {
2754
+ originator.version = client.clientInfo.version;
2755
+ }
2756
+ if (replaceQueue) {
2757
+ const survivors = [];
2758
+ for (const entry2 of this.promptQueue) {
2759
+ if (entry2.kind === "user" && !entry2.cancelled) {
2760
+ entry2.cancelled = true;
2761
+ this.broadcastQueueRemoved(entry2.messageId, "cancelled");
2762
+ entry2.resolve({ stopReason: "cancelled" });
2763
+ continue;
2764
+ }
2765
+ survivors.push(entry2);
2766
+ }
2767
+ this.promptQueue = survivors;
2768
+ }
2769
+ const entry = {
2770
+ kind: "user",
2771
+ messageId: newMessageId,
2772
+ originator,
2773
+ clientId: client.clientId,
2774
+ prompt,
2775
+ enqueuedAt: Date.now(),
2776
+ cancelled: false,
2777
+ wasAmend: true,
2778
+ // No-op resolve/reject: there's no client request awaiting M2's
2779
+ // session/prompt response. The amend_prompt request has already
2780
+ // returned by this point. drainQueue calls these unconditionally
2781
+ // when runQueueEntry settles; making them no-ops is safe.
2782
+ resolve: () => void 0,
2783
+ reject: () => void 0
2784
+ };
2785
+ this.promptQueue.unshift(entry);
2786
+ this.persistRewrite();
2787
+ this.broadcastQueueAdded(entry, {
2788
+ amending: targetMessageId,
2789
+ position: 1
2790
+ });
2791
+ this.amendInProgress = {
2792
+ cancelledMessageId: targetMessageId,
2793
+ newMessageId
2794
+ };
2795
+ void this.agent.connection.notify("session/cancel", { sessionId: this.upstreamSessionId }).catch(() => void 0);
2796
+ return {
2797
+ amended: true,
2798
+ reason: "ok",
2799
+ messageId: newMessageId
2800
+ };
2801
+ }
2802
+ // Send the amendment as a plain follow-up prompt — used when the
2803
+ // target already completed and the caller opted in to send_anyway.
2804
+ // Returns the new prompt's messageId so the result can surface it.
2805
+ enqueueAmendmentAsFollowUp(client, prompt) {
2806
+ const messageId = generateMessageId();
2807
+ const originator = { clientId: client.clientId };
2808
+ if (client.clientInfo?.name) {
2809
+ originator.name = client.clientInfo.name;
2810
+ }
2811
+ if (client.clientInfo?.version) {
2812
+ originator.version = client.clientInfo.version;
2813
+ }
2814
+ const entry = {
2815
+ kind: "user",
2816
+ messageId,
2817
+ originator,
2818
+ clientId: client.clientId,
2819
+ prompt,
2820
+ enqueuedAt: Date.now(),
2821
+ cancelled: false,
2822
+ resolve: () => void 0,
2823
+ reject: () => void 0
2824
+ };
2825
+ this.promptQueue.push(entry);
2826
+ this.persistRewrite();
2827
+ this.broadcastQueueAdded(entry);
2828
+ void this.drainQueue();
2829
+ return messageId;
2830
+ }
2362
2831
  async cancel(clientId) {
2363
2832
  const client = this.clients.get(clientId);
2364
2833
  if (!client) {
@@ -2409,6 +2878,18 @@ var Session = class {
2409
2878
  onTitleChange(handler) {
2410
2879
  this.titleHandlers.push(handler);
2411
2880
  }
2881
+ // External entry point for retitling a live session from outside the
2882
+ // ACP slash-command path (e.g. PATCH /v1/sessions/:id from the picker).
2883
+ // Goes through the same enqueuePrompt path as /hydra title so it
2884
+ // serializes after any in-flight turn and shares broadcast/persistence.
2885
+ retitle(title) {
2886
+ return this.runTitleCommand(title);
2887
+ }
2888
+ // External entry point for the LLM-regen title path (T in the picker,
2889
+ // equivalent to bare /hydra title with no arg).
2890
+ retitleFromAgent() {
2891
+ return this.runTitleCommand("");
2892
+ }
2412
2893
  // Update the canonical title and broadcast a session_info_update to
2413
2894
  // every attached client. Clients that already speak the spec's
2414
2895
  // session_info_update need no hydra-specific wiring to pick this up.
@@ -2456,12 +2937,19 @@ var Session = class {
2456
2937
  // Apply an agent-emitted current_model_update. Returns true if the
2457
2938
  // notification was a model update (caller still needs to broadcast
2458
2939
  // it). Returns false otherwise so the caller can try the next kind.
2940
+ // current_model_update can carry availableModels in the same payload
2941
+ // (per ACP spec); we cache that list too so session/set_model can
2942
+ // validate against it.
2459
2943
  maybeApplyAgentModel(params) {
2460
2944
  const obj = params ?? {};
2461
2945
  const update = obj.update ?? {};
2462
2946
  if (update.sessionUpdate !== "current_model_update") {
2463
2947
  return false;
2464
2948
  }
2949
+ const models = parseModelsList(update.availableModels);
2950
+ if (models.length > 0) {
2951
+ this.setAgentAdvertisedModels(models);
2952
+ }
2465
2953
  const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
2466
2954
  if (raw === void 0) {
2467
2955
  return true;
@@ -2479,6 +2967,55 @@ var Session = class {
2479
2967
  }
2480
2968
  return true;
2481
2969
  }
2970
+ // Apply an opencode-style config_option_update. opencode emits this
2971
+ // (not the spec-shaped current_model_update / available_models_update)
2972
+ // to carry both the current model and the list of available models.
2973
+ // The payload is `configOptions: [{ id: "model", currentValue, options:
2974
+ // [{ value, name }] }, ...]`. We harvest only the entry whose id is
2975
+ // "model" — other ids ("mode", "effort", etc.) are opencode-internal
2976
+ // and not consumed by hydra. Returns true when we recognized and
2977
+ // handled the notification so the wireAgent loop can stop trying
2978
+ // further extractors (the broadcast still fires; clients that grok
2979
+ // config_option_update render it directly).
2980
+ maybeApplyAgentConfigOption(params) {
2981
+ const obj = params ?? {};
2982
+ const update = obj.update ?? {};
2983
+ if (update.sessionUpdate !== "config_option_update") {
2984
+ return false;
2985
+ }
2986
+ const list = update.configOptions;
2987
+ if (!Array.isArray(list)) {
2988
+ return true;
2989
+ }
2990
+ for (const raw of list) {
2991
+ if (!raw || typeof raw !== "object") {
2992
+ continue;
2993
+ }
2994
+ const opt = raw;
2995
+ if (opt.id !== "model") {
2996
+ continue;
2997
+ }
2998
+ const models = parseModelsList(opt.options);
2999
+ if (models.length > 0) {
3000
+ this.setAgentAdvertisedModels(models);
3001
+ }
3002
+ const cv = opt.currentValue;
3003
+ if (typeof cv === "string") {
3004
+ const trimmed = cv.trim();
3005
+ if (trimmed && trimmed !== this.currentModel) {
3006
+ this.currentModel = trimmed;
3007
+ for (const handler of this.modelHandlers) {
3008
+ try {
3009
+ handler(trimmed);
3010
+ } catch {
3011
+ }
3012
+ }
3013
+ }
3014
+ }
3015
+ break;
3016
+ }
3017
+ return true;
3018
+ }
2482
3019
  maybeApplyAgentMode(params) {
2483
3020
  const obj = params ?? {};
2484
3021
  const update = obj.update ?? {};
@@ -2577,6 +3114,20 @@ var Session = class {
2577
3114
  }
2578
3115
  this.broadcastAvailableModes();
2579
3116
  }
3117
+ setAgentAdvertisedModels(models) {
3118
+ if (sameAdvertisedModels(this.agentAdvertisedModels, models)) {
3119
+ this.broadcastAvailableModels();
3120
+ return;
3121
+ }
3122
+ this.agentAdvertisedModels = models;
3123
+ for (const handler of this.agentModelsHandlers) {
3124
+ try {
3125
+ handler(models);
3126
+ } catch {
3127
+ }
3128
+ }
3129
+ this.broadcastAvailableModels();
3130
+ }
2580
3131
  // Subscribe to snapshot-state updates. SessionManager wires these to
2581
3132
  // persist the new value into meta.json so cold resurrect can restore
2582
3133
  // them via the attach response _meta.
@@ -2586,6 +3137,9 @@ var Session = class {
2586
3137
  onAgentModesChange(handler) {
2587
3138
  this.agentModesHandlers.push(handler);
2588
3139
  }
3140
+ onAgentModelsChange(handler) {
3141
+ this.agentModelsHandlers.push(handler);
3142
+ }
2589
3143
  onModelChange(handler) {
2590
3144
  this.modelHandlers.push(handler);
2591
3145
  }
@@ -2611,6 +3165,15 @@ var Session = class {
2611
3165
  availableModes() {
2612
3166
  return [...this.agentAdvertisedModes];
2613
3167
  }
3168
+ // The agent's advertised models list. Used by acp-ws.ts's dedicated
3169
+ // session/set_model handler to validate the requested modelId before
3170
+ // forwarding to the agent (catches cross-agent set_model storms from
3171
+ // clients that assume a different agent is on the other end). When
3172
+ // the agent never advertised any models, returns [] and the
3173
+ // set_model handler falls back to pass-through.
3174
+ availableModels() {
3175
+ return [...this.agentAdvertisedModels];
3176
+ }
2614
3177
  // Pick up an agent-emitted session_info_update and store its title
2615
3178
  // as our canonical record. The notification is also forwarded to
2616
3179
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -2758,6 +3321,12 @@ var Session = class {
2758
3321
  this.agentMeta = fresh.agentMeta;
2759
3322
  this.agentAdvertisedCommands = [];
2760
3323
  this.broadcastMergedCommands();
3324
+ if (this.agentAdvertisedModels.length > 0) {
3325
+ this.setAgentAdvertisedModels([]);
3326
+ }
3327
+ if (this.agentAdvertisedModes.length > 0) {
3328
+ this.setAgentAdvertisedModes([]);
3329
+ }
2761
3330
  await oldAgent.kill().catch(() => void 0);
2762
3331
  if (transcript) {
2763
3332
  await this.runInternalPrompt(transcript).catch(() => void 0);
@@ -3217,6 +3786,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3217
3786
  try {
3218
3787
  const result = await this.runQueueEntry(next);
3219
3788
  next.resolve(result);
3789
+ await Promise.resolve();
3220
3790
  } catch (err) {
3221
3791
  next.reject(err);
3222
3792
  } finally {
@@ -3253,12 +3823,33 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3253
3823
  }
3254
3824
  );
3255
3825
  } catch (err) {
3256
- this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
3826
+ this.broadcastTurnComplete(
3827
+ entry.clientId,
3828
+ { stopReason: "error" },
3829
+ entry.messageId,
3830
+ entry.wasAmend
3831
+ );
3832
+ this.clearAmendIfMatches(entry.messageId);
3257
3833
  throw err;
3258
3834
  }
3259
- this.broadcastTurnComplete(entry.clientId, response);
3835
+ this.broadcastTurnComplete(
3836
+ entry.clientId,
3837
+ response,
3838
+ entry.messageId,
3839
+ entry.wasAmend
3840
+ );
3841
+ this.clearAmendIfMatches(entry.messageId);
3260
3842
  return response;
3261
3843
  }
3844
+ // Clear amendInProgress once the cancelled turn's task has fully
3845
+ // settled. broadcastTurnComplete needs the marker still set when it
3846
+ // fires, so the clear must happen *after*. Called from runQueueEntry's
3847
+ // settle path for both success and error.
3848
+ clearAmendIfMatches(messageId) {
3849
+ if (this.amendInProgress?.cancelledMessageId === messageId) {
3850
+ this.amendInProgress = void 0;
3851
+ }
3852
+ }
3262
3853
  };
3263
3854
  function withCode(err, code) {
3264
3855
  err.code = code;
@@ -3302,6 +3893,42 @@ function sameAdvertisedModes(a, b) {
3302
3893
  }
3303
3894
  return true;
3304
3895
  }
3896
+ function sameAdvertisedModels(a, b) {
3897
+ if (a.length !== b.length) {
3898
+ return false;
3899
+ }
3900
+ for (let i = 0; i < a.length; i++) {
3901
+ if (a[i]?.modelId !== b[i]?.modelId || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
3902
+ return false;
3903
+ }
3904
+ }
3905
+ return true;
3906
+ }
3907
+ function parseModelsList(list) {
3908
+ if (!Array.isArray(list)) {
3909
+ return [];
3910
+ }
3911
+ const out = [];
3912
+ for (const raw of list) {
3913
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
3914
+ continue;
3915
+ }
3916
+ const r = raw;
3917
+ const modelId = typeof r.modelId === "string" && r.modelId.trim() || typeof r.value === "string" && r.value.trim() || typeof r.id === "string" && r.id.trim() || void 0;
3918
+ if (!modelId) {
3919
+ continue;
3920
+ }
3921
+ const model = { modelId };
3922
+ if (typeof r.name === "string" && r.name.length > 0) {
3923
+ model.name = r.name;
3924
+ }
3925
+ if (typeof r.description === "string" && r.description.length > 0) {
3926
+ model.description = r.description;
3927
+ }
3928
+ out.push(model);
3929
+ }
3930
+ return out;
3931
+ }
3305
3932
  function extractAdvertisedModes(params) {
3306
3933
  const obj = params ?? {};
3307
3934
  const update = obj.update ?? {};
@@ -3506,6 +4133,11 @@ var PersistedAgentMode = z4.object({
3506
4133
  name: z4.string().optional(),
3507
4134
  description: z4.string().optional()
3508
4135
  });
4136
+ var PersistedAgentModel = z4.object({
4137
+ modelId: z4.string(),
4138
+ name: z4.string().optional(),
4139
+ description: z4.string().optional()
4140
+ });
3509
4141
  var PersistedUsage = z4.object({
3510
4142
  used: z4.number().optional(),
3511
4143
  size: z4.number().optional(),
@@ -3552,6 +4184,7 @@ var SessionRecord = z4.object({
3552
4184
  currentUsage: PersistedUsage.optional(),
3553
4185
  agentCommands: z4.array(PersistedAgentCommand).optional(),
3554
4186
  agentModes: z4.array(PersistedAgentMode).optional(),
4187
+ agentModels: z4.array(PersistedAgentModel).optional(),
3555
4188
  createdAt: z4.string(),
3556
4189
  updatedAt: z4.string()
3557
4190
  });
@@ -3671,6 +4304,7 @@ function recordFromMemorySession(args) {
3671
4304
  currentUsage: args.currentUsage,
3672
4305
  agentCommands: args.agentCommands,
3673
4306
  agentModes: args.agentModes,
4307
+ agentModels: args.agentModels,
3674
4308
  createdAt: args.createdAt ?? now,
3675
4309
  updatedAt: args.updatedAt ?? now
3676
4310
  };
@@ -3905,7 +4539,8 @@ var SessionManager = class {
3905
4539
  cwd: params.cwd,
3906
4540
  agentArgs: params.agentArgs,
3907
4541
  mcpServers: params.mcpServers,
3908
- model: params.model
4542
+ model: params.model,
4543
+ onInstallProgress: params.onInstallProgress
3909
4544
  });
3910
4545
  const session = new Session({
3911
4546
  cwd: params.cwd,
@@ -3922,7 +4557,8 @@ var SessionManager = class {
3922
4557
  historyMaxEntries: this.sessionHistoryMaxEntries,
3923
4558
  currentModel: fresh.initialModel,
3924
4559
  currentMode: fresh.initialMode,
3925
- agentModes: fresh.initialModes
4560
+ agentModes: fresh.initialModes,
4561
+ agentModels: fresh.initialModels
3926
4562
  });
3927
4563
  await this.attachManagerHooks(session);
3928
4564
  return session;
@@ -3967,7 +4603,10 @@ var SessionManager = class {
3967
4603
  if (params.upstreamSessionId === "") {
3968
4604
  return this.doResurrectFromImport(params);
3969
4605
  }
3970
- const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
4606
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], {
4607
+ npmRegistry: this.npmRegistry,
4608
+ onInstallProgress: params.onInstallProgress
4609
+ });
3971
4610
  const agent = this.spawner({
3972
4611
  agentId: params.agentId,
3973
4612
  cwd: params.cwd,
@@ -4025,6 +4664,7 @@ var SessionManager = class {
4025
4664
  currentUsage: params.currentUsage,
4026
4665
  agentCommands: params.agentCommands,
4027
4666
  agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
4667
+ agentModels: params.agentModels ?? nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})),
4028
4668
  // Only gate the first-prompt title heuristic when we actually have
4029
4669
  // a title to preserve. A title-less session (lost to a write race
4030
4670
  // or never seeded) should re-derive from the next prompt rather
@@ -4048,7 +4688,8 @@ var SessionManager = class {
4048
4688
  agentId: params.agentId,
4049
4689
  cwd,
4050
4690
  agentArgs: params.agentArgs,
4051
- mcpServers: []
4691
+ mcpServers: [],
4692
+ onInstallProgress: params.onInstallProgress
4052
4693
  });
4053
4694
  const session = new Session({
4054
4695
  sessionId: params.hydraSessionId,
@@ -4071,6 +4712,7 @@ var SessionManager = class {
4071
4712
  currentUsage: params.currentUsage,
4072
4713
  agentCommands: params.agentCommands,
4073
4714
  agentModes: params.agentModes ?? fresh.initialModes,
4715
+ agentModels: params.agentModels ?? fresh.initialModels,
4074
4716
  firstPromptSeeded: !!params.title,
4075
4717
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
4076
4718
  });
@@ -4100,7 +4742,10 @@ var SessionManager = class {
4100
4742
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
4101
4743
  throw err;
4102
4744
  }
4103
- const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
4745
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], {
4746
+ npmRegistry: this.npmRegistry,
4747
+ onInstallProgress: params.onInstallProgress
4748
+ });
4104
4749
  const agent = this.spawner({
4105
4750
  agentId: params.agentId,
4106
4751
  cwd: params.cwd,
@@ -4126,15 +4771,25 @@ var SessionManager = class {
4126
4771
  );
4127
4772
  }
4128
4773
  let initialModel = extractInitialModel(newResult);
4774
+ const initialModels = extractInitialModels(newResult);
4129
4775
  const desired = params.model ?? this.defaultModels[params.agentId];
4130
4776
  if (desired && desired !== initialModel) {
4131
- try {
4132
- await agent.connection.request("session/set_model", {
4133
- sessionId: sessionIdRaw,
4134
- modelId: desired
4135
- });
4136
- initialModel = desired;
4137
- } catch {
4777
+ const validates = initialModels.length === 0 || initialModels.some((m) => m.modelId === desired);
4778
+ if (validates) {
4779
+ try {
4780
+ await agent.connection.request("session/set_model", {
4781
+ sessionId: sessionIdRaw,
4782
+ modelId: desired
4783
+ });
4784
+ initialModel = desired;
4785
+ } catch {
4786
+ }
4787
+ } else {
4788
+ const known = initialModels.map((m) => m.modelId).join(", ");
4789
+ process.stderr.write(
4790
+ `hydra-acp: defaultModels[${params.agentId}]=${JSON.stringify(desired)} is not in the agent's availableModels (${known}); skipping session/set_model
4791
+ `
4792
+ );
4138
4793
  }
4139
4794
  }
4140
4795
  const initialModes = extractInitialModes(newResult);
@@ -4144,6 +4799,7 @@ var SessionManager = class {
4144
4799
  upstreamSessionId: sessionIdRaw,
4145
4800
  agentMeta: newResult._meta,
4146
4801
  initialModel,
4802
+ initialModels: initialModels.length > 0 ? initialModels : void 0,
4147
4803
  initialModes: initialModes.length > 0 ? initialModes : void 0,
4148
4804
  initialMode
4149
4805
  };
@@ -4206,6 +4862,15 @@ var SessionManager = class {
4206
4862
  }))
4207
4863
  }).catch(() => void 0);
4208
4864
  });
4865
+ session.onAgentModelsChange((models) => {
4866
+ void this.persistSnapshot(session.sessionId, {
4867
+ agentModels: models.map((m) => ({
4868
+ modelId: m.modelId,
4869
+ ...m.name !== void 0 ? { name: m.name } : {},
4870
+ ...m.description !== void 0 ? { description: m.description } : {}
4871
+ }))
4872
+ }).catch(() => void 0);
4873
+ });
4209
4874
  this.sessions.set(session.sessionId, session);
4210
4875
  await this.enqueueMetaWrite(session.sessionId, async () => {
4211
4876
  const existing = await this.store.read(session.sessionId);
@@ -4249,6 +4914,7 @@ var SessionManager = class {
4249
4914
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
4250
4915
  agentCommands: record.agentCommands,
4251
4916
  agentModes: record.agentModes,
4917
+ agentModels: record.agentModels,
4252
4918
  createdAt: record.createdAt
4253
4919
  };
4254
4920
  }
@@ -4492,6 +5158,26 @@ var SessionManager = class {
4492
5158
  const record = await this.store.read(sessionId).catch(() => void 0);
4493
5159
  return record !== void 0;
4494
5160
  }
5161
+ // Public retitle entry point that works on live AND cold sessions.
5162
+ // - Live: routes through Session.retitle so attached clients receive
5163
+ // a session_info_update broadcast (and persistTitle fires from the
5164
+ // onTitleChange handler, just like /hydra title).
5165
+ // - Cold: writes the new title straight into meta.json — there's
5166
+ // nothing in memory to broadcast to, but a later resurrect / list
5167
+ // will pick up the new title.
5168
+ // Returns false when no record exists at all (live or on disk).
5169
+ async setTitle(sessionId, title) {
5170
+ const live = this.get(sessionId);
5171
+ if (live) {
5172
+ await live.retitle(title);
5173
+ return true;
5174
+ }
5175
+ if (!await this.hasRecord(sessionId)) {
5176
+ return false;
5177
+ }
5178
+ await this.persistTitle(sessionId, title);
5179
+ return true;
5180
+ }
4495
5181
  // Persist a title update from Session.setTitle. The on-disk record
4496
5182
  // was written at create time; updating it here keeps the session
4497
5183
  // record's title in sync with what was broadcast to clients so a
@@ -4544,6 +5230,7 @@ var SessionManager = class {
4544
5230
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
4545
5231
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
4546
5232
  ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
5233
+ ...update.agentModels !== void 0 ? { agentModels: update.agentModels } : {},
4547
5234
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4548
5235
  });
4549
5236
  });
@@ -4645,6 +5332,18 @@ function mergeForPersistence(session, existing) {
4645
5332
  return out;
4646
5333
  }) : void 0;
4647
5334
  const agentModes = persistedModes ?? existing?.agentModes;
5335
+ const sessionModels = session.availableModels();
5336
+ const persistedModels = sessionModels.length > 0 ? sessionModels.map((m) => {
5337
+ const out = { modelId: m.modelId };
5338
+ if (m.name !== void 0) {
5339
+ out.name = m.name;
5340
+ }
5341
+ if (m.description !== void 0) {
5342
+ out.description = m.description;
5343
+ }
5344
+ return out;
5345
+ }) : void 0;
5346
+ const agentModels = persistedModels ?? existing?.agentModels;
4648
5347
  return recordFromMemorySession({
4649
5348
  sessionId: session.sessionId,
4650
5349
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -4661,6 +5360,7 @@ function mergeForPersistence(session, existing) {
4661
5360
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
4662
5361
  agentCommands,
4663
5362
  agentModes,
5363
+ agentModels,
4664
5364
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
4665
5365
  });
4666
5366
  }
@@ -4726,6 +5426,40 @@ function asString(value) {
4726
5426
  function nonEmptyOrUndefined(arr) {
4727
5427
  return arr.length > 0 ? arr : void 0;
4728
5428
  }
5429
+ function extractInitialModels(result) {
5430
+ const direct = parseModelsList(result.availableModels);
5431
+ if (direct.length > 0) {
5432
+ return direct;
5433
+ }
5434
+ const models = result.models;
5435
+ if (models && typeof models === "object" && !Array.isArray(models)) {
5436
+ const fromModelsObj = parseModelsList(
5437
+ models.availableModels
5438
+ );
5439
+ if (fromModelsObj.length > 0) {
5440
+ return fromModelsObj;
5441
+ }
5442
+ }
5443
+ const meta = result._meta;
5444
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
5445
+ for (const [key, value] of Object.entries(
5446
+ meta
5447
+ )) {
5448
+ if (key === "hydra-acp") {
5449
+ continue;
5450
+ }
5451
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5452
+ const fromMeta = parseModelsList(
5453
+ value.availableModels
5454
+ );
5455
+ if (fromMeta.length > 0) {
5456
+ return fromMeta;
5457
+ }
5458
+ }
5459
+ }
5460
+ }
5461
+ return [];
5462
+ }
4729
5463
  function extractInitialModes(result) {
4730
5464
  const direct = parseModesList(result.availableModes);
4731
5465
  if (direct.length > 0) {
@@ -5948,8 +6682,55 @@ function mapToolCallUpdate(u) {
5948
6682
  if (status !== void 0) {
5949
6683
  event.status = status;
5950
6684
  }
6685
+ if (status === "failed") {
6686
+ const errorText = extractToolFailureText(u);
6687
+ if (errorText !== null) {
6688
+ event.errorText = errorText;
6689
+ }
6690
+ if (isUpstreamInterrupted(u, errorText)) {
6691
+ event.upstreamInterrupted = true;
6692
+ }
6693
+ }
5951
6694
  return event;
5952
6695
  }
6696
+ function extractToolFailureText(u) {
6697
+ const content = u.content;
6698
+ if (Array.isArray(content)) {
6699
+ for (const block of content) {
6700
+ if (!block || typeof block !== "object") {
6701
+ continue;
6702
+ }
6703
+ const b = block;
6704
+ const text = extractContentText(b.content);
6705
+ if (text !== null && text.length > 0) {
6706
+ return text;
6707
+ }
6708
+ }
6709
+ }
6710
+ const rawOutput = u.rawOutput;
6711
+ if (rawOutput && typeof rawOutput === "object") {
6712
+ const err = rawOutput.error;
6713
+ if (typeof err === "string" && err.length > 0) {
6714
+ return sanitizeWireText(err);
6715
+ }
6716
+ }
6717
+ return null;
6718
+ }
6719
+ function isUpstreamInterrupted(u, errorText) {
6720
+ const rawOutput = u.rawOutput;
6721
+ if (rawOutput && typeof rawOutput === "object") {
6722
+ const meta = rawOutput.metadata;
6723
+ if (meta && typeof meta === "object") {
6724
+ if (meta.interrupted === true) {
6725
+ return true;
6726
+ }
6727
+ }
6728
+ }
6729
+ if (errorText !== null && errorText.toLowerCase().includes("tool execution aborted")) {
6730
+ return true;
6731
+ }
6732
+ return false;
6733
+ }
5953
6734
  function mapPlan(u) {
5954
6735
  const entries = u.entries;
5955
6736
  if (!Array.isArray(entries)) {
@@ -5992,7 +6773,16 @@ function mapModel(u) {
5992
6773
  }
5993
6774
  function mapTurnComplete(u) {
5994
6775
  const stopReason = readString(u, "stopReason");
5995
- return stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" };
6776
+ const meta = u._meta;
6777
+ const amended = meta?.["hydra-acp"]?.amended !== void 0 && meta["hydra-acp"].amended !== null;
6778
+ const out = { kind: "turn-complete" };
6779
+ if (stopReason !== void 0) {
6780
+ out.stopReason = stopReason;
6781
+ }
6782
+ if (amended) {
6783
+ out.amended = true;
6784
+ }
6785
+ return out;
5996
6786
  }
5997
6787
  function extractContentText(content) {
5998
6788
  if (typeof content === "string") {
@@ -6321,6 +7111,35 @@ function registerSessionRoutes(app, manager, defaults) {
6321
7111
  }
6322
7112
  reply.code(204).send();
6323
7113
  });
7114
+ app.patch("/v1/sessions/:id", async (request, reply) => {
7115
+ const raw = request.params.id;
7116
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
7117
+ const body = request.body ?? {};
7118
+ if (body.regen === true) {
7119
+ const session = manager.get(id);
7120
+ if (!session) {
7121
+ reply.code(409).send({ error: "regen requires a live session" });
7122
+ return;
7123
+ }
7124
+ void session.retitleFromAgent().catch((err) => {
7125
+ app.log.warn(
7126
+ `title regen failed for ${id}: ${err.message}`
7127
+ );
7128
+ });
7129
+ reply.code(202).send();
7130
+ return;
7131
+ }
7132
+ if (typeof body.title !== "string" || body.title.trim().length === 0) {
7133
+ reply.code(400).send({ error: "title must be a non-empty string" });
7134
+ return;
7135
+ }
7136
+ const ok = await manager.setTitle(id, body.title);
7137
+ if (!ok) {
7138
+ reply.code(404).send({ error: "session not found" });
7139
+ return;
7140
+ }
7141
+ reply.code(204).send();
7142
+ });
6324
7143
  app.delete("/v1/sessions/:id", async (request, reply) => {
6325
7144
  const raw = request.params.id;
6326
7145
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -6908,7 +7727,8 @@ function registerAcpWsEndpoint(app, deps) {
6908
7727
  mcpServers: params.mcpServers,
6909
7728
  title: hydraMeta.name,
6910
7729
  agentArgs: hydraMeta.agentArgs,
6911
- model: hydraMeta.model
7730
+ model: hydraMeta.model,
7731
+ onInstallProgress: makeInstallProgressForwarder(connection)
6912
7732
  });
6913
7733
  const client = bindClientToSession(connection, session, state);
6914
7734
  const { entries: replay } = await session.attach(client, "full");
@@ -6924,6 +7744,7 @@ function registerAcpWsEndpoint(app, deps) {
6924
7744
  })();
6925
7745
  });
6926
7746
  const modesPayload = buildModesPayload(session);
7747
+ const modelsPayload = buildModelsPayload(session);
6927
7748
  return {
6928
7749
  sessionId: session.sessionId,
6929
7750
  // session/new is implicitly an attach; mirror session/attach's
@@ -6932,6 +7753,7 @@ function registerAcpWsEndpoint(app, deps) {
6932
7753
  // events without an extra round-trip.
6933
7754
  clientId: client.clientId,
6934
7755
  ...modesPayload ? { modes: modesPayload } : {},
7756
+ ...modelsPayload ? { models: modelsPayload } : {},
6935
7757
  _meta: buildResponseMeta(session)
6936
7758
  };
6937
7759
  });
@@ -6967,7 +7789,10 @@ function registerAcpWsEndpoint(app, deps) {
6967
7789
  err.code = JsonRpcErrorCodes.SessionNotFound;
6968
7790
  throw err;
6969
7791
  }
6970
- session = await deps.manager.resurrect(resurrectParams);
7792
+ session = await deps.manager.resurrect({
7793
+ ...resurrectParams,
7794
+ onInstallProgress: makeInstallProgressForwarder(connection)
7795
+ });
6971
7796
  }
6972
7797
  const client = bindClientToSession(
6973
7798
  connection,
@@ -6993,6 +7818,7 @@ function registerAcpWsEndpoint(app, deps) {
6993
7818
  }
6994
7819
  session.replayPendingPermissions(client);
6995
7820
  const modesPayload = buildModesPayload(session);
7821
+ const modelsPayload = buildModelsPayload(session);
6996
7822
  return {
6997
7823
  sessionId: session.sessionId,
6998
7824
  clientId: client.clientId,
@@ -7004,6 +7830,7 @@ function registerAcpWsEndpoint(app, deps) {
7004
7830
  historyPolicy: appliedPolicy,
7005
7831
  replayed: replay.length,
7006
7832
  ...modesPayload ? { modes: modesPayload } : {},
7833
+ ...modelsPayload ? { models: modelsPayload } : {},
7007
7834
  _meta: buildResponseMeta(session)
7008
7835
  };
7009
7836
  });
@@ -7111,6 +7938,22 @@ function registerAcpWsEndpoint(app, deps) {
7111
7938
  }
7112
7939
  return session.updateQueuedPrompt(params.messageId, params.prompt);
7113
7940
  });
7941
+ connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
7942
+ const params = AmendPromptParams.parse(raw);
7943
+ const att = state.attached.get(params.sessionId);
7944
+ if (!att) {
7945
+ const err = new Error("not attached to session");
7946
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7947
+ throw err;
7948
+ }
7949
+ const session = deps.manager.get(params.sessionId);
7950
+ if (!session) {
7951
+ const err = new Error(`session ${params.sessionId} not found`);
7952
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7953
+ throw err;
7954
+ }
7955
+ return session.amendPrompt(att.clientId, params);
7956
+ });
7114
7957
  connection.onRequest("session/load", async (raw) => {
7115
7958
  const rawObj = raw ?? {};
7116
7959
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -7143,15 +7986,39 @@ function registerAcpWsEndpoint(app, deps) {
7143
7986
  }
7144
7987
  session.replayPendingPermissions(client);
7145
7988
  const modesPayload = buildModesPayload(session);
7989
+ const modelsPayload = buildModelsPayload(session);
7146
7990
  return {
7147
7991
  sessionId: session.sessionId,
7148
7992
  // Same as session/new: include clientId so the deferred-echo
7149
7993
  // path in queue-aware clients can recognize own broadcasts.
7150
7994
  clientId: client.clientId,
7151
7995
  ...modesPayload ? { modes: modesPayload } : {},
7996
+ ...modelsPayload ? { models: modelsPayload } : {},
7152
7997
  _meta: buildResponseMeta(session)
7153
7998
  };
7154
7999
  });
8000
+ connection.onRequest("session/set_model", async (rawParams) => {
8001
+ const decision = decideSetModel(rawParams, deps.manager);
8002
+ if (decision.kind === "error") {
8003
+ app.log.warn(decision.logMessage);
8004
+ const err = new Error(decision.message);
8005
+ err.code = decision.code;
8006
+ throw err;
8007
+ }
8008
+ if (decision.kind === "no_op") {
8009
+ app.log.warn(decision.logMessage);
8010
+ await connection.notify("session/update", {
8011
+ sessionId: decision.sessionId,
8012
+ update: {
8013
+ sessionUpdate: "current_model_update",
8014
+ currentModel: decision.currentModel
8015
+ }
8016
+ }).catch(() => void 0);
8017
+ return null;
8018
+ }
8019
+ app.log.info(decision.logMessage);
8020
+ return decision.session.forwardRequest("session/set_model", rawParams);
8021
+ });
7155
8022
  connection.setDefaultHandler(async (rawParams, method) => {
7156
8023
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
7157
8024
  const err = new Error(`Method not found: ${method}`);
@@ -7174,6 +8041,26 @@ function registerAcpWsEndpoint(app, deps) {
7174
8041
  });
7175
8042
  });
7176
8043
  }
8044
+ function makeInstallProgressForwarder(connection) {
8045
+ return (event) => {
8046
+ const payload = {
8047
+ agentId: event.agentId,
8048
+ version: event.version,
8049
+ source: event.source,
8050
+ phase: event.phase
8051
+ };
8052
+ if ("receivedBytes" in event) {
8053
+ payload.receivedBytes = event.receivedBytes;
8054
+ }
8055
+ if ("totalBytes" in event) {
8056
+ payload.totalBytes = event.totalBytes;
8057
+ }
8058
+ if ("packageSpec" in event) {
8059
+ payload.packageSpec = event.packageSpec;
8060
+ }
8061
+ void connection.notify(AGENT_INSTALL_PROGRESS_METHOD, payload).catch(() => void 0);
8062
+ };
8063
+ }
7177
8064
  function buildModesPayload(session) {
7178
8065
  const modes = session.availableModes();
7179
8066
  if (modes.length === 0) {
@@ -7194,6 +8081,94 @@ function buildModesPayload(session) {
7194
8081
  const currentModeId = session.currentMode ?? modes[0].id;
7195
8082
  return { currentModeId, availableModes };
7196
8083
  }
8084
+ function buildModelsPayload(session) {
8085
+ const models = session.availableModels();
8086
+ if (models.length === 0) {
8087
+ return void 0;
8088
+ }
8089
+ const availableModels = models.map((m) => {
8090
+ const out = {
8091
+ modelId: m.modelId
8092
+ };
8093
+ if (m.name !== void 0) {
8094
+ out.name = m.name;
8095
+ }
8096
+ if (m.description !== void 0) {
8097
+ out.description = m.description;
8098
+ }
8099
+ return out;
8100
+ });
8101
+ const currentModelId = session.currentModel ?? models[0].modelId;
8102
+ return { currentModelId, availableModels };
8103
+ }
8104
+ function decideSetModel(rawParams, manager) {
8105
+ if (!rawParams || typeof rawParams !== "object") {
8106
+ return {
8107
+ kind: "error",
8108
+ code: JsonRpcErrorCodes.InvalidParams,
8109
+ message: "session/set_model requires params",
8110
+ logMessage: "session/set_model rejected: params not an object"
8111
+ };
8112
+ }
8113
+ const params = rawParams;
8114
+ if (typeof params.sessionId !== "string") {
8115
+ return {
8116
+ kind: "error",
8117
+ code: JsonRpcErrorCodes.InvalidParams,
8118
+ message: "session/set_model requires string sessionId",
8119
+ logMessage: "session/set_model rejected: missing/non-string sessionId"
8120
+ };
8121
+ }
8122
+ if (typeof params.modelId !== "string") {
8123
+ return {
8124
+ kind: "error",
8125
+ code: JsonRpcErrorCodes.InvalidParams,
8126
+ message: "session/set_model requires string modelId",
8127
+ logMessage: `session/set_model rejected: missing/non-string modelId sessionId=${params.sessionId}`
8128
+ };
8129
+ }
8130
+ const session = manager.get(params.sessionId);
8131
+ if (!session) {
8132
+ return {
8133
+ kind: "error",
8134
+ code: JsonRpcErrorCodes.SessionNotFound,
8135
+ message: `session ${params.sessionId} not found`,
8136
+ logMessage: `session/set_model rejected: session not found sessionId=${params.sessionId}`
8137
+ };
8138
+ }
8139
+ const advertised = session.availableModels();
8140
+ if (advertised.length === 0) {
8141
+ return {
8142
+ kind: "ok",
8143
+ session,
8144
+ logMessage: `session/set_model passthrough (no availableModels) sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
8145
+ };
8146
+ }
8147
+ const match = advertised.find((m) => m.modelId === params.modelId);
8148
+ if (!match) {
8149
+ const known = advertised.map((m) => m.modelId).join(", ");
8150
+ if (session.currentModel !== void 0 && session.currentModel.length > 0) {
8151
+ return {
8152
+ kind: "no_op",
8153
+ session,
8154
+ sessionId: params.sessionId,
8155
+ currentModel: session.currentModel,
8156
+ logMessage: `session/set_model no_op (resyncing client) sessionId=${params.sessionId} requested=${JSON.stringify(params.modelId)} actual=${JSON.stringify(session.currentModel)} agentId=${session.agentId} known=[${known}]`
8157
+ };
8158
+ }
8159
+ return {
8160
+ kind: "error",
8161
+ code: JsonRpcErrorCodes.InvalidParams,
8162
+ message: `model "${params.modelId}" is not in this session's availableModels (agent ${session.agentId}); known models: ${known}`,
8163
+ logMessage: `session/set_model rejected sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)} agentId=${session.agentId} known=[${known}] (no current model to fall back to)`
8164
+ };
8165
+ }
8166
+ return {
8167
+ kind: "ok",
8168
+ session,
8169
+ logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
8170
+ };
8171
+ }
7197
8172
  function buildResponseMeta(session) {
7198
8173
  const ours = {
7199
8174
  upstreamSessionId: session.upstreamSessionId,
@@ -7223,6 +8198,10 @@ function buildResponseMeta(session) {
7223
8198
  if (modes.length > 0) {
7224
8199
  ours.availableModes = modes;
7225
8200
  }
8201
+ const models = session.availableModels();
8202
+ if (models.length > 0) {
8203
+ ours.availableModels = models;
8204
+ }
7226
8205
  if (session.turnStartedAt !== void 0) {
7227
8206
  ours.turnStartedAt = session.turnStartedAt;
7228
8207
  }
@@ -7263,10 +8242,17 @@ function buildInitializeResult() {
7263
8242
  ],
7264
8243
  // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
7265
8244
  // ACP clients ignore the field; capability-aware clients learn here
7266
- // that hydra accepts concurrent session/prompt requests and emits
7267
- // prompt_queue_* notifications so they can stop running their own
7268
- // local queue.
7269
- _meta: mergeMeta(void 0, { promptQueueing: true })
8245
+ // which hydra-acp extensions the daemon supports so they can gate
8246
+ // UI surface accordingly. promptPipelining is false until the
8247
+ // streaming-input probe lands (Option A in the steering brief);
8248
+ // the others are unconditional method-availability flags.
8249
+ _meta: mergeMeta(void 0, {
8250
+ promptQueueing: true,
8251
+ promptCancelling: true,
8252
+ promptUpdating: true,
8253
+ promptAmending: true,
8254
+ promptPipelining: false
8255
+ })
7270
8256
  };
7271
8257
  }
7272
8258
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {