@hydra-acp/cli 0.1.25 → 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/cli.js CHANGED
@@ -483,12 +483,35 @@ function extractHydraMeta(meta) {
483
483
  out.availableModes = modes;
484
484
  }
485
485
  }
486
+ if (Array.isArray(obj.availableModels)) {
487
+ const models = [];
488
+ for (const raw of obj.availableModels) {
489
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
490
+ continue;
491
+ }
492
+ const m = raw;
493
+ if (typeof m.modelId !== "string") {
494
+ continue;
495
+ }
496
+ const model = { modelId: m.modelId };
497
+ if (typeof m.name === "string") {
498
+ model.name = m.name;
499
+ }
500
+ if (typeof m.description === "string") {
501
+ model.description = m.description;
502
+ }
503
+ models.push(model);
504
+ }
505
+ if (models.length > 0) {
506
+ out.availableModels = models;
507
+ }
508
+ }
486
509
  return out;
487
510
  }
488
511
  function mergeMeta(passthrough, ours) {
489
512
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
490
513
  }
491
- var ACP_PROTOCOL_VERSION, JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, PromptOriginatorSchema, PromptQueueAddedParams, PromptQueueUpdatedParams, PromptQueueRemovedParams, CancelPromptParams, CancelPromptResult, UpdatePromptParams, UpdatePromptResult, AmendPromptParams, AmendPromptResult, PromptAmendedParams, ProxyInitializeParams;
514
+ var ACP_PROTOCOL_VERSION, JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, PromptOriginatorSchema, PromptQueueAddedParams, PromptQueueUpdatedParams, PromptQueueRemovedParams, CancelPromptParams, CancelPromptResult, UpdatePromptParams, UpdatePromptResult, AmendPromptParams, AmendPromptResult, PromptAmendedParams, AgentInstallProgressParams, AGENT_INSTALL_PROGRESS_METHOD, ProxyInitializeParams;
492
515
  var init_types = __esm({
493
516
  "src/acp/types.ts"() {
494
517
  "use strict";
@@ -682,6 +705,23 @@ var init_types = __esm({
682
705
  originator: PromptOriginatorSchema,
683
706
  amendedAt: z3.number()
684
707
  });
708
+ AgentInstallProgressParams = z3.object({
709
+ agentId: z3.string(),
710
+ version: z3.string(),
711
+ source: z3.enum(["binary", "npm"]),
712
+ phase: z3.enum([
713
+ "download_start",
714
+ "download_progress",
715
+ "download_done",
716
+ "extract",
717
+ "install_start",
718
+ "installed"
719
+ ]),
720
+ receivedBytes: z3.number().optional(),
721
+ totalBytes: z3.number().optional(),
722
+ packageSpec: z3.string().optional()
723
+ });
724
+ AGENT_INSTALL_PROGRESS_METHOD = "hydra-acp/agent_install_progress";
685
725
  ProxyInitializeParams = z3.object({
686
726
  protocolVersion: z3.number().optional(),
687
727
  proxyInfo: z3.object({
@@ -1020,6 +1060,42 @@ function sameAdvertisedModes(a, b) {
1020
1060
  }
1021
1061
  return true;
1022
1062
  }
1063
+ function sameAdvertisedModels(a, b) {
1064
+ if (a.length !== b.length) {
1065
+ return false;
1066
+ }
1067
+ for (let i = 0; i < a.length; i++) {
1068
+ if (a[i]?.modelId !== b[i]?.modelId || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
1069
+ return false;
1070
+ }
1071
+ }
1072
+ return true;
1073
+ }
1074
+ function parseModelsList(list) {
1075
+ if (!Array.isArray(list)) {
1076
+ return [];
1077
+ }
1078
+ const out = [];
1079
+ for (const raw of list) {
1080
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1081
+ continue;
1082
+ }
1083
+ const r = raw;
1084
+ 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;
1085
+ if (!modelId) {
1086
+ continue;
1087
+ }
1088
+ const model = { modelId };
1089
+ if (typeof r.name === "string" && r.name.length > 0) {
1090
+ model.name = r.name;
1091
+ }
1092
+ if (typeof r.description === "string" && r.description.length > 0) {
1093
+ model.description = r.description;
1094
+ }
1095
+ out.push(model);
1096
+ }
1097
+ return out;
1098
+ }
1023
1099
  function extractAdvertisedModes(params) {
1024
1100
  const obj = params ?? {};
1025
1101
  const update = obj.update ?? {};
@@ -1306,11 +1382,19 @@ var init_session = __esm({
1306
1382
  // Last available_modes_update we observed from the agent. Same
1307
1383
  // pattern as commands: cache, persist, broadcast on change.
1308
1384
  agentAdvertisedModes = [];
1385
+ // Last availableModels payload we observed (from current_model_update,
1386
+ // a session/new / session/load response, or — for opencode — a
1387
+ // config_option_update where configOptions[i].id === "model").
1388
+ // Cached so a mid-session attach can synthesize a model picker
1389
+ // snapshot, and so session/set_model can validate the requested id
1390
+ // against what the agent claims to support.
1391
+ agentAdvertisedModels = [];
1309
1392
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1310
1393
  // to mirror changes into meta.json so cold-resurrect attaches can
1311
1394
  // surface the latest snapshot via the attach response _meta.
1312
1395
  agentCommandsHandlers = [];
1313
1396
  agentModesHandlers = [];
1397
+ agentModelsHandlers = [];
1314
1398
  modelHandlers = [];
1315
1399
  modeHandlers = [];
1316
1400
  usageHandlers = [];
@@ -1346,6 +1430,9 @@ var init_session = __esm({
1346
1430
  if (init.agentModes && init.agentModes.length > 0) {
1347
1431
  this.agentAdvertisedModes = [...init.agentModes];
1348
1432
  }
1433
+ if (init.agentModels && init.agentModels.length > 0) {
1434
+ this.agentAdvertisedModels = [...init.agentModels];
1435
+ }
1349
1436
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1350
1437
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1351
1438
  this.logger = init.logger;
@@ -1383,6 +1470,23 @@ var init_session = __esm({
1383
1470
  }
1384
1471
  });
1385
1472
  }
1473
+ // Re-broadcast our cached availableModels via current_model_update.
1474
+ // Spec shape: { currentModel, availableModels } — we only include the
1475
+ // currentModel field when we know it, so this broadcast can also fire
1476
+ // model-list updates standalone before any current model is set.
1477
+ broadcastAvailableModels() {
1478
+ const update = {
1479
+ sessionUpdate: "current_model_update",
1480
+ availableModels: [...this.agentAdvertisedModels]
1481
+ };
1482
+ if (this.currentModel !== void 0 && this.currentModel.length > 0) {
1483
+ update.currentModel = this.currentModel;
1484
+ }
1485
+ this.recordAndBroadcast("session/update", {
1486
+ sessionId: this.upstreamSessionId,
1487
+ update
1488
+ });
1489
+ }
1386
1490
  // Register session/update, session/request_permission, and onExit
1387
1491
  // handlers on an agent connection. Re-run on every /hydra agent so
1388
1492
  // the new agent is plumbed identically. The exit handler's identity
@@ -1413,6 +1517,10 @@ var init_session = __esm({
1413
1517
  this.recordAndBroadcast("session/update", params);
1414
1518
  return;
1415
1519
  }
1520
+ if (this.maybeApplyAgentConfigOption(params)) {
1521
+ this.recordAndBroadcast("session/update", params);
1522
+ return;
1523
+ }
1416
1524
  if (this.maybeApplyAgentUsage(params)) {
1417
1525
  this.recordAndBroadcast("session/update", params);
1418
1526
  return;
@@ -1561,16 +1669,19 @@ var init_session = __esm({
1561
1669
  recordedAt
1562
1670
  });
1563
1671
  }
1564
- if (this.currentModel !== void 0 && this.currentModel.length > 0) {
1672
+ if (this.currentModel !== void 0 && this.currentModel.length > 0 || this.agentAdvertisedModels.length > 0) {
1673
+ const update = {
1674
+ sessionUpdate: "current_model_update"
1675
+ };
1676
+ if (this.currentModel !== void 0 && this.currentModel.length > 0) {
1677
+ update.currentModel = this.currentModel;
1678
+ }
1679
+ if (this.agentAdvertisedModels.length > 0) {
1680
+ update.availableModels = [...this.agentAdvertisedModels];
1681
+ }
1565
1682
  out.push({
1566
1683
  method: "session/update",
1567
- params: {
1568
- sessionId,
1569
- update: {
1570
- sessionUpdate: "current_model_update",
1571
- currentModel: this.currentModel
1572
- }
1573
- },
1684
+ params: { sessionId, update },
1574
1685
  recordedAt
1575
1686
  });
1576
1687
  }
@@ -2205,6 +2316,18 @@ var init_session = __esm({
2205
2316
  onTitleChange(handler) {
2206
2317
  this.titleHandlers.push(handler);
2207
2318
  }
2319
+ // External entry point for retitling a live session from outside the
2320
+ // ACP slash-command path (e.g. PATCH /v1/sessions/:id from the picker).
2321
+ // Goes through the same enqueuePrompt path as /hydra title so it
2322
+ // serializes after any in-flight turn and shares broadcast/persistence.
2323
+ retitle(title) {
2324
+ return this.runTitleCommand(title);
2325
+ }
2326
+ // External entry point for the LLM-regen title path (T in the picker,
2327
+ // equivalent to bare /hydra title with no arg).
2328
+ retitleFromAgent() {
2329
+ return this.runTitleCommand("");
2330
+ }
2208
2331
  // Update the canonical title and broadcast a session_info_update to
2209
2332
  // every attached client. Clients that already speak the spec's
2210
2333
  // session_info_update need no hydra-specific wiring to pick this up.
@@ -2252,12 +2375,19 @@ var init_session = __esm({
2252
2375
  // Apply an agent-emitted current_model_update. Returns true if the
2253
2376
  // notification was a model update (caller still needs to broadcast
2254
2377
  // it). Returns false otherwise so the caller can try the next kind.
2378
+ // current_model_update can carry availableModels in the same payload
2379
+ // (per ACP spec); we cache that list too so session/set_model can
2380
+ // validate against it.
2255
2381
  maybeApplyAgentModel(params) {
2256
2382
  const obj = params ?? {};
2257
2383
  const update = obj.update ?? {};
2258
2384
  if (update.sessionUpdate !== "current_model_update") {
2259
2385
  return false;
2260
2386
  }
2387
+ const models = parseModelsList(update.availableModels);
2388
+ if (models.length > 0) {
2389
+ this.setAgentAdvertisedModels(models);
2390
+ }
2261
2391
  const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
2262
2392
  if (raw === void 0) {
2263
2393
  return true;
@@ -2275,6 +2405,55 @@ var init_session = __esm({
2275
2405
  }
2276
2406
  return true;
2277
2407
  }
2408
+ // Apply an opencode-style config_option_update. opencode emits this
2409
+ // (not the spec-shaped current_model_update / available_models_update)
2410
+ // to carry both the current model and the list of available models.
2411
+ // The payload is `configOptions: [{ id: "model", currentValue, options:
2412
+ // [{ value, name }] }, ...]`. We harvest only the entry whose id is
2413
+ // "model" — other ids ("mode", "effort", etc.) are opencode-internal
2414
+ // and not consumed by hydra. Returns true when we recognized and
2415
+ // handled the notification so the wireAgent loop can stop trying
2416
+ // further extractors (the broadcast still fires; clients that grok
2417
+ // config_option_update render it directly).
2418
+ maybeApplyAgentConfigOption(params) {
2419
+ const obj = params ?? {};
2420
+ const update = obj.update ?? {};
2421
+ if (update.sessionUpdate !== "config_option_update") {
2422
+ return false;
2423
+ }
2424
+ const list = update.configOptions;
2425
+ if (!Array.isArray(list)) {
2426
+ return true;
2427
+ }
2428
+ for (const raw of list) {
2429
+ if (!raw || typeof raw !== "object") {
2430
+ continue;
2431
+ }
2432
+ const opt = raw;
2433
+ if (opt.id !== "model") {
2434
+ continue;
2435
+ }
2436
+ const models = parseModelsList(opt.options);
2437
+ if (models.length > 0) {
2438
+ this.setAgentAdvertisedModels(models);
2439
+ }
2440
+ const cv = opt.currentValue;
2441
+ if (typeof cv === "string") {
2442
+ const trimmed = cv.trim();
2443
+ if (trimmed && trimmed !== this.currentModel) {
2444
+ this.currentModel = trimmed;
2445
+ for (const handler of this.modelHandlers) {
2446
+ try {
2447
+ handler(trimmed);
2448
+ } catch {
2449
+ }
2450
+ }
2451
+ }
2452
+ }
2453
+ break;
2454
+ }
2455
+ return true;
2456
+ }
2278
2457
  maybeApplyAgentMode(params) {
2279
2458
  const obj = params ?? {};
2280
2459
  const update = obj.update ?? {};
@@ -2373,6 +2552,20 @@ var init_session = __esm({
2373
2552
  }
2374
2553
  this.broadcastAvailableModes();
2375
2554
  }
2555
+ setAgentAdvertisedModels(models) {
2556
+ if (sameAdvertisedModels(this.agentAdvertisedModels, models)) {
2557
+ this.broadcastAvailableModels();
2558
+ return;
2559
+ }
2560
+ this.agentAdvertisedModels = models;
2561
+ for (const handler of this.agentModelsHandlers) {
2562
+ try {
2563
+ handler(models);
2564
+ } catch {
2565
+ }
2566
+ }
2567
+ this.broadcastAvailableModels();
2568
+ }
2376
2569
  // Subscribe to snapshot-state updates. SessionManager wires these to
2377
2570
  // persist the new value into meta.json so cold resurrect can restore
2378
2571
  // them via the attach response _meta.
@@ -2382,6 +2575,9 @@ var init_session = __esm({
2382
2575
  onAgentModesChange(handler) {
2383
2576
  this.agentModesHandlers.push(handler);
2384
2577
  }
2578
+ onAgentModelsChange(handler) {
2579
+ this.agentModelsHandlers.push(handler);
2580
+ }
2385
2581
  onModelChange(handler) {
2386
2582
  this.modelHandlers.push(handler);
2387
2583
  }
@@ -2407,6 +2603,15 @@ var init_session = __esm({
2407
2603
  availableModes() {
2408
2604
  return [...this.agentAdvertisedModes];
2409
2605
  }
2606
+ // The agent's advertised models list. Used by acp-ws.ts's dedicated
2607
+ // session/set_model handler to validate the requested modelId before
2608
+ // forwarding to the agent (catches cross-agent set_model storms from
2609
+ // clients that assume a different agent is on the other end). When
2610
+ // the agent never advertised any models, returns [] and the
2611
+ // set_model handler falls back to pass-through.
2612
+ availableModels() {
2613
+ return [...this.agentAdvertisedModels];
2614
+ }
2410
2615
  // Pick up an agent-emitted session_info_update and store its title
2411
2616
  // as our canonical record. The notification is also forwarded to
2412
2617
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -2554,6 +2759,12 @@ var init_session = __esm({
2554
2759
  this.agentMeta = fresh.agentMeta;
2555
2760
  this.agentAdvertisedCommands = [];
2556
2761
  this.broadcastMergedCommands();
2762
+ if (this.agentAdvertisedModels.length > 0) {
2763
+ this.setAgentAdvertisedModels([]);
2764
+ }
2765
+ if (this.agentAdvertisedModes.length > 0) {
2766
+ this.setAgentAdvertisedModes([]);
2767
+ }
2557
2768
  await oldAgent.kill().catch(() => void 0);
2558
2769
  if (transcript) {
2559
2770
  await this.runInternalPrompt(transcript).catch(() => void 0);
@@ -3120,11 +3331,12 @@ function recordFromMemorySession(args) {
3120
3331
  currentUsage: args.currentUsage,
3121
3332
  agentCommands: args.agentCommands,
3122
3333
  agentModes: args.agentModes,
3334
+ agentModels: args.agentModels,
3123
3335
  createdAt: args.createdAt ?? now,
3124
3336
  updatedAt: args.updatedAt ?? now
3125
3337
  };
3126
3338
  }
3127
- var HYDRA_ID_ALPHABET2, generateRawId, HYDRA_LINEAGE_PREFIX, PersistedAgentCommand, PersistedAgentMode, PersistedUsage, SessionRecord, SESSION_ID_PATTERN, SessionStore;
3339
+ var HYDRA_ID_ALPHABET2, generateRawId, HYDRA_LINEAGE_PREFIX, PersistedAgentCommand, PersistedAgentMode, PersistedAgentModel, PersistedUsage, SessionRecord, SESSION_ID_PATTERN, SessionStore;
3128
3340
  var init_session_store = __esm({
3129
3341
  "src/core/session-store.ts"() {
3130
3342
  "use strict";
@@ -3141,6 +3353,11 @@ var init_session_store = __esm({
3141
3353
  name: z4.string().optional(),
3142
3354
  description: z4.string().optional()
3143
3355
  });
3356
+ PersistedAgentModel = z4.object({
3357
+ modelId: z4.string(),
3358
+ name: z4.string().optional(),
3359
+ description: z4.string().optional()
3360
+ });
3144
3361
  PersistedUsage = z4.object({
3145
3362
  used: z4.number().optional(),
3146
3363
  size: z4.number().optional(),
@@ -3187,6 +3404,7 @@ var init_session_store = __esm({
3187
3404
  currentUsage: PersistedUsage.optional(),
3188
3405
  agentCommands: z4.array(PersistedAgentCommand).optional(),
3189
3406
  agentModes: z4.array(PersistedAgentMode).optional(),
3407
+ agentModels: z4.array(PersistedAgentModel).optional(),
3190
3408
  createdAt: z4.string(),
3191
3409
  updatedAt: z4.string()
3192
3410
  });
@@ -3682,8 +3900,55 @@ function mapToolCallUpdate(u) {
3682
3900
  if (status !== void 0) {
3683
3901
  event.status = status;
3684
3902
  }
3903
+ if (status === "failed") {
3904
+ const errorText = extractToolFailureText(u);
3905
+ if (errorText !== null) {
3906
+ event.errorText = errorText;
3907
+ }
3908
+ if (isUpstreamInterrupted(u, errorText)) {
3909
+ event.upstreamInterrupted = true;
3910
+ }
3911
+ }
3685
3912
  return event;
3686
3913
  }
3914
+ function extractToolFailureText(u) {
3915
+ const content = u.content;
3916
+ if (Array.isArray(content)) {
3917
+ for (const block of content) {
3918
+ if (!block || typeof block !== "object") {
3919
+ continue;
3920
+ }
3921
+ const b = block;
3922
+ const text = extractContentText(b.content);
3923
+ if (text !== null && text.length > 0) {
3924
+ return text;
3925
+ }
3926
+ }
3927
+ }
3928
+ const rawOutput = u.rawOutput;
3929
+ if (rawOutput && typeof rawOutput === "object") {
3930
+ const err = rawOutput.error;
3931
+ if (typeof err === "string" && err.length > 0) {
3932
+ return sanitizeWireText(err);
3933
+ }
3934
+ }
3935
+ return null;
3936
+ }
3937
+ function isUpstreamInterrupted(u, errorText) {
3938
+ const rawOutput = u.rawOutput;
3939
+ if (rawOutput && typeof rawOutput === "object") {
3940
+ const meta = rawOutput.metadata;
3941
+ if (meta && typeof meta === "object") {
3942
+ if (meta.interrupted === true) {
3943
+ return true;
3944
+ }
3945
+ }
3946
+ }
3947
+ if (errorText !== null && errorText.toLowerCase().includes("tool execution aborted")) {
3948
+ return true;
3949
+ }
3950
+ return false;
3951
+ }
3687
3952
  function mapPlan(u) {
3688
3953
  const entries = u.entries;
3689
3954
  if (!Array.isArray(entries)) {
@@ -5069,6 +5334,34 @@ async function killSession(config, serviceToken, id, fetchImpl = fetch) {
5069
5334
  throw new Error(`daemon returned HTTP ${response.status}`);
5070
5335
  }
5071
5336
  }
5337
+ async function renameSession(config, serviceToken, id, title, fetchImpl = fetch) {
5338
+ const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
5339
+ const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
5340
+ method: "PATCH",
5341
+ headers: {
5342
+ Authorization: `Bearer ${serviceToken}`,
5343
+ "Content-Type": "application/json"
5344
+ },
5345
+ body: JSON.stringify({ title })
5346
+ });
5347
+ if (!response.ok && response.status !== 204 && response.status !== 404) {
5348
+ throw new Error(`daemon returned HTTP ${response.status}`);
5349
+ }
5350
+ }
5351
+ async function regenSessionTitle(config, serviceToken, id, fetchImpl = fetch) {
5352
+ const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
5353
+ const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
5354
+ method: "PATCH",
5355
+ headers: {
5356
+ Authorization: `Bearer ${serviceToken}`,
5357
+ "Content-Type": "application/json"
5358
+ },
5359
+ body: JSON.stringify({ regen: true })
5360
+ });
5361
+ if (!response.ok && response.status !== 202 && response.status !== 204 && response.status !== 404 && response.status !== 409) {
5362
+ throw new Error(`daemon returned HTTP ${response.status}`);
5363
+ }
5364
+ }
5072
5365
  async function deleteSession(config, serviceToken, id, fetchImpl = fetch) {
5073
5366
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
5074
5367
  const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
@@ -5139,6 +5432,7 @@ async function pickSession(term, opts) {
5139
5432
  let cwdOnly = false;
5140
5433
  let mode = "normal";
5141
5434
  let pendingAction = null;
5435
+ let renameBuffer = "";
5142
5436
  let transientStatus = null;
5143
5437
  let termHeight = readTermHeight(term);
5144
5438
  let termWidth = readTermWidth(term);
@@ -5247,6 +5541,12 @@ async function pickSession(term, opts) {
5247
5541
  term.dim.noFormat(` working on ${shortId2(pendingAction.sessionId)}\u2026`);
5248
5542
  return;
5249
5543
  }
5544
+ if (mode === "rename" && pendingAction) {
5545
+ term.brightYellow.noFormat(` title: ${renameBuffer}`);
5546
+ term.bgBrightYellow(" ");
5547
+ term.dim.noFormat(" Enter saves \xB7 Esc cancels");
5548
+ return;
5549
+ }
5250
5550
  if (transientStatus !== null) {
5251
5551
  term.dim.noFormat(` ${transientStatus}`);
5252
5552
  return;
@@ -5364,6 +5664,37 @@ async function pickSession(term, opts) {
5364
5664
  renderFromScratch();
5365
5665
  }
5366
5666
  };
5667
+ const performRename = async (title) => {
5668
+ if (!pendingAction) {
5669
+ return;
5670
+ }
5671
+ const target = pendingAction;
5672
+ mode = "busy";
5673
+ paintIndicator();
5674
+ try {
5675
+ await renameSession(opts.config, opts.serviceToken, target.sessionId, title);
5676
+ mode = "normal";
5677
+ pendingAction = null;
5678
+ renameBuffer = "";
5679
+ await refresh(target.sessionId);
5680
+ } catch (err) {
5681
+ mode = "normal";
5682
+ pendingAction = null;
5683
+ renameBuffer = "";
5684
+ transientStatus = `rename failed: ${err.message}`;
5685
+ paintIndicator();
5686
+ }
5687
+ };
5688
+ const performRegen = async (target) => {
5689
+ try {
5690
+ await regenSessionTitle(opts.config, opts.serviceToken, target.sessionId);
5691
+ transientStatus = "title regen queued (press r to refresh)";
5692
+ paintIndicator();
5693
+ } catch (err) {
5694
+ transientStatus = `regen failed: ${err.message}`;
5695
+ paintIndicator();
5696
+ }
5697
+ };
5367
5698
  const performAction = async (kind) => {
5368
5699
  if (!pendingAction) {
5369
5700
  return;
@@ -5436,6 +5767,52 @@ async function pickSession(term, opts) {
5436
5767
  renderFromScratch();
5437
5768
  return;
5438
5769
  }
5770
+ if (mode === "rename") {
5771
+ if (name === "ENTER" || name === "KP_ENTER") {
5772
+ const trimmed = renameBuffer.trim();
5773
+ if (trimmed.length === 0) {
5774
+ mode = "normal";
5775
+ pendingAction = null;
5776
+ renameBuffer = "";
5777
+ paintIndicator();
5778
+ return;
5779
+ }
5780
+ void performRename(trimmed);
5781
+ return;
5782
+ }
5783
+ if (name === "ESCAPE" || name === "CTRL_C") {
5784
+ mode = "normal";
5785
+ pendingAction = null;
5786
+ renameBuffer = "";
5787
+ paintIndicator();
5788
+ return;
5789
+ }
5790
+ if (name === "BACKSPACE") {
5791
+ if (renameBuffer.length > 0) {
5792
+ renameBuffer = renameBuffer.slice(0, -1);
5793
+ paintIndicator();
5794
+ }
5795
+ return;
5796
+ }
5797
+ if (name === "CTRL_U") {
5798
+ renameBuffer = "";
5799
+ paintIndicator();
5800
+ return;
5801
+ }
5802
+ if (name === "CTRL_W") {
5803
+ const trimmedRight = renameBuffer.replace(/\s+$/, "");
5804
+ const lastSpace = trimmedRight.lastIndexOf(" ");
5805
+ renameBuffer = lastSpace >= 0 ? trimmedRight.slice(0, lastSpace) : "";
5806
+ paintIndicator();
5807
+ return;
5808
+ }
5809
+ if (data?.isCharacter) {
5810
+ renameBuffer += name;
5811
+ paintIndicator();
5812
+ return;
5813
+ }
5814
+ return;
5815
+ }
5439
5816
  if (mode === "confirm-kill" || mode === "confirm-delete") {
5440
5817
  if (data?.isCharacter && (name === "y" || name === "Y")) {
5441
5818
  const kind = mode === "confirm-kill" ? "kill" : "delete";
@@ -5538,6 +5915,29 @@ async function pickSession(term, opts) {
5538
5915
  paintIndicator();
5539
5916
  return;
5540
5917
  }
5918
+ if (name === "t" && selectedIdx > 0) {
5919
+ const session = visible[selectedIdx - 1];
5920
+ if (!session) {
5921
+ return;
5922
+ }
5923
+ pendingAction = {
5924
+ sessionId: session.sessionId,
5925
+ cwd: session.cwd,
5926
+ status: session.status
5927
+ };
5928
+ renameBuffer = session.title ?? "";
5929
+ mode = "rename";
5930
+ paintIndicator();
5931
+ return;
5932
+ }
5933
+ if (name === "T" && selectedIdx > 0) {
5934
+ const session = visible[selectedIdx - 1];
5935
+ if (!session || session.status !== "live") {
5936
+ return;
5937
+ }
5938
+ void performRegen({ sessionId: session.sessionId });
5939
+ return;
5940
+ }
5541
5941
  if ((name === "d" || name === "D") && selectedIdx > 0) {
5542
5942
  const session = visible[selectedIdx - 1];
5543
5943
  if (!session) {
@@ -5668,6 +6068,8 @@ var init_picker = __esm({
5668
6068
  null,
5669
6069
  ["k", "kill the selected live session"],
5670
6070
  ["d", "delete the selected cold session"],
6071
+ ["t", "retitle the selected session"],
6072
+ ["T", "regenerate title via agent (live session)"],
5671
6073
  null,
5672
6074
  ["c", "create new session"],
5673
6075
  ["?", "toggle this help"],
@@ -7886,11 +8288,16 @@ var init_screen = __esm({
7886
8288
  const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
7887
8289
  const right = this.bannerRightContent();
7888
8290
  const rightSig = right ? `${right.kind}|${right.text}` : "";
7889
- const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.currentMode ?? ""}|${this.banner.hint}|` + rightSig;
8291
+ const stalled = this.banner.status === "busy" && this.banner.stalled === true;
8292
+ const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${stalled ? "1" : "0"}|${this.banner.queued}|${this.scrollOffset}|${this.banner.currentMode ?? ""}|${this.banner.hint}|` + rightSig;
7890
8293
  this.paintRow(row, sig, () => {
7891
8294
  const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
7892
8295
  if (this.banner.status === "busy") {
7893
- this.term.brightYellow(`${dot} ${this.banner.status}`);
8296
+ if (stalled) {
8297
+ this.term.brightRed(`${dot} stalled`);
8298
+ } else {
8299
+ this.term.brightYellow(`${dot} ${this.banner.status}`);
8300
+ }
7894
8301
  if (elapsedStr) {
7895
8302
  this.term(" ").dim(elapsedStr);
7896
8303
  }
@@ -9267,8 +9674,29 @@ var init_completion = __esm({
9267
9674
  }
9268
9675
  });
9269
9676
 
9677
+ // src/tui/reconnect-state.ts
9678
+ function parseReattachResponse(result) {
9679
+ const out = {};
9680
+ if (!result || typeof result !== "object") {
9681
+ return out;
9682
+ }
9683
+ const r = result;
9684
+ if (typeof r.historyPolicy === "string") {
9685
+ out.appliedPolicy = r.historyPolicy;
9686
+ }
9687
+ if (typeof r.clientId === "string" && r.clientId.length > 0) {
9688
+ out.clientId = r.clientId;
9689
+ }
9690
+ return out;
9691
+ }
9692
+ var init_reconnect_state = __esm({
9693
+ "src/tui/reconnect-state.ts"() {
9694
+ "use strict";
9695
+ }
9696
+ });
9697
+
9270
9698
  // src/tui/format.ts
9271
- import chalk from "chalk";
9699
+ import chalk2 from "chalk";
9272
9700
  import { highlight, supportsLanguage } from "cli-highlight";
9273
9701
  function formatEvent(event) {
9274
9702
  switch (event.kind) {
@@ -9561,12 +9989,22 @@ function formatToolLine2(state) {
9561
9989
  } else {
9562
9990
  title = `${initial} \xB7 ${latest}`;
9563
9991
  }
9564
- return {
9565
- prefix: ` ${toolStatusIcon(state.status)} `,
9566
- prefixStyle: toolIconStyle(state.status),
9567
- body: title,
9568
- bodyStyle: toolStatusStyle(state.status)
9569
- };
9992
+ const lines = [
9993
+ {
9994
+ prefix: ` ${toolStatusIcon(state.status)} `,
9995
+ prefixStyle: toolIconStyle(state.status),
9996
+ body: title,
9997
+ bodyStyle: toolStatusStyle(state.status)
9998
+ }
9999
+ ];
10000
+ if (state.status === "failed" && state.errorText) {
10001
+ lines.push({
10002
+ prefix: " ",
10003
+ body: sanitizeSingleLine(state.errorText),
10004
+ bodyStyle: "tool-status-fail"
10005
+ });
10006
+ }
10007
+ return lines;
9570
10008
  }
9571
10009
  function toolStatusIcon(status) {
9572
10010
  switch (status) {
@@ -9665,7 +10103,8 @@ var highlightChalk, HIGHLIGHT_THEME;
9665
10103
  var init_format = __esm({
9666
10104
  "src/tui/format.ts"() {
9667
10105
  "use strict";
9668
- highlightChalk = new chalk.Instance({ level: 3 });
10106
+ init_render_update();
10107
+ highlightChalk = new chalk2.Instance({ level: 3 });
9669
10108
  HIGHLIGHT_THEME = {
9670
10109
  keyword: highlightChalk.blueBright,
9671
10110
  built_in: highlightChalk.cyan,
@@ -9727,8 +10166,9 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9727
10166
  term.grabInput(false);
9728
10167
  process.exit(0);
9729
10168
  }
9730
- const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
9731
- term.brightYellow(launchLabel)("\n");
10169
+ const launchLabelBase = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
10170
+ const installStatus = createInstallStatusLine(term, launchLabelBase);
10171
+ installStatus.write(launchLabelBase);
9732
10172
  const protocol = config.daemon.tls ? "wss" : "ws";
9733
10173
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
9734
10174
  const subprotocols = ["acp.v1", `hydra-acp-token.${serviceToken}`];
@@ -9754,6 +10194,13 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9754
10194
  });
9755
10195
  const conn = new JsonRpcConnection(stream);
9756
10196
  await stream.start();
10197
+ conn.onNotification(AGENT_INSTALL_PROGRESS_METHOD, (raw) => {
10198
+ const parsed = AgentInstallProgressParams.safeParse(raw);
10199
+ if (!parsed.success) {
10200
+ return;
10201
+ }
10202
+ installStatus.applyProgress(parsed.data);
10203
+ });
9757
10204
  let bufferedEvents = [];
9758
10205
  let applyRenderEvent = null;
9759
10206
  let teardownStarted = false;
@@ -9771,34 +10218,46 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9771
10218
  let currentHeadMessageId;
9772
10219
  let sessionBusySince = null;
9773
10220
  let sessionElapsedTimer = null;
10221
+ let lastUpdateAt = null;
10222
+ let upstreamInterruptedSeen = false;
9774
10223
  const adjustPendingTurns = (delta) => {
9775
10224
  const before = pendingTurns;
9776
10225
  pendingTurns = Math.max(0, pendingTurns + delta);
9777
10226
  const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
9778
10227
  if (before === 0 && pendingTurns > 0) {
9779
10228
  sessionBusySince = Date.now();
10229
+ lastUpdateAt = Date.now();
9780
10230
  dispatcherRef?.setTurnRunning(true);
9781
10231
  if (screenReady) {
9782
- screenRef.setBanner({ status: "busy", elapsedMs: 0 });
10232
+ screenRef.setBanner({ status: "busy", elapsedMs: 0, stalled: false });
9783
10233
  }
9784
10234
  if (sessionElapsedTimer === null && screenReady) {
9785
10235
  sessionElapsedTimer = setInterval(() => {
9786
10236
  if (sessionBusySince === null || screenRef === null) {
9787
10237
  return;
9788
10238
  }
9789
- screenRef.setBanner({ elapsedMs: Date.now() - sessionBusySince });
10239
+ const idleMs = lastUpdateAt === null ? 0 : Date.now() - lastUpdateAt;
10240
+ screenRef.setBanner({
10241
+ elapsedMs: Date.now() - sessionBusySince,
10242
+ stalled: idleMs >= STALL_THRESHOLD_MS
10243
+ });
9790
10244
  renderToolsBlock();
9791
10245
  }, 1e3);
9792
10246
  }
9793
10247
  } else if (before > 0 && pendingTurns === 0) {
9794
10248
  sessionBusySince = null;
10249
+ lastUpdateAt = null;
9795
10250
  dispatcherRef?.setTurnRunning(false);
9796
10251
  if (sessionElapsedTimer !== null) {
9797
10252
  clearInterval(sessionElapsedTimer);
9798
10253
  sessionElapsedTimer = null;
9799
10254
  }
9800
10255
  if (screenReady) {
9801
- screenRef.setBanner({ status: "ready", elapsedMs: void 0 });
10256
+ screenRef.setBanner({
10257
+ status: "ready",
10258
+ elapsedMs: void 0,
10259
+ stalled: false
10260
+ });
9802
10261
  }
9803
10262
  }
9804
10263
  void delta;
@@ -9819,6 +10278,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9819
10278
  const { update } = params ?? {};
9820
10279
  const event = mapUpdate(update);
9821
10280
  debugLogUpdate(update, event);
10281
+ lastUpdateAt = Date.now();
9822
10282
  const rawTag = update?.sessionUpdate;
9823
10283
  if (typeof rawTag === "string" && !STATE_UPDATE_KINDS2.has(rawTag)) {
9824
10284
  const u = update ?? {};
@@ -10388,6 +10848,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10388
10848
  };
10389
10849
  const sessionbarAgent = resolvedAgentId || agentInfoName || "?";
10390
10850
  const usage = { ...initialUsage ?? {} };
10851
+ installStatus.finalize();
10391
10852
  screen.start();
10392
10853
  screen.setSessionbar({
10393
10854
  agent: sessionbarAgent,
@@ -11270,7 +11731,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11270
11731
  for (const id of visibleIds) {
11271
11732
  const state = toolStates.get(id);
11272
11733
  if (state) {
11273
- lines.push(formatToolLine2(state));
11734
+ lines.push(...formatToolLine2(state));
11274
11735
  }
11275
11736
  }
11276
11737
  screen.upsertLines("tools", lines);
@@ -11281,7 +11742,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11281
11742
  toolsBlockStopReason = null;
11282
11743
  renderToolsBlock();
11283
11744
  };
11284
- const recordToolCall = (id, title, status) => {
11745
+ const recordToolCall = (id, title, status, errorText) => {
11285
11746
  const wasNew = !toolStates.has(id);
11286
11747
  const existing = toolStates.get(id);
11287
11748
  const state = existing ?? {
@@ -11298,6 +11759,9 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11298
11759
  if (!existing) {
11299
11760
  state.status = status ?? "pending";
11300
11761
  }
11762
+ if (errorText !== void 0) {
11763
+ state.errorText = errorText;
11764
+ }
11301
11765
  toolStates.set(id, state);
11302
11766
  if (wasNew) {
11303
11767
  if (toolsBlockStartedAt === null) {
@@ -11389,7 +11853,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11389
11853
  }
11390
11854
  if (event.kind === "tool-call") {
11391
11855
  closeAgentText();
11392
- recordToolCall(event.toolCallId, event.title, event.status);
11856
+ recordToolCall(event.toolCallId, event.title, event.status, void 0);
11393
11857
  renderToolsBlock();
11394
11858
  return;
11395
11859
  }
@@ -11404,7 +11868,15 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11404
11868
  }
11405
11869
  if (event.kind === "tool-call-update") {
11406
11870
  closeAgentText();
11407
- recordToolCall(event.toolCallId, event.title, event.status);
11871
+ recordToolCall(
11872
+ event.toolCallId,
11873
+ event.title,
11874
+ event.status,
11875
+ event.errorText
11876
+ );
11877
+ if (event.upstreamInterrupted) {
11878
+ upstreamInterruptedSeen = true;
11879
+ }
11408
11880
  renderToolsBlock();
11409
11881
  return;
11410
11882
  }
@@ -11418,7 +11890,10 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11418
11890
  if (event.kind === "turn-complete") {
11419
11891
  currentHeadMessageId = void 0;
11420
11892
  closeAgentText();
11421
- const effectiveStopReason = event.amended ? "amended" : event.stopReason;
11893
+ let effectiveStopReason = event.amended ? "amended" : event.stopReason;
11894
+ if (!event.amended && upstreamInterruptedSeen && (effectiveStopReason === void 0 || effectiveStopReason === "end_turn")) {
11895
+ effectiveStopReason = "error";
11896
+ }
11422
11897
  if (lastPlanEvent !== null && effectiveStopReason !== void 0 && effectiveStopReason !== "end_turn") {
11423
11898
  const lines = formatEvent({ ...lastPlanEvent, stopped: true });
11424
11899
  if (lines.length > 0) {
@@ -11448,6 +11923,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11448
11923
  toolsBlockEndedAt = null;
11449
11924
  toolsBlockStopReason = null;
11450
11925
  toolsExpanded = false;
11926
+ upstreamInterruptedSeen = false;
11451
11927
  screen.ensureSeparator();
11452
11928
  }
11453
11929
  };
@@ -11557,9 +12033,10 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11557
12033
  if (resp.error) {
11558
12034
  throw new Error(resp.error.message);
11559
12035
  }
11560
- const result = resp.result ?? {};
11561
- if (typeof result.historyPolicy === "string") {
11562
- appliedPolicy = result.historyPolicy;
12036
+ const fields = parseReattachResponse(resp.result);
12037
+ appliedPolicy = fields.appliedPolicy;
12038
+ if (fields.clientId !== void 0) {
12039
+ ownClientId = fields.clientId;
11563
12040
  }
11564
12041
  } catch (err) {
11565
12042
  attachErr = err;
@@ -11685,6 +12162,95 @@ function writeDebugLine(payload) {
11685
12162
  } catch {
11686
12163
  }
11687
12164
  }
12165
+ function createInstallStatusLine(term, baseLabel) {
12166
+ let finalized = false;
12167
+ let lastText = "";
12168
+ let osc94Active = false;
12169
+ const writeOsc94 = (state) => {
12170
+ if (finalized) {
12171
+ return;
12172
+ }
12173
+ if (state === 3 && osc94Active) {
12174
+ return;
12175
+ }
12176
+ if (state === 0 && !osc94Active) {
12177
+ return;
12178
+ }
12179
+ osc94Active = state === 3;
12180
+ process.stdout.write(`\x1B]9;4;${state}\x1B\\`);
12181
+ };
12182
+ const redraw = (text) => {
12183
+ if (finalized) {
12184
+ return;
12185
+ }
12186
+ process.stdout.write("\r");
12187
+ term.eraseLineAfter();
12188
+ term.brightYellow(text);
12189
+ lastText = text;
12190
+ };
12191
+ const formatProgressText = (event) => {
12192
+ const idVer = `${event.agentId}@${event.version}`;
12193
+ if (event.source === "npm") {
12194
+ if (event.phase === "install_start" || event.phase === "download_start") {
12195
+ return `${baseLabel} installing ${idVer} via npm\u2026`;
12196
+ }
12197
+ if (event.phase === "installed") {
12198
+ return `${baseLabel} ${idVer} installed`;
12199
+ }
12200
+ return `${baseLabel} installing ${idVer} via npm\u2026`;
12201
+ }
12202
+ if (event.phase === "download_start" || event.phase === "download_progress") {
12203
+ const received = event.receivedBytes ?? 0;
12204
+ const total = event.totalBytes ?? 0;
12205
+ const rxMb = (received / 1e6).toFixed(1);
12206
+ if (total > 0) {
12207
+ const totalMb = (total / 1e6).toFixed(1);
12208
+ const pct = Math.min(100, Math.floor(received / total * 100));
12209
+ return `${baseLabel} downloading ${idVer} ${rxMb}/${totalMb} MB (${pct}%)`;
12210
+ }
12211
+ return `${baseLabel} downloading ${idVer} ${rxMb} MB`;
12212
+ }
12213
+ if (event.phase === "download_done") {
12214
+ return `${baseLabel} downloaded ${idVer}, verifying\u2026`;
12215
+ }
12216
+ if (event.phase === "extract") {
12217
+ return `${baseLabel} extracting ${idVer}\u2026`;
12218
+ }
12219
+ if (event.phase === "installed") {
12220
+ return `${baseLabel} ${idVer} installed`;
12221
+ }
12222
+ return lastText || baseLabel;
12223
+ };
12224
+ return {
12225
+ write(text) {
12226
+ if (finalized) {
12227
+ return;
12228
+ }
12229
+ term.brightYellow(text);
12230
+ lastText = text;
12231
+ },
12232
+ applyProgress(event) {
12233
+ if (finalized) {
12234
+ return;
12235
+ }
12236
+ const isActive = event.phase === "download_start" || event.phase === "download_progress" || event.phase === "install_start" || event.phase === "extract" || event.phase === "download_done";
12237
+ if (isActive) {
12238
+ writeOsc94(3);
12239
+ } else if (event.phase === "installed") {
12240
+ writeOsc94(0);
12241
+ }
12242
+ redraw(formatProgressText(event));
12243
+ },
12244
+ finalize() {
12245
+ if (finalized) {
12246
+ return;
12247
+ }
12248
+ finalized = true;
12249
+ writeOsc94(0);
12250
+ process.stdout.write("\n");
12251
+ }
12252
+ };
12253
+ }
11688
12254
  function rotateIfBig(target) {
11689
12255
  try {
11690
12256
  const stat4 = statSync(target);
@@ -11695,7 +12261,7 @@ function rotateIfBig(target) {
11695
12261
  } catch {
11696
12262
  }
11697
12263
  }
11698
- var HELP_ENTRIES_TAIL, logMaxBytes;
12264
+ var STALL_THRESHOLD_MS, HELP_ENTRIES_TAIL, logMaxBytes;
11699
12265
  var init_app = __esm({
11700
12266
  "src/tui/app.ts"() {
11701
12267
  "use strict";
@@ -11717,8 +12283,10 @@ var init_app = __esm({
11717
12283
  init_attachments();
11718
12284
  init_clipboard();
11719
12285
  init_completion();
12286
+ init_reconnect_state();
11720
12287
  init_render_update();
11721
12288
  init_format();
12289
+ STALL_THRESHOLD_MS = 12e4;
11722
12290
  HELP_ENTRIES_TAIL = [
11723
12291
  ["Alt+Enter", "newline in prompt"],
11724
12292
  ["Shift+Tab", "cycle agent modes (plan / accept-edits / etc.)"],
@@ -11880,6 +12448,7 @@ init_config();
11880
12448
  init_service_token();
11881
12449
  import * as fsp7 from "fs/promises";
11882
12450
  import { setTimeout as sleep2 } from "timers/promises";
12451
+ import chalk from "chalk";
11883
12452
 
11884
12453
  // src/daemon/server.ts
11885
12454
  init_config();
@@ -11948,8 +12517,10 @@ async function ensureBinary(args) {
11948
12517
  }
11949
12518
  await downloadAndExtract({
11950
12519
  agentId: args.agentId,
12520
+ version: args.version,
11951
12521
  archiveUrl: args.target.archive,
11952
- installDir
12522
+ installDir,
12523
+ onProgress: args.onProgress
11953
12524
  });
11954
12525
  if (!await fileExists(cmdPath)) {
11955
12526
  throw new Error(
@@ -11969,9 +12540,16 @@ async function downloadAndExtract(args) {
11969
12540
  const archivePath = await downloadTo({
11970
12541
  url: args.archiveUrl,
11971
12542
  dir: tempDir,
11972
- agentId: args.agentId
12543
+ agentId: args.agentId,
12544
+ version: args.version,
12545
+ onProgress: args.onProgress
11973
12546
  });
11974
12547
  logSink(`hydra-acp: extracting ${args.agentId}`);
12548
+ safeEmit(args.onProgress, {
12549
+ phase: "extract",
12550
+ agentId: args.agentId,
12551
+ version: args.version
12552
+ });
11975
12553
  await extract(archivePath, tempDir);
11976
12554
  await fsp.unlink(archivePath).catch(() => void 0);
11977
12555
  try {
@@ -11982,16 +12560,35 @@ async function downloadAndExtract(args) {
11982
12560
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(
11983
12561
  () => void 0
11984
12562
  );
12563
+ safeEmit(args.onProgress, {
12564
+ phase: "installed",
12565
+ agentId: args.agentId,
12566
+ version: args.version
12567
+ });
11985
12568
  return;
11986
12569
  }
11987
12570
  throw err;
11988
12571
  }
11989
12572
  logSink(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
12573
+ safeEmit(args.onProgress, {
12574
+ phase: "installed",
12575
+ agentId: args.agentId,
12576
+ version: args.version
12577
+ });
11990
12578
  } catch (err) {
11991
12579
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
11992
12580
  throw err;
11993
12581
  }
11994
12582
  }
12583
+ function safeEmit(cb, event) {
12584
+ if (!cb) {
12585
+ return;
12586
+ }
12587
+ try {
12588
+ cb(event);
12589
+ } catch {
12590
+ }
12591
+ }
11995
12592
  async function downloadTo(args) {
11996
12593
  const filename = inferArchiveName(args.url);
11997
12594
  const dest = path2.join(args.dir, filename);
@@ -12004,17 +12601,34 @@ async function downloadTo(args) {
12004
12601
  const total = Number(response.headers.get("content-length") ?? "0");
12005
12602
  const out = fs4.createWriteStream(dest);
12006
12603
  const nodeStream = Readable.fromWeb(response.body);
12604
+ safeEmit(args.onProgress, {
12605
+ phase: "download_start",
12606
+ agentId: args.agentId,
12607
+ version: args.version,
12608
+ totalBytes: total
12609
+ });
12007
12610
  let received = 0;
12008
- let lastEmit = Date.now();
12009
- const EMIT_INTERVAL_MS = 2e3;
12611
+ let lastLogEmit = Date.now();
12612
+ let lastCbEmit = 0;
12613
+ const LOG_INTERVAL_MS = 2e3;
12614
+ const CB_INTERVAL_MS = 150;
12010
12615
  nodeStream.on("data", (chunk) => {
12011
12616
  received += chunk.length;
12012
12617
  const now = Date.now();
12013
- if (now - lastEmit < EMIT_INTERVAL_MS) {
12014
- return;
12618
+ if (now - lastCbEmit >= CB_INTERVAL_MS) {
12619
+ lastCbEmit = now;
12620
+ safeEmit(args.onProgress, {
12621
+ phase: "download_progress",
12622
+ agentId: args.agentId,
12623
+ version: args.version,
12624
+ receivedBytes: received,
12625
+ totalBytes: total
12626
+ });
12627
+ }
12628
+ if (now - lastLogEmit >= LOG_INTERVAL_MS) {
12629
+ lastLogEmit = now;
12630
+ logSink(formatProgress(args.agentId, received, total));
12015
12631
  }
12016
- lastEmit = now;
12017
- logSink(formatProgress(args.agentId, received, total));
12018
12632
  });
12019
12633
  await new Promise((resolve5, reject) => {
12020
12634
  nodeStream.on("error", reject);
@@ -12029,6 +12643,13 @@ async function downloadTo(args) {
12029
12643
  /* done */
12030
12644
  true
12031
12645
  ));
12646
+ safeEmit(args.onProgress, {
12647
+ phase: "download_done",
12648
+ agentId: args.agentId,
12649
+ version: args.version,
12650
+ receivedBytes: received,
12651
+ totalBytes: total
12652
+ });
12032
12653
  return dest;
12033
12654
  }
12034
12655
  function formatProgress(agentId, received, total, done = false) {
@@ -12128,9 +12749,11 @@ async function ensureNpmPackage(args) {
12128
12749
  }
12129
12750
  await installInto({
12130
12751
  agentId: args.agentId,
12752
+ version: args.version,
12131
12753
  packageSpec: args.packageSpec,
12132
12754
  installDir,
12133
- registry: args.registry
12755
+ registry: args.registry,
12756
+ onProgress: args.onProgress
12134
12757
  });
12135
12758
  if (!await fileExists2(binPath)) {
12136
12759
  throw new Error(
@@ -12146,6 +12769,12 @@ async function installInto(args) {
12146
12769
  logSink2(
12147
12770
  `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
12148
12771
  );
12772
+ safeEmit2(args.onProgress, {
12773
+ phase: "install_start",
12774
+ agentId: args.agentId,
12775
+ version: args.version,
12776
+ packageSpec: args.packageSpec
12777
+ });
12149
12778
  await runNpmInstall({
12150
12779
  packageSpec: args.packageSpec,
12151
12780
  cwd: tempDir,
@@ -12159,11 +12788,21 @@ async function installInto(args) {
12159
12788
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
12160
12789
  () => void 0
12161
12790
  );
12791
+ safeEmit2(args.onProgress, {
12792
+ phase: "installed",
12793
+ agentId: args.agentId,
12794
+ version: args.version
12795
+ });
12162
12796
  return;
12163
12797
  }
12164
12798
  throw err;
12165
12799
  }
12166
12800
  logSink2(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
12801
+ safeEmit2(args.onProgress, {
12802
+ phase: "installed",
12803
+ agentId: args.agentId,
12804
+ version: args.version
12805
+ });
12167
12806
  } catch (err) {
12168
12807
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
12169
12808
  () => void 0
@@ -12171,44 +12810,87 @@ async function installInto(args) {
12171
12810
  throw err;
12172
12811
  }
12173
12812
  }
12813
+ function safeEmit2(cb, event) {
12814
+ if (!cb) {
12815
+ return;
12816
+ }
12817
+ try {
12818
+ cb(event);
12819
+ } catch {
12820
+ }
12821
+ }
12822
+ var ETXTBSY_RETRIES = 5;
12823
+ var ETXTBSY_BACKOFF_MS = 25;
12174
12824
  function runNpmInstall(args) {
12175
- return new Promise((resolve5, reject) => {
12176
- const registryArgs = args.registry ? ["--registry", args.registry] : [];
12177
- const child = spawn2(
12178
- "npm",
12179
- ["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
12180
- {
12181
- cwd: args.cwd,
12182
- stdio: ["ignore", "pipe", "pipe"]
12183
- }
12184
- );
12185
- let stderrTail = "";
12186
- child.stdout?.on("data", (chunk) => {
12187
- void chunk;
12188
- });
12189
- child.stderr?.setEncoding("utf8");
12190
- child.stderr?.on("data", (chunk) => {
12191
- stderrTail = (stderrTail + chunk).slice(-4096);
12192
- });
12193
- child.on("error", (err) => {
12194
- const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
12195
- reject(new Error(msg));
12196
- });
12197
- child.on("exit", (code, signal) => {
12198
- if (code === 0) {
12199
- resolve5();
12825
+ return runNpmInstallOnce(args, 0);
12826
+ }
12827
+ async function runNpmInstallOnce(args, attempt) {
12828
+ try {
12829
+ await new Promise((resolve5, reject) => {
12830
+ const registryArgs = args.registry ? ["--registry", args.registry] : [];
12831
+ let child;
12832
+ try {
12833
+ child = spawn2(
12834
+ "npm",
12835
+ [
12836
+ "install",
12837
+ "--no-audit",
12838
+ "--no-fund",
12839
+ "--silent",
12840
+ ...registryArgs,
12841
+ args.packageSpec
12842
+ ],
12843
+ { cwd: args.cwd, stdio: ["ignore", "pipe", "pipe"] }
12844
+ );
12845
+ } catch (err) {
12846
+ reject(err);
12200
12847
  return;
12201
12848
  }
12202
- const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
12203
- const tail = stderrTail.trim();
12204
- reject(
12205
- new Error(
12206
- tail ? `npm install ${args.packageSpec} failed (${reason})
12849
+ let stderrTail = "";
12850
+ child.stdout?.on("data", (chunk) => {
12851
+ void chunk;
12852
+ });
12853
+ child.stderr?.setEncoding("utf8");
12854
+ child.stderr?.on("data", (chunk) => {
12855
+ stderrTail = (stderrTail + chunk).slice(-4096);
12856
+ });
12857
+ child.on("error", (err) => {
12858
+ const e = err;
12859
+ if (e.code === "ENOENT") {
12860
+ reject(
12861
+ new Error(
12862
+ `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)`
12863
+ )
12864
+ );
12865
+ return;
12866
+ }
12867
+ reject(err);
12868
+ });
12869
+ child.on("exit", (code, signal) => {
12870
+ if (code === 0) {
12871
+ resolve5();
12872
+ return;
12873
+ }
12874
+ const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
12875
+ const tail = stderrTail.trim();
12876
+ reject(
12877
+ new Error(
12878
+ tail ? `npm install ${args.packageSpec} failed (${reason})
12207
12879
  stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
12208
- )
12209
- );
12880
+ )
12881
+ );
12882
+ });
12210
12883
  });
12211
- });
12884
+ } catch (err) {
12885
+ const code = err.code;
12886
+ if (code === "ETXTBSY" && attempt < ETXTBSY_RETRIES) {
12887
+ await new Promise(
12888
+ (r) => setTimeout(r, ETXTBSY_BACKOFF_MS * (attempt + 1))
12889
+ );
12890
+ return runNpmInstallOnce(args, attempt + 1);
12891
+ }
12892
+ throw err;
12893
+ }
12212
12894
  }
12213
12895
  async function fileExists2(p) {
12214
12896
  try {
@@ -12406,12 +13088,14 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
12406
13088
  };
12407
13089
  }
12408
13090
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
13091
+ const npmCb = options.onInstallProgress;
12409
13092
  const binPath = await ensureNpmPackage({
12410
13093
  agentId: agent.id,
12411
13094
  version,
12412
13095
  packageSpec: npx.package,
12413
13096
  bin,
12414
- registry: options.npmRegistry
13097
+ registry: options.npmRegistry,
13098
+ onProgress: npmCb ? (e) => npmCb({ source: "npm", ...e }) : void 0
12415
13099
  });
12416
13100
  return {
12417
13101
  command: binPath,
@@ -12427,10 +13111,12 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
12427
13111
  `Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
12428
13112
  );
12429
13113
  }
13114
+ const binCb = options.onInstallProgress;
12430
13115
  const cmdPath = await ensureBinary({
12431
13116
  agentId: agent.id,
12432
13117
  version,
12433
- target
13118
+ target,
13119
+ onProgress: binCb ? (e) => binCb({ source: "binary", ...e }) : void 0
12434
13120
  });
12435
13121
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
12436
13122
  return {
@@ -12848,7 +13534,8 @@ var SessionManager = class {
12848
13534
  cwd: params.cwd,
12849
13535
  agentArgs: params.agentArgs,
12850
13536
  mcpServers: params.mcpServers,
12851
- model: params.model
13537
+ model: params.model,
13538
+ onInstallProgress: params.onInstallProgress
12852
13539
  });
12853
13540
  const session = new Session({
12854
13541
  cwd: params.cwd,
@@ -12865,7 +13552,8 @@ var SessionManager = class {
12865
13552
  historyMaxEntries: this.sessionHistoryMaxEntries,
12866
13553
  currentModel: fresh.initialModel,
12867
13554
  currentMode: fresh.initialMode,
12868
- agentModes: fresh.initialModes
13555
+ agentModes: fresh.initialModes,
13556
+ agentModels: fresh.initialModels
12869
13557
  });
12870
13558
  await this.attachManagerHooks(session);
12871
13559
  return session;
@@ -12910,7 +13598,10 @@ var SessionManager = class {
12910
13598
  if (params.upstreamSessionId === "") {
12911
13599
  return this.doResurrectFromImport(params);
12912
13600
  }
12913
- const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
13601
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], {
13602
+ npmRegistry: this.npmRegistry,
13603
+ onInstallProgress: params.onInstallProgress
13604
+ });
12914
13605
  const agent = this.spawner({
12915
13606
  agentId: params.agentId,
12916
13607
  cwd: params.cwd,
@@ -12968,6 +13659,7 @@ var SessionManager = class {
12968
13659
  currentUsage: params.currentUsage,
12969
13660
  agentCommands: params.agentCommands,
12970
13661
  agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
13662
+ agentModels: params.agentModels ?? nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})),
12971
13663
  // Only gate the first-prompt title heuristic when we actually have
12972
13664
  // a title to preserve. A title-less session (lost to a write race
12973
13665
  // or never seeded) should re-derive from the next prompt rather
@@ -12991,7 +13683,8 @@ var SessionManager = class {
12991
13683
  agentId: params.agentId,
12992
13684
  cwd,
12993
13685
  agentArgs: params.agentArgs,
12994
- mcpServers: []
13686
+ mcpServers: [],
13687
+ onInstallProgress: params.onInstallProgress
12995
13688
  });
12996
13689
  const session = new Session({
12997
13690
  sessionId: params.hydraSessionId,
@@ -13014,6 +13707,7 @@ var SessionManager = class {
13014
13707
  currentUsage: params.currentUsage,
13015
13708
  agentCommands: params.agentCommands,
13016
13709
  agentModes: params.agentModes ?? fresh.initialModes,
13710
+ agentModels: params.agentModels ?? fresh.initialModels,
13017
13711
  firstPromptSeeded: !!params.title,
13018
13712
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
13019
13713
  });
@@ -13043,7 +13737,10 @@ var SessionManager = class {
13043
13737
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
13044
13738
  throw err;
13045
13739
  }
13046
- const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
13740
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], {
13741
+ npmRegistry: this.npmRegistry,
13742
+ onInstallProgress: params.onInstallProgress
13743
+ });
13047
13744
  const agent = this.spawner({
13048
13745
  agentId: params.agentId,
13049
13746
  cwd: params.cwd,
@@ -13069,15 +13766,25 @@ var SessionManager = class {
13069
13766
  );
13070
13767
  }
13071
13768
  let initialModel = extractInitialModel(newResult);
13769
+ const initialModels = extractInitialModels(newResult);
13072
13770
  const desired = params.model ?? this.defaultModels[params.agentId];
13073
13771
  if (desired && desired !== initialModel) {
13074
- try {
13075
- await agent.connection.request("session/set_model", {
13076
- sessionId: sessionIdRaw,
13077
- modelId: desired
13078
- });
13079
- initialModel = desired;
13080
- } catch {
13772
+ const validates = initialModels.length === 0 || initialModels.some((m) => m.modelId === desired);
13773
+ if (validates) {
13774
+ try {
13775
+ await agent.connection.request("session/set_model", {
13776
+ sessionId: sessionIdRaw,
13777
+ modelId: desired
13778
+ });
13779
+ initialModel = desired;
13780
+ } catch {
13781
+ }
13782
+ } else {
13783
+ const known = initialModels.map((m) => m.modelId).join(", ");
13784
+ process.stderr.write(
13785
+ `hydra-acp: defaultModels[${params.agentId}]=${JSON.stringify(desired)} is not in the agent's availableModels (${known}); skipping session/set_model
13786
+ `
13787
+ );
13081
13788
  }
13082
13789
  }
13083
13790
  const initialModes = extractInitialModes(newResult);
@@ -13087,6 +13794,7 @@ var SessionManager = class {
13087
13794
  upstreamSessionId: sessionIdRaw,
13088
13795
  agentMeta: newResult._meta,
13089
13796
  initialModel,
13797
+ initialModels: initialModels.length > 0 ? initialModels : void 0,
13090
13798
  initialModes: initialModes.length > 0 ? initialModes : void 0,
13091
13799
  initialMode
13092
13800
  };
@@ -13149,6 +13857,15 @@ var SessionManager = class {
13149
13857
  }))
13150
13858
  }).catch(() => void 0);
13151
13859
  });
13860
+ session.onAgentModelsChange((models) => {
13861
+ void this.persistSnapshot(session.sessionId, {
13862
+ agentModels: models.map((m) => ({
13863
+ modelId: m.modelId,
13864
+ ...m.name !== void 0 ? { name: m.name } : {},
13865
+ ...m.description !== void 0 ? { description: m.description } : {}
13866
+ }))
13867
+ }).catch(() => void 0);
13868
+ });
13152
13869
  this.sessions.set(session.sessionId, session);
13153
13870
  await this.enqueueMetaWrite(session.sessionId, async () => {
13154
13871
  const existing = await this.store.read(session.sessionId);
@@ -13192,6 +13909,7 @@ var SessionManager = class {
13192
13909
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
13193
13910
  agentCommands: record.agentCommands,
13194
13911
  agentModes: record.agentModes,
13912
+ agentModels: record.agentModels,
13195
13913
  createdAt: record.createdAt
13196
13914
  };
13197
13915
  }
@@ -13435,6 +14153,26 @@ var SessionManager = class {
13435
14153
  const record = await this.store.read(sessionId).catch(() => void 0);
13436
14154
  return record !== void 0;
13437
14155
  }
14156
+ // Public retitle entry point that works on live AND cold sessions.
14157
+ // - Live: routes through Session.retitle so attached clients receive
14158
+ // a session_info_update broadcast (and persistTitle fires from the
14159
+ // onTitleChange handler, just like /hydra title).
14160
+ // - Cold: writes the new title straight into meta.json — there's
14161
+ // nothing in memory to broadcast to, but a later resurrect / list
14162
+ // will pick up the new title.
14163
+ // Returns false when no record exists at all (live or on disk).
14164
+ async setTitle(sessionId, title) {
14165
+ const live = this.get(sessionId);
14166
+ if (live) {
14167
+ await live.retitle(title);
14168
+ return true;
14169
+ }
14170
+ if (!await this.hasRecord(sessionId)) {
14171
+ return false;
14172
+ }
14173
+ await this.persistTitle(sessionId, title);
14174
+ return true;
14175
+ }
13438
14176
  // Persist a title update from Session.setTitle. The on-disk record
13439
14177
  // was written at create time; updating it here keeps the session
13440
14178
  // record's title in sync with what was broadcast to clients so a
@@ -13487,6 +14225,7 @@ var SessionManager = class {
13487
14225
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
13488
14226
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
13489
14227
  ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
14228
+ ...update.agentModels !== void 0 ? { agentModels: update.agentModels } : {},
13490
14229
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13491
14230
  });
13492
14231
  });
@@ -13588,6 +14327,18 @@ function mergeForPersistence(session, existing) {
13588
14327
  return out;
13589
14328
  }) : void 0;
13590
14329
  const agentModes = persistedModes ?? existing?.agentModes;
14330
+ const sessionModels = session.availableModels();
14331
+ const persistedModels = sessionModels.length > 0 ? sessionModels.map((m) => {
14332
+ const out = { modelId: m.modelId };
14333
+ if (m.name !== void 0) {
14334
+ out.name = m.name;
14335
+ }
14336
+ if (m.description !== void 0) {
14337
+ out.description = m.description;
14338
+ }
14339
+ return out;
14340
+ }) : void 0;
14341
+ const agentModels = persistedModels ?? existing?.agentModels;
13591
14342
  return recordFromMemorySession({
13592
14343
  sessionId: session.sessionId,
13593
14344
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -13604,6 +14355,7 @@ function mergeForPersistence(session, existing) {
13604
14355
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
13605
14356
  agentCommands,
13606
14357
  agentModes,
14358
+ agentModels,
13607
14359
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
13608
14360
  });
13609
14361
  }
@@ -13669,6 +14421,40 @@ function asString(value) {
13669
14421
  function nonEmptyOrUndefined(arr) {
13670
14422
  return arr.length > 0 ? arr : void 0;
13671
14423
  }
14424
+ function extractInitialModels(result) {
14425
+ const direct = parseModelsList(result.availableModels);
14426
+ if (direct.length > 0) {
14427
+ return direct;
14428
+ }
14429
+ const models = result.models;
14430
+ if (models && typeof models === "object" && !Array.isArray(models)) {
14431
+ const fromModelsObj = parseModelsList(
14432
+ models.availableModels
14433
+ );
14434
+ if (fromModelsObj.length > 0) {
14435
+ return fromModelsObj;
14436
+ }
14437
+ }
14438
+ const meta = result._meta;
14439
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
14440
+ for (const [key, value] of Object.entries(
14441
+ meta
14442
+ )) {
14443
+ if (key === "hydra-acp") {
14444
+ continue;
14445
+ }
14446
+ if (value && typeof value === "object" && !Array.isArray(value)) {
14447
+ const fromMeta = parseModelsList(
14448
+ value.availableModels
14449
+ );
14450
+ if (fromMeta.length > 0) {
14451
+ return fromMeta;
14452
+ }
14453
+ }
14454
+ }
14455
+ }
14456
+ return [];
14457
+ }
13672
14458
  function extractInitialModes(result) {
13673
14459
  const direct = parseModesList(result.availableModes);
13674
14460
  if (direct.length > 0) {
@@ -14646,6 +15432,35 @@ function registerSessionRoutes(app, manager, defaults) {
14646
15432
  }
14647
15433
  reply.code(204).send();
14648
15434
  });
15435
+ app.patch("/v1/sessions/:id", async (request, reply) => {
15436
+ const raw = request.params.id;
15437
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
15438
+ const body = request.body ?? {};
15439
+ if (body.regen === true) {
15440
+ const session = manager.get(id);
15441
+ if (!session) {
15442
+ reply.code(409).send({ error: "regen requires a live session" });
15443
+ return;
15444
+ }
15445
+ void session.retitleFromAgent().catch((err) => {
15446
+ app.log.warn(
15447
+ `title regen failed for ${id}: ${err.message}`
15448
+ );
15449
+ });
15450
+ reply.code(202).send();
15451
+ return;
15452
+ }
15453
+ if (typeof body.title !== "string" || body.title.trim().length === 0) {
15454
+ reply.code(400).send({ error: "title must be a non-empty string" });
15455
+ return;
15456
+ }
15457
+ const ok = await manager.setTitle(id, body.title);
15458
+ if (!ok) {
15459
+ reply.code(404).send({ error: "session not found" });
15460
+ return;
15461
+ }
15462
+ reply.code(204).send();
15463
+ });
14649
15464
  app.delete("/v1/sessions/:id", async (request, reply) => {
14650
15465
  const raw = request.params.id;
14651
15466
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -15188,7 +16003,8 @@ function registerAcpWsEndpoint(app, deps) {
15188
16003
  mcpServers: params.mcpServers,
15189
16004
  title: hydraMeta.name,
15190
16005
  agentArgs: hydraMeta.agentArgs,
15191
- model: hydraMeta.model
16006
+ model: hydraMeta.model,
16007
+ onInstallProgress: makeInstallProgressForwarder(connection)
15192
16008
  });
15193
16009
  const client = bindClientToSession(connection, session, state);
15194
16010
  const { entries: replay } = await session.attach(client, "full");
@@ -15204,6 +16020,7 @@ function registerAcpWsEndpoint(app, deps) {
15204
16020
  })();
15205
16021
  });
15206
16022
  const modesPayload = buildModesPayload(session);
16023
+ const modelsPayload = buildModelsPayload(session);
15207
16024
  return {
15208
16025
  sessionId: session.sessionId,
15209
16026
  // session/new is implicitly an attach; mirror session/attach's
@@ -15212,6 +16029,7 @@ function registerAcpWsEndpoint(app, deps) {
15212
16029
  // events without an extra round-trip.
15213
16030
  clientId: client.clientId,
15214
16031
  ...modesPayload ? { modes: modesPayload } : {},
16032
+ ...modelsPayload ? { models: modelsPayload } : {},
15215
16033
  _meta: buildResponseMeta(session)
15216
16034
  };
15217
16035
  });
@@ -15247,7 +16065,10 @@ function registerAcpWsEndpoint(app, deps) {
15247
16065
  err.code = JsonRpcErrorCodes.SessionNotFound;
15248
16066
  throw err;
15249
16067
  }
15250
- session = await deps.manager.resurrect(resurrectParams);
16068
+ session = await deps.manager.resurrect({
16069
+ ...resurrectParams,
16070
+ onInstallProgress: makeInstallProgressForwarder(connection)
16071
+ });
15251
16072
  }
15252
16073
  const client = bindClientToSession(
15253
16074
  connection,
@@ -15273,6 +16094,7 @@ function registerAcpWsEndpoint(app, deps) {
15273
16094
  }
15274
16095
  session.replayPendingPermissions(client);
15275
16096
  const modesPayload = buildModesPayload(session);
16097
+ const modelsPayload = buildModelsPayload(session);
15276
16098
  return {
15277
16099
  sessionId: session.sessionId,
15278
16100
  clientId: client.clientId,
@@ -15284,6 +16106,7 @@ function registerAcpWsEndpoint(app, deps) {
15284
16106
  historyPolicy: appliedPolicy,
15285
16107
  replayed: replay.length,
15286
16108
  ...modesPayload ? { modes: modesPayload } : {},
16109
+ ...modelsPayload ? { models: modelsPayload } : {},
15287
16110
  _meta: buildResponseMeta(session)
15288
16111
  };
15289
16112
  });
@@ -15439,15 +16262,39 @@ function registerAcpWsEndpoint(app, deps) {
15439
16262
  }
15440
16263
  session.replayPendingPermissions(client);
15441
16264
  const modesPayload = buildModesPayload(session);
16265
+ const modelsPayload = buildModelsPayload(session);
15442
16266
  return {
15443
16267
  sessionId: session.sessionId,
15444
16268
  // Same as session/new: include clientId so the deferred-echo
15445
16269
  // path in queue-aware clients can recognize own broadcasts.
15446
16270
  clientId: client.clientId,
15447
16271
  ...modesPayload ? { modes: modesPayload } : {},
16272
+ ...modelsPayload ? { models: modelsPayload } : {},
15448
16273
  _meta: buildResponseMeta(session)
15449
16274
  };
15450
16275
  });
16276
+ connection.onRequest("session/set_model", async (rawParams) => {
16277
+ const decision = decideSetModel(rawParams, deps.manager);
16278
+ if (decision.kind === "error") {
16279
+ app.log.warn(decision.logMessage);
16280
+ const err = new Error(decision.message);
16281
+ err.code = decision.code;
16282
+ throw err;
16283
+ }
16284
+ if (decision.kind === "no_op") {
16285
+ app.log.warn(decision.logMessage);
16286
+ await connection.notify("session/update", {
16287
+ sessionId: decision.sessionId,
16288
+ update: {
16289
+ sessionUpdate: "current_model_update",
16290
+ currentModel: decision.currentModel
16291
+ }
16292
+ }).catch(() => void 0);
16293
+ return null;
16294
+ }
16295
+ app.log.info(decision.logMessage);
16296
+ return decision.session.forwardRequest("session/set_model", rawParams);
16297
+ });
15451
16298
  connection.setDefaultHandler(async (rawParams, method) => {
15452
16299
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
15453
16300
  const err = new Error(`Method not found: ${method}`);
@@ -15470,6 +16317,26 @@ function registerAcpWsEndpoint(app, deps) {
15470
16317
  });
15471
16318
  });
15472
16319
  }
16320
+ function makeInstallProgressForwarder(connection) {
16321
+ return (event) => {
16322
+ const payload = {
16323
+ agentId: event.agentId,
16324
+ version: event.version,
16325
+ source: event.source,
16326
+ phase: event.phase
16327
+ };
16328
+ if ("receivedBytes" in event) {
16329
+ payload.receivedBytes = event.receivedBytes;
16330
+ }
16331
+ if ("totalBytes" in event) {
16332
+ payload.totalBytes = event.totalBytes;
16333
+ }
16334
+ if ("packageSpec" in event) {
16335
+ payload.packageSpec = event.packageSpec;
16336
+ }
16337
+ void connection.notify(AGENT_INSTALL_PROGRESS_METHOD, payload).catch(() => void 0);
16338
+ };
16339
+ }
15473
16340
  function buildModesPayload(session) {
15474
16341
  const modes = session.availableModes();
15475
16342
  if (modes.length === 0) {
@@ -15490,6 +16357,94 @@ function buildModesPayload(session) {
15490
16357
  const currentModeId = session.currentMode ?? modes[0].id;
15491
16358
  return { currentModeId, availableModes };
15492
16359
  }
16360
+ function buildModelsPayload(session) {
16361
+ const models = session.availableModels();
16362
+ if (models.length === 0) {
16363
+ return void 0;
16364
+ }
16365
+ const availableModels = models.map((m) => {
16366
+ const out = {
16367
+ modelId: m.modelId
16368
+ };
16369
+ if (m.name !== void 0) {
16370
+ out.name = m.name;
16371
+ }
16372
+ if (m.description !== void 0) {
16373
+ out.description = m.description;
16374
+ }
16375
+ return out;
16376
+ });
16377
+ const currentModelId = session.currentModel ?? models[0].modelId;
16378
+ return { currentModelId, availableModels };
16379
+ }
16380
+ function decideSetModel(rawParams, manager) {
16381
+ if (!rawParams || typeof rawParams !== "object") {
16382
+ return {
16383
+ kind: "error",
16384
+ code: JsonRpcErrorCodes.InvalidParams,
16385
+ message: "session/set_model requires params",
16386
+ logMessage: "session/set_model rejected: params not an object"
16387
+ };
16388
+ }
16389
+ const params = rawParams;
16390
+ if (typeof params.sessionId !== "string") {
16391
+ return {
16392
+ kind: "error",
16393
+ code: JsonRpcErrorCodes.InvalidParams,
16394
+ message: "session/set_model requires string sessionId",
16395
+ logMessage: "session/set_model rejected: missing/non-string sessionId"
16396
+ };
16397
+ }
16398
+ if (typeof params.modelId !== "string") {
16399
+ return {
16400
+ kind: "error",
16401
+ code: JsonRpcErrorCodes.InvalidParams,
16402
+ message: "session/set_model requires string modelId",
16403
+ logMessage: `session/set_model rejected: missing/non-string modelId sessionId=${params.sessionId}`
16404
+ };
16405
+ }
16406
+ const session = manager.get(params.sessionId);
16407
+ if (!session) {
16408
+ return {
16409
+ kind: "error",
16410
+ code: JsonRpcErrorCodes.SessionNotFound,
16411
+ message: `session ${params.sessionId} not found`,
16412
+ logMessage: `session/set_model rejected: session not found sessionId=${params.sessionId}`
16413
+ };
16414
+ }
16415
+ const advertised = session.availableModels();
16416
+ if (advertised.length === 0) {
16417
+ return {
16418
+ kind: "ok",
16419
+ session,
16420
+ logMessage: `session/set_model passthrough (no availableModels) sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
16421
+ };
16422
+ }
16423
+ const match = advertised.find((m) => m.modelId === params.modelId);
16424
+ if (!match) {
16425
+ const known = advertised.map((m) => m.modelId).join(", ");
16426
+ if (session.currentModel !== void 0 && session.currentModel.length > 0) {
16427
+ return {
16428
+ kind: "no_op",
16429
+ session,
16430
+ sessionId: params.sessionId,
16431
+ currentModel: session.currentModel,
16432
+ 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}]`
16433
+ };
16434
+ }
16435
+ return {
16436
+ kind: "error",
16437
+ code: JsonRpcErrorCodes.InvalidParams,
16438
+ message: `model "${params.modelId}" is not in this session's availableModels (agent ${session.agentId}); known models: ${known}`,
16439
+ 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)`
16440
+ };
16441
+ }
16442
+ return {
16443
+ kind: "ok",
16444
+ session,
16445
+ logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
16446
+ };
16447
+ }
15493
16448
  function buildResponseMeta(session) {
15494
16449
  const ours = {
15495
16450
  upstreamSessionId: session.upstreamSessionId,
@@ -15519,6 +16474,10 @@ function buildResponseMeta(session) {
15519
16474
  if (modes.length > 0) {
15520
16475
  ours.availableModes = modes;
15521
16476
  }
16477
+ const models = session.availableModels();
16478
+ if (models.length > 0) {
16479
+ ours.availableModels = models;
16480
+ }
15522
16481
  if (session.turnStartedAt !== void 0) {
15523
16482
  ours.turnStartedAt = session.turnStartedAt;
15524
16483
  }
@@ -15754,6 +16713,7 @@ function ensureLoopbackOrTls(config) {
15754
16713
 
15755
16714
  // src/cli/commands/daemon.ts
15756
16715
  init_daemon_bootstrap();
16716
+ init_hydra_version();
15757
16717
 
15758
16718
  // src/cli/commands/log-tail.ts
15759
16719
  import * as fs16 from "fs";
@@ -15984,6 +16944,8 @@ async function runDaemonStatus() {
15984
16944
  const info = await readPidFile();
15985
16945
  if (!info) {
15986
16946
  process.stdout.write("Daemon: not running\n");
16947
+ process.stdout.write(`CLI version: ${HYDRA_VERSION}
16948
+ `);
15987
16949
  return;
15988
16950
  }
15989
16951
  const alive = isProcessAlive(info.pid);
@@ -15991,6 +16953,52 @@ async function runDaemonStatus() {
15991
16953
  `Daemon: ${alive ? "running" : "stale pid file"} pid=${info.pid} host=${info.host} port=${info.port} started=${info.startedAt}
15992
16954
  `
15993
16955
  );
16956
+ let daemonVersion;
16957
+ if (alive) {
16958
+ try {
16959
+ const config = await loadConfig();
16960
+ daemonVersion = await fetchDaemonVersion(config);
16961
+ } catch {
16962
+ }
16963
+ }
16964
+ if (daemonVersion === void 0) {
16965
+ process.stdout.write(`CLI version: ${HYDRA_VERSION}
16966
+ `);
16967
+ if (alive) {
16968
+ process.stdout.write(
16969
+ "Daemon version: unknown (health endpoint unreachable)\n"
16970
+ );
16971
+ }
16972
+ return;
16973
+ }
16974
+ if (daemonVersion === HYDRA_VERSION) {
16975
+ process.stdout.write(`Version: ${HYDRA_VERSION}
16976
+ `);
16977
+ return;
16978
+ }
16979
+ process.stdout.write(`CLI version: ${HYDRA_VERSION}
16980
+ `);
16981
+ process.stdout.write(`Daemon version: ${daemonVersion}
16982
+ `);
16983
+ process.stdout.write(
16984
+ chalk.yellow(
16985
+ "Version mismatch \u2014 run `hydra-acp daemon restart` to upgrade the daemon.\n"
16986
+ )
16987
+ );
16988
+ }
16989
+ async function fetchDaemonVersion(config) {
16990
+ const protocol = config.daemon.tls ? "https" : "http";
16991
+ const url = `${protocol}://${config.daemon.host}:${config.daemon.port}/v1/health`;
16992
+ try {
16993
+ const response = await fetch(url, { signal: AbortSignal.timeout(1e3) });
16994
+ if (!response.ok) {
16995
+ return void 0;
16996
+ }
16997
+ const body = await response.json();
16998
+ return typeof body.version === "string" ? body.version : void 0;
16999
+ } catch {
17000
+ return void 0;
17001
+ }
15994
17002
  }
15995
17003
  async function readPidFile() {
15996
17004
  try {