@hydra-acp/cli 0.1.25 → 0.1.27

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
@@ -380,8 +380,10 @@ async function ensureBinary(args) {
380
380
  }
381
381
  await downloadAndExtract({
382
382
  agentId: args.agentId,
383
+ version: args.version,
383
384
  archiveUrl: args.target.archive,
384
- installDir
385
+ installDir,
386
+ onProgress: args.onProgress
385
387
  });
386
388
  if (!await fileExists(cmdPath)) {
387
389
  throw new Error(
@@ -401,9 +403,16 @@ async function downloadAndExtract(args) {
401
403
  const archivePath = await downloadTo({
402
404
  url: args.archiveUrl,
403
405
  dir: tempDir,
404
- agentId: args.agentId
406
+ agentId: args.agentId,
407
+ version: args.version,
408
+ onProgress: args.onProgress
405
409
  });
406
410
  logSink(`hydra-acp: extracting ${args.agentId}`);
411
+ safeEmit(args.onProgress, {
412
+ phase: "extract",
413
+ agentId: args.agentId,
414
+ version: args.version
415
+ });
407
416
  await extract(archivePath, tempDir);
408
417
  await fsp.unlink(archivePath).catch(() => void 0);
409
418
  try {
@@ -414,16 +423,35 @@ async function downloadAndExtract(args) {
414
423
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(
415
424
  () => void 0
416
425
  );
426
+ safeEmit(args.onProgress, {
427
+ phase: "installed",
428
+ agentId: args.agentId,
429
+ version: args.version
430
+ });
417
431
  return;
418
432
  }
419
433
  throw err;
420
434
  }
421
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
+ });
422
441
  } catch (err) {
423
442
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
424
443
  throw err;
425
444
  }
426
445
  }
446
+ function safeEmit(cb, event) {
447
+ if (!cb) {
448
+ return;
449
+ }
450
+ try {
451
+ cb(event);
452
+ } catch {
453
+ }
454
+ }
427
455
  async function downloadTo(args) {
428
456
  const filename = inferArchiveName(args.url);
429
457
  const dest = path2.join(args.dir, filename);
@@ -436,17 +464,34 @@ async function downloadTo(args) {
436
464
  const total = Number(response.headers.get("content-length") ?? "0");
437
465
  const out = fs3.createWriteStream(dest);
438
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
+ });
439
473
  let received = 0;
440
- let lastEmit = Date.now();
441
- 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;
442
478
  nodeStream.on("data", (chunk) => {
443
479
  received += chunk.length;
444
480
  const now = Date.now();
445
- if (now - lastEmit < EMIT_INTERVAL_MS) {
446
- 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));
447
494
  }
448
- lastEmit = now;
449
- logSink(formatProgress(args.agentId, received, total));
450
495
  });
451
496
  await new Promise((resolve3, reject) => {
452
497
  nodeStream.on("error", reject);
@@ -461,6 +506,13 @@ async function downloadTo(args) {
461
506
  /* done */
462
507
  true
463
508
  ));
509
+ safeEmit(args.onProgress, {
510
+ phase: "download_done",
511
+ agentId: args.agentId,
512
+ version: args.version,
513
+ receivedBytes: received,
514
+ totalBytes: total
515
+ });
464
516
  return dest;
465
517
  }
466
518
  function formatProgress(agentId, received, total, done = false) {
@@ -559,9 +611,11 @@ async function ensureNpmPackage(args) {
559
611
  }
560
612
  await installInto({
561
613
  agentId: args.agentId,
614
+ version: args.version,
562
615
  packageSpec: args.packageSpec,
563
616
  installDir,
564
- registry: args.registry
617
+ registry: args.registry,
618
+ onProgress: args.onProgress
565
619
  });
566
620
  if (!await fileExists2(binPath)) {
567
621
  throw new Error(
@@ -577,6 +631,12 @@ async function installInto(args) {
577
631
  logSink2(
578
632
  `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
579
633
  );
634
+ safeEmit2(args.onProgress, {
635
+ phase: "install_start",
636
+ agentId: args.agentId,
637
+ version: args.version,
638
+ packageSpec: args.packageSpec
639
+ });
580
640
  await runNpmInstall({
581
641
  packageSpec: args.packageSpec,
582
642
  cwd: tempDir,
@@ -590,11 +650,21 @@ async function installInto(args) {
590
650
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
591
651
  () => void 0
592
652
  );
653
+ safeEmit2(args.onProgress, {
654
+ phase: "installed",
655
+ agentId: args.agentId,
656
+ version: args.version
657
+ });
593
658
  return;
594
659
  }
595
660
  throw err;
596
661
  }
597
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
+ });
598
668
  } catch (err) {
599
669
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
600
670
  () => void 0
@@ -602,44 +672,87 @@ async function installInto(args) {
602
672
  throw err;
603
673
  }
604
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;
605
686
  function runNpmInstall(args) {
606
- return new Promise((resolve3, reject) => {
607
- const registryArgs = args.registry ? ["--registry", args.registry] : [];
608
- const child = spawn2(
609
- "npm",
610
- ["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
611
- {
612
- cwd: args.cwd,
613
- stdio: ["ignore", "pipe", "pipe"]
614
- }
615
- );
616
- let stderrTail = "";
617
- child.stdout?.on("data", (chunk) => {
618
- void chunk;
619
- });
620
- child.stderr?.setEncoding("utf8");
621
- child.stderr?.on("data", (chunk) => {
622
- stderrTail = (stderrTail + chunk).slice(-4096);
623
- });
624
- child.on("error", (err) => {
625
- const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
626
- reject(new Error(msg));
627
- });
628
- child.on("exit", (code, signal) => {
629
- if (code === 0) {
630
- 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);
631
709
  return;
632
710
  }
633
- const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
634
- const tail = stderrTail.trim();
635
- reject(
636
- new Error(
637
- 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})
638
741
  stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
639
- )
640
- );
742
+ )
743
+ );
744
+ });
641
745
  });
642
- });
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
+ }
643
756
  }
644
757
  async function fileExists2(p) {
645
758
  try {
@@ -837,12 +950,14 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
837
950
  };
838
951
  }
839
952
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
953
+ const npmCb = options.onInstallProgress;
840
954
  const binPath = await ensureNpmPackage({
841
955
  agentId: agent.id,
842
956
  version,
843
957
  packageSpec: npx.package,
844
958
  bin,
845
- registry: options.npmRegistry
959
+ registry: options.npmRegistry,
960
+ onProgress: npmCb ? (e) => npmCb({ source: "npm", ...e }) : void 0
846
961
  });
847
962
  return {
848
963
  command: binPath,
@@ -858,10 +973,12 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
858
973
  `Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
859
974
  );
860
975
  }
976
+ const binCb = options.onInstallProgress;
861
977
  const cmdPath = await ensureBinary({
862
978
  agentId: agent.id,
863
979
  version,
864
- target
980
+ target,
981
+ onProgress: binCb ? (e) => binCb({ source: "binary", ...e }) : void 0
865
982
  });
866
983
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
867
984
  return {
@@ -1094,6 +1211,29 @@ function extractHydraMeta(meta) {
1094
1211
  out.availableModes = modes;
1095
1212
  }
1096
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
+ }
1097
1237
  return out;
1098
1238
  }
1099
1239
  function mergeMeta(passthrough, ours) {
@@ -1217,6 +1357,23 @@ var PromptAmendedParams = z3.object({
1217
1357
  originator: PromptOriginatorSchema,
1218
1358
  amendedAt: z3.number()
1219
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";
1220
1377
  var ProxyInitializeParams = z3.object({
1221
1378
  protocolVersion: z3.number().optional(),
1222
1379
  proxyInfo: z3.object({
@@ -1787,11 +1944,19 @@ var Session = class {
1787
1944
  // Last available_modes_update we observed from the agent. Same
1788
1945
  // pattern as commands: cache, persist, broadcast on change.
1789
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 = [];
1790
1954
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1791
1955
  // to mirror changes into meta.json so cold-resurrect attaches can
1792
1956
  // surface the latest snapshot via the attach response _meta.
1793
1957
  agentCommandsHandlers = [];
1794
1958
  agentModesHandlers = [];
1959
+ agentModelsHandlers = [];
1795
1960
  modelHandlers = [];
1796
1961
  modeHandlers = [];
1797
1962
  usageHandlers = [];
@@ -1827,6 +1992,9 @@ var Session = class {
1827
1992
  if (init.agentModes && init.agentModes.length > 0) {
1828
1993
  this.agentAdvertisedModes = [...init.agentModes];
1829
1994
  }
1995
+ if (init.agentModels && init.agentModels.length > 0) {
1996
+ this.agentAdvertisedModels = [...init.agentModels];
1997
+ }
1830
1998
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1831
1999
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1832
2000
  this.logger = init.logger;
@@ -1864,6 +2032,23 @@ var Session = class {
1864
2032
  }
1865
2033
  });
1866
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
+ }
1867
2052
  // Register session/update, session/request_permission, and onExit
1868
2053
  // handlers on an agent connection. Re-run on every /hydra agent so
1869
2054
  // the new agent is plumbed identically. The exit handler's identity
@@ -1894,6 +2079,10 @@ var Session = class {
1894
2079
  this.recordAndBroadcast("session/update", params);
1895
2080
  return;
1896
2081
  }
2082
+ if (this.maybeApplyAgentConfigOption(params)) {
2083
+ this.recordAndBroadcast("session/update", params);
2084
+ return;
2085
+ }
1897
2086
  if (this.maybeApplyAgentUsage(params)) {
1898
2087
  this.recordAndBroadcast("session/update", params);
1899
2088
  return;
@@ -2042,16 +2231,19 @@ var Session = class {
2042
2231
  recordedAt
2043
2232
  });
2044
2233
  }
2045
- 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
+ }
2046
2244
  out.push({
2047
2245
  method: "session/update",
2048
- params: {
2049
- sessionId,
2050
- update: {
2051
- sessionUpdate: "current_model_update",
2052
- currentModel: this.currentModel
2053
- }
2054
- },
2246
+ params: { sessionId, update },
2055
2247
  recordedAt
2056
2248
  });
2057
2249
  }
@@ -2686,6 +2878,18 @@ var Session = class {
2686
2878
  onTitleChange(handler) {
2687
2879
  this.titleHandlers.push(handler);
2688
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
+ }
2689
2893
  // Update the canonical title and broadcast a session_info_update to
2690
2894
  // every attached client. Clients that already speak the spec's
2691
2895
  // session_info_update need no hydra-specific wiring to pick this up.
@@ -2733,12 +2937,19 @@ var Session = class {
2733
2937
  // Apply an agent-emitted current_model_update. Returns true if the
2734
2938
  // notification was a model update (caller still needs to broadcast
2735
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.
2736
2943
  maybeApplyAgentModel(params) {
2737
2944
  const obj = params ?? {};
2738
2945
  const update = obj.update ?? {};
2739
2946
  if (update.sessionUpdate !== "current_model_update") {
2740
2947
  return false;
2741
2948
  }
2949
+ const models = parseModelsList(update.availableModels);
2950
+ if (models.length > 0) {
2951
+ this.setAgentAdvertisedModels(models);
2952
+ }
2742
2953
  const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
2743
2954
  if (raw === void 0) {
2744
2955
  return true;
@@ -2756,6 +2967,55 @@ var Session = class {
2756
2967
  }
2757
2968
  return true;
2758
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
+ }
2759
3019
  maybeApplyAgentMode(params) {
2760
3020
  const obj = params ?? {};
2761
3021
  const update = obj.update ?? {};
@@ -2854,6 +3114,20 @@ var Session = class {
2854
3114
  }
2855
3115
  this.broadcastAvailableModes();
2856
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
+ }
2857
3131
  // Subscribe to snapshot-state updates. SessionManager wires these to
2858
3132
  // persist the new value into meta.json so cold resurrect can restore
2859
3133
  // them via the attach response _meta.
@@ -2863,6 +3137,9 @@ var Session = class {
2863
3137
  onAgentModesChange(handler) {
2864
3138
  this.agentModesHandlers.push(handler);
2865
3139
  }
3140
+ onAgentModelsChange(handler) {
3141
+ this.agentModelsHandlers.push(handler);
3142
+ }
2866
3143
  onModelChange(handler) {
2867
3144
  this.modelHandlers.push(handler);
2868
3145
  }
@@ -2888,6 +3165,15 @@ var Session = class {
2888
3165
  availableModes() {
2889
3166
  return [...this.agentAdvertisedModes];
2890
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
+ }
2891
3177
  // Pick up an agent-emitted session_info_update and store its title
2892
3178
  // as our canonical record. The notification is also forwarded to
2893
3179
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -3035,6 +3321,12 @@ var Session = class {
3035
3321
  this.agentMeta = fresh.agentMeta;
3036
3322
  this.agentAdvertisedCommands = [];
3037
3323
  this.broadcastMergedCommands();
3324
+ if (this.agentAdvertisedModels.length > 0) {
3325
+ this.setAgentAdvertisedModels([]);
3326
+ }
3327
+ if (this.agentAdvertisedModes.length > 0) {
3328
+ this.setAgentAdvertisedModes([]);
3329
+ }
3038
3330
  await oldAgent.kill().catch(() => void 0);
3039
3331
  if (transcript) {
3040
3332
  await this.runInternalPrompt(transcript).catch(() => void 0);
@@ -3601,6 +3893,42 @@ function sameAdvertisedModes(a, b) {
3601
3893
  }
3602
3894
  return true;
3603
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
+ }
3604
3932
  function extractAdvertisedModes(params) {
3605
3933
  const obj = params ?? {};
3606
3934
  const update = obj.update ?? {};
@@ -3805,6 +4133,11 @@ var PersistedAgentMode = z4.object({
3805
4133
  name: z4.string().optional(),
3806
4134
  description: z4.string().optional()
3807
4135
  });
4136
+ var PersistedAgentModel = z4.object({
4137
+ modelId: z4.string(),
4138
+ name: z4.string().optional(),
4139
+ description: z4.string().optional()
4140
+ });
3808
4141
  var PersistedUsage = z4.object({
3809
4142
  used: z4.number().optional(),
3810
4143
  size: z4.number().optional(),
@@ -3851,6 +4184,7 @@ var SessionRecord = z4.object({
3851
4184
  currentUsage: PersistedUsage.optional(),
3852
4185
  agentCommands: z4.array(PersistedAgentCommand).optional(),
3853
4186
  agentModes: z4.array(PersistedAgentMode).optional(),
4187
+ agentModels: z4.array(PersistedAgentModel).optional(),
3854
4188
  createdAt: z4.string(),
3855
4189
  updatedAt: z4.string()
3856
4190
  });
@@ -3970,6 +4304,7 @@ function recordFromMemorySession(args) {
3970
4304
  currentUsage: args.currentUsage,
3971
4305
  agentCommands: args.agentCommands,
3972
4306
  agentModes: args.agentModes,
4307
+ agentModels: args.agentModels,
3973
4308
  createdAt: args.createdAt ?? now,
3974
4309
  updatedAt: args.updatedAt ?? now
3975
4310
  };
@@ -4204,7 +4539,8 @@ var SessionManager = class {
4204
4539
  cwd: params.cwd,
4205
4540
  agentArgs: params.agentArgs,
4206
4541
  mcpServers: params.mcpServers,
4207
- model: params.model
4542
+ model: params.model,
4543
+ onInstallProgress: params.onInstallProgress
4208
4544
  });
4209
4545
  const session = new Session({
4210
4546
  cwd: params.cwd,
@@ -4221,7 +4557,8 @@ var SessionManager = class {
4221
4557
  historyMaxEntries: this.sessionHistoryMaxEntries,
4222
4558
  currentModel: fresh.initialModel,
4223
4559
  currentMode: fresh.initialMode,
4224
- agentModes: fresh.initialModes
4560
+ agentModes: fresh.initialModes,
4561
+ agentModels: fresh.initialModels
4225
4562
  });
4226
4563
  await this.attachManagerHooks(session);
4227
4564
  return session;
@@ -4266,7 +4603,10 @@ var SessionManager = class {
4266
4603
  if (params.upstreamSessionId === "") {
4267
4604
  return this.doResurrectFromImport(params);
4268
4605
  }
4269
- 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
+ });
4270
4610
  const agent = this.spawner({
4271
4611
  agentId: params.agentId,
4272
4612
  cwd: params.cwd,
@@ -4324,6 +4664,7 @@ var SessionManager = class {
4324
4664
  currentUsage: params.currentUsage,
4325
4665
  agentCommands: params.agentCommands,
4326
4666
  agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
4667
+ agentModels: params.agentModels ?? nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})),
4327
4668
  // Only gate the first-prompt title heuristic when we actually have
4328
4669
  // a title to preserve. A title-less session (lost to a write race
4329
4670
  // or never seeded) should re-derive from the next prompt rather
@@ -4347,7 +4688,8 @@ var SessionManager = class {
4347
4688
  agentId: params.agentId,
4348
4689
  cwd,
4349
4690
  agentArgs: params.agentArgs,
4350
- mcpServers: []
4691
+ mcpServers: [],
4692
+ onInstallProgress: params.onInstallProgress
4351
4693
  });
4352
4694
  const session = new Session({
4353
4695
  sessionId: params.hydraSessionId,
@@ -4370,6 +4712,7 @@ var SessionManager = class {
4370
4712
  currentUsage: params.currentUsage,
4371
4713
  agentCommands: params.agentCommands,
4372
4714
  agentModes: params.agentModes ?? fresh.initialModes,
4715
+ agentModels: params.agentModels ?? fresh.initialModels,
4373
4716
  firstPromptSeeded: !!params.title,
4374
4717
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
4375
4718
  });
@@ -4399,7 +4742,10 @@ var SessionManager = class {
4399
4742
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
4400
4743
  throw err;
4401
4744
  }
4402
- 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
+ });
4403
4749
  const agent = this.spawner({
4404
4750
  agentId: params.agentId,
4405
4751
  cwd: params.cwd,
@@ -4425,15 +4771,25 @@ var SessionManager = class {
4425
4771
  );
4426
4772
  }
4427
4773
  let initialModel = extractInitialModel(newResult);
4774
+ const initialModels = extractInitialModels(newResult);
4428
4775
  const desired = params.model ?? this.defaultModels[params.agentId];
4429
4776
  if (desired && desired !== initialModel) {
4430
- try {
4431
- await agent.connection.request("session/set_model", {
4432
- sessionId: sessionIdRaw,
4433
- modelId: desired
4434
- });
4435
- initialModel = desired;
4436
- } 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
+ );
4437
4793
  }
4438
4794
  }
4439
4795
  const initialModes = extractInitialModes(newResult);
@@ -4443,6 +4799,7 @@ var SessionManager = class {
4443
4799
  upstreamSessionId: sessionIdRaw,
4444
4800
  agentMeta: newResult._meta,
4445
4801
  initialModel,
4802
+ initialModels: initialModels.length > 0 ? initialModels : void 0,
4446
4803
  initialModes: initialModes.length > 0 ? initialModes : void 0,
4447
4804
  initialMode
4448
4805
  };
@@ -4505,6 +4862,15 @@ var SessionManager = class {
4505
4862
  }))
4506
4863
  }).catch(() => void 0);
4507
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
+ });
4508
4874
  this.sessions.set(session.sessionId, session);
4509
4875
  await this.enqueueMetaWrite(session.sessionId, async () => {
4510
4876
  const existing = await this.store.read(session.sessionId);
@@ -4548,6 +4914,7 @@ var SessionManager = class {
4548
4914
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
4549
4915
  agentCommands: record.agentCommands,
4550
4916
  agentModes: record.agentModes,
4917
+ agentModels: record.agentModels,
4551
4918
  createdAt: record.createdAt
4552
4919
  };
4553
4920
  }
@@ -4791,6 +5158,26 @@ var SessionManager = class {
4791
5158
  const record = await this.store.read(sessionId).catch(() => void 0);
4792
5159
  return record !== void 0;
4793
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
+ }
4794
5181
  // Persist a title update from Session.setTitle. The on-disk record
4795
5182
  // was written at create time; updating it here keeps the session
4796
5183
  // record's title in sync with what was broadcast to clients so a
@@ -4843,6 +5230,7 @@ var SessionManager = class {
4843
5230
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
4844
5231
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
4845
5232
  ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
5233
+ ...update.agentModels !== void 0 ? { agentModels: update.agentModels } : {},
4846
5234
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4847
5235
  });
4848
5236
  });
@@ -4944,6 +5332,18 @@ function mergeForPersistence(session, existing) {
4944
5332
  return out;
4945
5333
  }) : void 0;
4946
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;
4947
5347
  return recordFromMemorySession({
4948
5348
  sessionId: session.sessionId,
4949
5349
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -4960,6 +5360,7 @@ function mergeForPersistence(session, existing) {
4960
5360
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
4961
5361
  agentCommands,
4962
5362
  agentModes,
5363
+ agentModels,
4963
5364
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
4964
5365
  });
4965
5366
  }
@@ -5025,6 +5426,40 @@ function asString(value) {
5025
5426
  function nonEmptyOrUndefined(arr) {
5026
5427
  return arr.length > 0 ? arr : void 0;
5027
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
+ }
5028
5463
  function extractInitialModes(result) {
5029
5464
  const direct = parseModesList(result.availableModes);
5030
5465
  if (direct.length > 0) {
@@ -6247,8 +6682,55 @@ function mapToolCallUpdate(u) {
6247
6682
  if (status !== void 0) {
6248
6683
  event.status = status;
6249
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
+ }
6250
6694
  return event;
6251
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
+ }
6252
6734
  function mapPlan(u) {
6253
6735
  const entries = u.entries;
6254
6736
  if (!Array.isArray(entries)) {
@@ -6629,6 +7111,35 @@ function registerSessionRoutes(app, manager, defaults) {
6629
7111
  }
6630
7112
  reply.code(204).send();
6631
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
+ });
6632
7143
  app.delete("/v1/sessions/:id", async (request, reply) => {
6633
7144
  const raw = request.params.id;
6634
7145
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -7216,7 +7727,8 @@ function registerAcpWsEndpoint(app, deps) {
7216
7727
  mcpServers: params.mcpServers,
7217
7728
  title: hydraMeta.name,
7218
7729
  agentArgs: hydraMeta.agentArgs,
7219
- model: hydraMeta.model
7730
+ model: hydraMeta.model,
7731
+ onInstallProgress: makeInstallProgressForwarder(connection)
7220
7732
  });
7221
7733
  const client = bindClientToSession(connection, session, state);
7222
7734
  const { entries: replay } = await session.attach(client, "full");
@@ -7232,6 +7744,7 @@ function registerAcpWsEndpoint(app, deps) {
7232
7744
  })();
7233
7745
  });
7234
7746
  const modesPayload = buildModesPayload(session);
7747
+ const modelsPayload = buildModelsPayload(session);
7235
7748
  return {
7236
7749
  sessionId: session.sessionId,
7237
7750
  // session/new is implicitly an attach; mirror session/attach's
@@ -7240,6 +7753,7 @@ function registerAcpWsEndpoint(app, deps) {
7240
7753
  // events without an extra round-trip.
7241
7754
  clientId: client.clientId,
7242
7755
  ...modesPayload ? { modes: modesPayload } : {},
7756
+ ...modelsPayload ? { models: modelsPayload } : {},
7243
7757
  _meta: buildResponseMeta(session)
7244
7758
  };
7245
7759
  });
@@ -7275,7 +7789,10 @@ function registerAcpWsEndpoint(app, deps) {
7275
7789
  err.code = JsonRpcErrorCodes.SessionNotFound;
7276
7790
  throw err;
7277
7791
  }
7278
- session = await deps.manager.resurrect(resurrectParams);
7792
+ session = await deps.manager.resurrect({
7793
+ ...resurrectParams,
7794
+ onInstallProgress: makeInstallProgressForwarder(connection)
7795
+ });
7279
7796
  }
7280
7797
  const client = bindClientToSession(
7281
7798
  connection,
@@ -7301,6 +7818,7 @@ function registerAcpWsEndpoint(app, deps) {
7301
7818
  }
7302
7819
  session.replayPendingPermissions(client);
7303
7820
  const modesPayload = buildModesPayload(session);
7821
+ const modelsPayload = buildModelsPayload(session);
7304
7822
  return {
7305
7823
  sessionId: session.sessionId,
7306
7824
  clientId: client.clientId,
@@ -7312,6 +7830,7 @@ function registerAcpWsEndpoint(app, deps) {
7312
7830
  historyPolicy: appliedPolicy,
7313
7831
  replayed: replay.length,
7314
7832
  ...modesPayload ? { modes: modesPayload } : {},
7833
+ ...modelsPayload ? { models: modelsPayload } : {},
7315
7834
  _meta: buildResponseMeta(session)
7316
7835
  };
7317
7836
  });
@@ -7467,15 +7986,39 @@ function registerAcpWsEndpoint(app, deps) {
7467
7986
  }
7468
7987
  session.replayPendingPermissions(client);
7469
7988
  const modesPayload = buildModesPayload(session);
7989
+ const modelsPayload = buildModelsPayload(session);
7470
7990
  return {
7471
7991
  sessionId: session.sessionId,
7472
7992
  // Same as session/new: include clientId so the deferred-echo
7473
7993
  // path in queue-aware clients can recognize own broadcasts.
7474
7994
  clientId: client.clientId,
7475
7995
  ...modesPayload ? { modes: modesPayload } : {},
7996
+ ...modelsPayload ? { models: modelsPayload } : {},
7476
7997
  _meta: buildResponseMeta(session)
7477
7998
  };
7478
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
+ });
7479
8022
  connection.setDefaultHandler(async (rawParams, method) => {
7480
8023
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
7481
8024
  const err = new Error(`Method not found: ${method}`);
@@ -7498,6 +8041,26 @@ function registerAcpWsEndpoint(app, deps) {
7498
8041
  });
7499
8042
  });
7500
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
+ }
7501
8064
  function buildModesPayload(session) {
7502
8065
  const modes = session.availableModes();
7503
8066
  if (modes.length === 0) {
@@ -7518,6 +8081,94 @@ function buildModesPayload(session) {
7518
8081
  const currentModeId = session.currentMode ?? modes[0].id;
7519
8082
  return { currentModeId, availableModes };
7520
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
+ }
7521
8172
  function buildResponseMeta(session) {
7522
8173
  const ours = {
7523
8174
  upstreamSessionId: session.upstreamSessionId,
@@ -7547,6 +8198,10 @@ function buildResponseMeta(session) {
7547
8198
  if (modes.length > 0) {
7548
8199
  ours.availableModes = modes;
7549
8200
  }
8201
+ const models = session.availableModels();
8202
+ if (models.length > 0) {
8203
+ ours.availableModels = models;
8204
+ }
7550
8205
  if (session.turnStartedAt !== void 0) {
7551
8206
  ours.turnStartedAt = session.turnStartedAt;
7552
8207
  }