@hydra-acp/cli 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -289,7 +289,15 @@ var init_config = __esm({
289
289
  // on Windows Terminal, dock badge on KDE/Konsole, etc.) while a turn is
290
290
  // running. Set false if your terminal renders this obnoxiously or you
291
291
  // just don't want it.
292
- progressIndicator: z.boolean().default(true)
292
+ progressIndicator: z.boolean().default(true),
293
+ // What the unmodified Enter key does in the prompt composer.
294
+ // "enqueue" (default) — Enter enqueues the prompt (sends immediately
295
+ // when idle, queues behind an in-flight turn); Shift+Enter amends
296
+ // the in-flight turn.
297
+ // "amend" — flips the two: Enter amends the in-flight turn,
298
+ // Shift+Enter enqueues. With no turn in flight either key just
299
+ // enqueues, since there's nothing to amend.
300
+ defaultEnterAction: z.enum(["enqueue", "amend"]).default("enqueue")
293
301
  });
294
302
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
295
303
  ExtensionBody = z.object({
@@ -333,7 +341,8 @@ var init_config = __esm({
333
341
  mouse: true,
334
342
  logMaxBytes: 5 * 1024 * 1024,
335
343
  cwdColumnMaxWidth: 24,
336
- progressIndicator: true
344
+ progressIndicator: true,
345
+ defaultEnterAction: "enqueue"
337
346
  })
338
347
  });
339
348
  }
@@ -413,6 +422,18 @@ function extractHydraMeta(meta) {
413
422
  if (typeof obj.promptQueueing === "boolean") {
414
423
  out.promptQueueing = obj.promptQueueing;
415
424
  }
425
+ if (typeof obj.promptCancelling === "boolean") {
426
+ out.promptCancelling = obj.promptCancelling;
427
+ }
428
+ if (typeof obj.promptUpdating === "boolean") {
429
+ out.promptUpdating = obj.promptUpdating;
430
+ }
431
+ if (typeof obj.promptAmending === "boolean") {
432
+ out.promptAmending = obj.promptAmending;
433
+ }
434
+ if (typeof obj.promptPipelining === "boolean") {
435
+ out.promptPipelining = obj.promptPipelining;
436
+ }
416
437
  if (Array.isArray(obj.queue)) {
417
438
  const entries = [];
418
439
  for (const raw of obj.queue) {
@@ -462,12 +483,35 @@ function extractHydraMeta(meta) {
462
483
  out.availableModes = modes;
463
484
  }
464
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
+ }
465
509
  return out;
466
510
  }
467
511
  function mergeMeta(passthrough, ours) {
468
512
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
469
513
  }
470
- 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, 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;
471
515
  var init_types = __esm({
472
516
  "src/acp/types.ts"() {
473
517
  "use strict";
@@ -633,6 +677,51 @@ var init_types = __esm({
633
677
  updated: z3.boolean(),
634
678
  reason: z3.enum(["ok", "not_found", "already_running"])
635
679
  });
680
+ AmendPromptParams = z3.object({
681
+ sessionId: z3.string(),
682
+ targetMessageId: z3.string(),
683
+ prompt: z3.array(z3.unknown()),
684
+ replaceQueue: z3.boolean().optional(),
685
+ onTargetCompleted: z3.enum(["reject", "send_anyway"]).optional()
686
+ });
687
+ AmendPromptResult = z3.object({
688
+ amended: z3.boolean(),
689
+ reason: z3.enum([
690
+ "ok",
691
+ "target_completed",
692
+ "target_cancelled",
693
+ "target_not_found"
694
+ ]),
695
+ // Present when a prompt was sent or replaced: the amendment's id on
696
+ // success, or the regular follow-up's id when onTargetCompleted is
697
+ // "send_anyway" and the daemon forwarded the prompt anyway.
698
+ messageId: z3.string().optional()
699
+ });
700
+ PromptAmendedParams = z3.object({
701
+ sessionId: z3.string(),
702
+ cancelledMessageId: z3.string(),
703
+ newMessageId: z3.string(),
704
+ prompt: z3.array(z3.unknown()),
705
+ originator: PromptOriginatorSchema,
706
+ amendedAt: z3.number()
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";
636
725
  ProxyInitializeParams = z3.object({
637
726
  protocolVersion: z3.number().optional(),
638
727
  proxyInfo: z3.object({
@@ -971,6 +1060,42 @@ function sameAdvertisedModes(a, b) {
971
1060
  }
972
1061
  return true;
973
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
+ }
974
1099
  function extractAdvertisedModes(params) {
975
1100
  const obj = params ?? {};
976
1101
  const update = obj.update ?? {};
@@ -1154,7 +1279,7 @@ function firstLine(text, max) {
1154
1279
  }
1155
1280
  return void 0;
1156
1281
  }
1157
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, DEFAULT_HISTORY_MAX_ENTRIES, Session, STATE_UPDATE_KINDS;
1282
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, DEFAULT_HISTORY_MAX_ENTRIES, RECENTLY_TERMINAL_LIMIT, Session, STATE_UPDATE_KINDS;
1158
1283
  var init_session = __esm({
1159
1284
  "src/core/session.ts"() {
1160
1285
  "use strict";
@@ -1165,6 +1290,7 @@ var init_session = __esm({
1165
1290
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
1166
1291
  HYDRA_SESSION_PREFIX = "hydra_session_";
1167
1292
  DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
1293
+ RECENTLY_TERMINAL_LIMIT = 64;
1168
1294
  Session = class {
1169
1295
  sessionId;
1170
1296
  cwd;
@@ -1256,14 +1382,36 @@ var init_session = __esm({
1256
1382
  // Last available_modes_update we observed from the agent. Same
1257
1383
  // pattern as commands: cache, persist, broadcast on change.
1258
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 = [];
1259
1392
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1260
1393
  // to mirror changes into meta.json so cold-resurrect attaches can
1261
1394
  // surface the latest snapshot via the attach response _meta.
1262
1395
  agentCommandsHandlers = [];
1263
1396
  agentModesHandlers = [];
1397
+ agentModelsHandlers = [];
1264
1398
  modelHandlers = [];
1265
1399
  modeHandlers = [];
1266
1400
  usageHandlers = [];
1401
+ // Set by amendPrompt at the start of a cancel-and-resubmit dance.
1402
+ // broadcastTurnComplete reads it to attach the _meta.amended marker
1403
+ // to the cancelled turn's turn_complete notification, and to fire the
1404
+ // dedicated prompt_amended notification. Cleared when the cancelled
1405
+ // turn's task completes (runQueueEntry) OR if the amendment is
1406
+ // cancelled mid-window via cancel_prompt(M2) before drainQueue picks
1407
+ // it up.
1408
+ amendInProgress;
1409
+ // LRU of recently-terminal messageIds → stopReason. Used by
1410
+ // amendPrompt to resolve targets that completed/cancelled before
1411
+ // the amend arrived. Capped at RECENTLY_TERMINAL_LIMIT entries;
1412
+ // older entries fall out and resolve to target_not_found, which is
1413
+ // the correct behavior.
1414
+ recentlyTerminal = /* @__PURE__ */ new Map();
1267
1415
  constructor(init) {
1268
1416
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
1269
1417
  this.cwd = init.cwd;
@@ -1282,6 +1430,9 @@ var init_session = __esm({
1282
1430
  if (init.agentModes && init.agentModes.length > 0) {
1283
1431
  this.agentAdvertisedModes = [...init.agentModes];
1284
1432
  }
1433
+ if (init.agentModels && init.agentModels.length > 0) {
1434
+ this.agentAdvertisedModels = [...init.agentModels];
1435
+ }
1285
1436
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1286
1437
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1287
1438
  this.logger = init.logger;
@@ -1319,6 +1470,23 @@ var init_session = __esm({
1319
1470
  }
1320
1471
  });
1321
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
+ }
1322
1490
  // Register session/update, session/request_permission, and onExit
1323
1491
  // handlers on an agent connection. Re-run on every /hydra agent so
1324
1492
  // the new agent is plumbed identically. The exit handler's identity
@@ -1349,6 +1517,10 @@ var init_session = __esm({
1349
1517
  this.recordAndBroadcast("session/update", params);
1350
1518
  return;
1351
1519
  }
1520
+ if (this.maybeApplyAgentConfigOption(params)) {
1521
+ this.recordAndBroadcast("session/update", params);
1522
+ return;
1523
+ }
1352
1524
  if (this.maybeApplyAgentUsage(params)) {
1353
1525
  this.recordAndBroadcast("session/update", params);
1354
1526
  return;
@@ -1497,16 +1669,19 @@ var init_session = __esm({
1497
1669
  recordedAt
1498
1670
  });
1499
1671
  }
1500
- 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
+ }
1501
1682
  out.push({
1502
1683
  method: "session/update",
1503
- params: {
1504
- sessionId,
1505
- update: {
1506
- sessionUpdate: "current_model_update",
1507
- currentModel: this.currentModel
1508
- }
1509
- },
1684
+ params: { sessionId, update },
1510
1685
  recordedAt
1511
1686
  });
1512
1687
  }
@@ -1693,7 +1868,7 @@ var init_session = __esm({
1693
1868
  );
1694
1869
  }
1695
1870
  }
1696
- broadcastTurnComplete(originatorClientId, response) {
1871
+ broadcastTurnComplete(originatorClientId, response, promptMessageId, wasAmend) {
1697
1872
  const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
1698
1873
  const update = {
1699
1874
  sessionUpdate: "turn_complete",
@@ -1702,16 +1877,84 @@ var init_session = __esm({
1702
1877
  if (stopReason !== void 0) {
1703
1878
  update.stopReason = stopReason;
1704
1879
  }
1880
+ const amend = this.amendInProgress;
1881
+ if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
1882
+ update._meta = {
1883
+ "hydra-acp": {
1884
+ amended: {
1885
+ cancelledMessageId: amend.cancelledMessageId,
1886
+ newMessageId: amend.newMessageId
1887
+ }
1888
+ }
1889
+ };
1890
+ }
1705
1891
  this.promptStartedAt = void 0;
1892
+ if (promptMessageId !== void 0 && stopReason !== void 0) {
1893
+ this.recordTerminal(promptMessageId, stopReason);
1894
+ }
1706
1895
  this.recordAndBroadcast(
1707
1896
  "session/update",
1708
1897
  {
1709
1898
  sessionId: this.sessionId,
1710
1899
  update
1711
1900
  },
1712
- originatorClientId
1901
+ wasAmend ? void 0 : originatorClientId
1902
+ );
1903
+ if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
1904
+ this.broadcastPromptAmended(amend);
1905
+ }
1906
+ }
1907
+ // Record that a prompt's turn has ended, with its terminal stopReason.
1908
+ // Used by amendPrompt to resolve targetMessageIds that completed/cancelled
1909
+ // before the amend arrived. LRU-trimmed at RECENTLY_TERMINAL_LIMIT.
1910
+ recordTerminal(messageId, stopReason) {
1911
+ this.recentlyTerminal.set(messageId, {
1912
+ stopReason,
1913
+ terminatedAt: Date.now()
1914
+ });
1915
+ while (this.recentlyTerminal.size > RECENTLY_TERMINAL_LIMIT) {
1916
+ const oldest = this.recentlyTerminal.keys().next().value;
1917
+ if (oldest === void 0) {
1918
+ break;
1919
+ }
1920
+ this.recentlyTerminal.delete(oldest);
1921
+ }
1922
+ }
1923
+ // Fire hydra-acp/prompt_amended for the M1→M2 linkage. The amendment's
1924
+ // current content is read live from the queue entry so any update_prompt
1925
+ // calls during the amend window are reflected. Best-effort: if M2 has
1926
+ // already been cancelled out of the queue by the time we get here, we
1927
+ // skip — the amendInProgress clearing in cancelQueuedPrompt should have
1928
+ // prevented this code path from running in that case.
1929
+ broadcastPromptAmended(amend) {
1930
+ const entry = this.findUserEntry(amend.newMessageId);
1931
+ if (!entry) {
1932
+ return;
1933
+ }
1934
+ const params = {
1935
+ sessionId: this.sessionId,
1936
+ cancelledMessageId: amend.cancelledMessageId,
1937
+ newMessageId: amend.newMessageId,
1938
+ prompt: entry.prompt,
1939
+ originator: entry.originator,
1940
+ amendedAt: Date.now()
1941
+ };
1942
+ this.broadcastQueueNotification(
1943
+ "hydra-acp/prompt_amended",
1944
+ params
1713
1945
  );
1714
1946
  }
1947
+ // Look up a user-prompt queue entry by messageId, searching both the
1948
+ // currentEntry slot and the waiting queue.
1949
+ findUserEntry(messageId) {
1950
+ if (this.currentEntry?.messageId === messageId && this.currentEntry.kind === "user") {
1951
+ return this.currentEntry;
1952
+ }
1953
+ const queued = this.promptQueue.find(
1954
+ (e) => e.messageId === messageId && e.kind === "user"
1955
+ );
1956
+ return queued?.kind === "user" ? queued : void 0;
1957
+ }
1715
1958
  // Total visible-or-running entries: the in-flight head (if any) plus
1716
1959
  // the queue's user-visible waiting entries. Internal entries don't
1717
1960
  // count — they're an implementation detail and the wire never
@@ -1723,9 +1966,9 @@ var init_session = __esm({
1723
1966
  }
1724
1967
  return count;
1725
1968
  }
1726
- broadcastQueueAdded(entry) {
1969
+ broadcastQueueAdded(entry, options) {
1727
1970
  const depth = this.visibleQueueDepth();
1728
- const position = Math.max(0, depth - 1);
1971
+ const position = options?.position ?? Math.max(0, depth - 1);
1729
1972
  const params = {
1730
1973
  sessionId: this.sessionId,
1731
1974
  messageId: entry.messageId,
@@ -1735,6 +1978,11 @@ var init_session = __esm({
1735
1978
  queueDepth: depth,
1736
1979
  enqueuedAt: entry.enqueuedAt
1737
1980
  };
1981
+ if (options?.amending !== void 0) {
1982
+ params._meta = {
1983
+ "hydra-acp": { amending: options.amending }
1984
+ };
1985
+ }
1738
1986
  this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
1739
1987
  }
1740
1988
  broadcastQueueUpdated(messageId, prompt) {
@@ -1857,6 +2105,9 @@ var init_session = __esm({
1857
2105
  this.broadcastQueueRemoved(messageId, "cancelled");
1858
2106
  this.persistRewrite();
1859
2107
  }
2108
+ if (this.amendInProgress?.newMessageId === messageId) {
2109
+ this.amendInProgress = void 0;
2110
+ }
1860
2111
  entry.resolve({ stopReason: "cancelled" });
1861
2112
  return { cancelled: true, reason: "ok" };
1862
2113
  }
@@ -1878,6 +2129,143 @@ var init_session = __esm({
1878
2129
  this.persistRewrite();
1879
2130
  return { updated: true, reason: "ok" };
1880
2131
  }
2132
+ // Amend the head prompt: cancel the in-flight turn and submit a
2133
+ // replacement that sits at the head of the queue. Resolves the
2134
+ // request immediately (the caller doesn't wait on cancel-settle).
2135
+ // Honours race outcomes — if the target finished or was cancelled
2136
+ // before this arrived, the request resolves with an outcome explaining
2137
+ // why and (depending on onTargetCompleted) optionally forwards as a
2138
+ // plain prompt. Queued targets are edited in place (same machinery
2139
+ // as updateQueuedPrompt).
2140
+ amendPrompt(clientId, params) {
2141
+ const client = this.clients.get(clientId);
2142
+ if (!client) {
2143
+ throw withCode(
2144
+ new Error("client not attached"),
2145
+ JsonRpcErrorCodes.SessionNotFound
2146
+ );
2147
+ }
2148
+ const { targetMessageId, prompt, replaceQueue, onTargetCompleted } = params;
2149
+ if (this.currentEntry?.messageId === targetMessageId && this.currentEntry.kind === "user" && !this.currentEntry.cancelled && this.amendInProgress === void 0) {
2150
+ return this.amendOnHead(client, prompt, targetMessageId, replaceQueue);
2151
+ }
2152
+ const queuedEntry = this.promptQueue.find(
2153
+ (e) => e.messageId === targetMessageId && e.kind === "user"
2154
+ );
2155
+ if (queuedEntry && queuedEntry.kind === "user" && !queuedEntry.cancelled) {
2156
+ queuedEntry.prompt = prompt;
2157
+ this.broadcastQueueUpdated(targetMessageId, prompt);
2158
+ this.persistRewrite();
2159
+ return { amended: true, reason: "ok", messageId: targetMessageId };
2160
+ }
2161
+ const terminal = this.recentlyTerminal.get(targetMessageId);
2162
+ if (terminal) {
2163
+ if (terminal.stopReason === "cancelled") {
2164
+ return { amended: false, reason: "target_cancelled" };
2165
+ }
2166
+ if (onTargetCompleted === "send_anyway") {
2167
+ const newMessageId = this.enqueueAmendmentAsFollowUp(client, prompt);
2168
+ return {
2169
+ amended: false,
2170
+ reason: "target_completed",
2171
+ messageId: newMessageId
2172
+ };
2173
+ }
2174
+ return { amended: false, reason: "target_completed" };
2175
+ }
2176
+ return { amended: false, reason: "target_not_found" };
2177
+ }
2178
+ // Head-of-queue amendment: splice M2 in front of any waiting entries,
2179
+ // broadcast the amend window's queue_added with the amending hint,
2180
+ // mark amendInProgress so the cancelled turn's broadcastTurnComplete
2181
+ // attaches the _meta marker and fires prompt_amended, then fire the
2182
+ // upstream session/cancel without awaiting it. drainQueue is already
2183
+ // running on the head; when its session/prompt returns, it advances
2184
+ // to M2 in the normal way.
2185
+ amendOnHead(client, prompt, targetMessageId, replaceQueue) {
2186
+ const newMessageId = generateMessageId();
2187
+ const originator = { clientId: client.clientId };
2188
+ if (client.clientInfo?.name) {
2189
+ originator.name = client.clientInfo.name;
2190
+ }
2191
+ if (client.clientInfo?.version) {
2192
+ originator.version = client.clientInfo.version;
2193
+ }
2194
+ if (replaceQueue) {
2195
+ const survivors = [];
2196
+ for (const entry2 of this.promptQueue) {
2197
+ if (entry2.kind === "user" && !entry2.cancelled) {
2198
+ entry2.cancelled = true;
2199
+ this.broadcastQueueRemoved(entry2.messageId, "cancelled");
2200
+ entry2.resolve({ stopReason: "cancelled" });
2201
+ continue;
2202
+ }
2203
+ survivors.push(entry2);
2204
+ }
2205
+ this.promptQueue = survivors;
2206
+ }
2207
+ const entry = {
2208
+ kind: "user",
2209
+ messageId: newMessageId,
2210
+ originator,
2211
+ clientId: client.clientId,
2212
+ prompt,
2213
+ enqueuedAt: Date.now(),
2214
+ cancelled: false,
2215
+ wasAmend: true,
2216
+ // No-op resolve/reject: there's no client request awaiting M2's
2217
+ // session/prompt response. The amend_prompt request has already
2218
+ // returned by this point. drainQueue calls these unconditionally
2219
+ // when runQueueEntry settles; making them no-ops is safe.
2220
+ resolve: () => void 0,
2221
+ reject: () => void 0
2222
+ };
2223
+ this.promptQueue.unshift(entry);
2224
+ this.persistRewrite();
2225
+ this.broadcastQueueAdded(entry, {
2226
+ amending: targetMessageId,
2227
+ position: 1
2228
+ });
2229
+ this.amendInProgress = {
2230
+ cancelledMessageId: targetMessageId,
2231
+ newMessageId
2232
+ };
2233
+ void this.agent.connection.notify("session/cancel", { sessionId: this.upstreamSessionId }).catch(() => void 0);
2234
+ return {
2235
+ amended: true,
2236
+ reason: "ok",
2237
+ messageId: newMessageId
2238
+ };
2239
+ }
2240
+ // Send the amendment as a plain follow-up prompt — used when the
2241
+ // target already completed and the caller opted in to send_anyway.
2242
+ // Returns the new prompt's messageId so the result can surface it.
2243
+ enqueueAmendmentAsFollowUp(client, prompt) {
2244
+ const messageId = generateMessageId();
2245
+ const originator = { clientId: client.clientId };
2246
+ if (client.clientInfo?.name) {
2247
+ originator.name = client.clientInfo.name;
2248
+ }
2249
+ if (client.clientInfo?.version) {
2250
+ originator.version = client.clientInfo.version;
2251
+ }
2252
+ const entry = {
2253
+ kind: "user",
2254
+ messageId,
2255
+ originator,
2256
+ clientId: client.clientId,
2257
+ prompt,
2258
+ enqueuedAt: Date.now(),
2259
+ cancelled: false,
2260
+ resolve: () => void 0,
2261
+ reject: () => void 0
2262
+ };
2263
+ this.promptQueue.push(entry);
2264
+ this.persistRewrite();
2265
+ this.broadcastQueueAdded(entry);
2266
+ void this.drainQueue();
2267
+ return messageId;
2268
+ }
1881
2269
  async cancel(clientId) {
1882
2270
  const client = this.clients.get(clientId);
1883
2271
  if (!client) {
@@ -1928,6 +2316,18 @@ var init_session = __esm({
1928
2316
  onTitleChange(handler) {
1929
2317
  this.titleHandlers.push(handler);
1930
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
+ }
1931
2331
  // Update the canonical title and broadcast a session_info_update to
1932
2332
  // every attached client. Clients that already speak the spec's
1933
2333
  // session_info_update need no hydra-specific wiring to pick this up.
@@ -1975,12 +2375,19 @@ var init_session = __esm({
1975
2375
  // Apply an agent-emitted current_model_update. Returns true if the
1976
2376
  // notification was a model update (caller still needs to broadcast
1977
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.
1978
2381
  maybeApplyAgentModel(params) {
1979
2382
  const obj = params ?? {};
1980
2383
  const update = obj.update ?? {};
1981
2384
  if (update.sessionUpdate !== "current_model_update") {
1982
2385
  return false;
1983
2386
  }
2387
+ const models = parseModelsList(update.availableModels);
2388
+ if (models.length > 0) {
2389
+ this.setAgentAdvertisedModels(models);
2390
+ }
1984
2391
  const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
1985
2392
  if (raw === void 0) {
1986
2393
  return true;
@@ -1998,6 +2405,55 @@ var init_session = __esm({
1998
2405
  }
1999
2406
  return true;
2000
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
+ }
2001
2457
  maybeApplyAgentMode(params) {
2002
2458
  const obj = params ?? {};
2003
2459
  const update = obj.update ?? {};
@@ -2096,6 +2552,20 @@ var init_session = __esm({
2096
2552
  }
2097
2553
  this.broadcastAvailableModes();
2098
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
+ }
2099
2569
  // Subscribe to snapshot-state updates. SessionManager wires these to
2100
2570
  // persist the new value into meta.json so cold resurrect can restore
2101
2571
  // them via the attach response _meta.
@@ -2105,6 +2575,9 @@ var init_session = __esm({
2105
2575
  onAgentModesChange(handler) {
2106
2576
  this.agentModesHandlers.push(handler);
2107
2577
  }
2578
+ onAgentModelsChange(handler) {
2579
+ this.agentModelsHandlers.push(handler);
2580
+ }
2108
2581
  onModelChange(handler) {
2109
2582
  this.modelHandlers.push(handler);
2110
2583
  }
@@ -2130,6 +2603,15 @@ var init_session = __esm({
2130
2603
  availableModes() {
2131
2604
  return [...this.agentAdvertisedModes];
2132
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
+ }
2133
2615
  // Pick up an agent-emitted session_info_update and store its title
2134
2616
  // as our canonical record. The notification is also forwarded to
2135
2617
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -2277,6 +2759,12 @@ var init_session = __esm({
2277
2759
  this.agentMeta = fresh.agentMeta;
2278
2760
  this.agentAdvertisedCommands = [];
2279
2761
  this.broadcastMergedCommands();
2762
+ if (this.agentAdvertisedModels.length > 0) {
2763
+ this.setAgentAdvertisedModels([]);
2764
+ }
2765
+ if (this.agentAdvertisedModes.length > 0) {
2766
+ this.setAgentAdvertisedModes([]);
2767
+ }
2280
2768
  await oldAgent.kill().catch(() => void 0);
2281
2769
  if (transcript) {
2282
2770
  await this.runInternalPrompt(transcript).catch(() => void 0);
@@ -2736,6 +3224,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2736
3224
  try {
2737
3225
  const result = await this.runQueueEntry(next);
2738
3226
  next.resolve(result);
3227
+ await Promise.resolve();
2739
3228
  } catch (err) {
2740
3229
  next.reject(err);
2741
3230
  } finally {
@@ -2772,12 +3261,33 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2772
3261
  }
2773
3262
  );
2774
3263
  } catch (err) {
2775
- this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
3264
+ this.broadcastTurnComplete(
3265
+ entry.clientId,
3266
+ { stopReason: "error" },
3267
+ entry.messageId,
3268
+ entry.wasAmend
3269
+ );
3270
+ this.clearAmendIfMatches(entry.messageId);
2776
3271
  throw err;
2777
3272
  }
2778
- this.broadcastTurnComplete(entry.clientId, response);
3273
+ this.broadcastTurnComplete(
3274
+ entry.clientId,
3275
+ response,
3276
+ entry.messageId,
3277
+ entry.wasAmend
3278
+ );
3279
+ this.clearAmendIfMatches(entry.messageId);
2779
3280
  return response;
2780
3281
  }
3282
+ // Clear amendInProgress once the cancelled turn's task has fully
3283
+ // settled. broadcastTurnComplete needs the marker still set when it
3284
+ // fires, so the clear must happen *after*. Called from runQueueEntry's
3285
+ // settle path for both success and error.
3286
+ clearAmendIfMatches(messageId) {
3287
+ if (this.amendInProgress?.cancelledMessageId === messageId) {
3288
+ this.amendInProgress = void 0;
3289
+ }
3290
+ }
2781
3291
  };
2782
3292
  STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
2783
3293
  "session_info_update",
@@ -2821,11 +3331,12 @@ function recordFromMemorySession(args) {
2821
3331
  currentUsage: args.currentUsage,
2822
3332
  agentCommands: args.agentCommands,
2823
3333
  agentModes: args.agentModes,
3334
+ agentModels: args.agentModels,
2824
3335
  createdAt: args.createdAt ?? now,
2825
3336
  updatedAt: args.updatedAt ?? now
2826
3337
  };
2827
3338
  }
2828
- 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;
2829
3340
  var init_session_store = __esm({
2830
3341
  "src/core/session-store.ts"() {
2831
3342
  "use strict";
@@ -2842,6 +3353,11 @@ var init_session_store = __esm({
2842
3353
  name: z4.string().optional(),
2843
3354
  description: z4.string().optional()
2844
3355
  });
3356
+ PersistedAgentModel = z4.object({
3357
+ modelId: z4.string(),
3358
+ name: z4.string().optional(),
3359
+ description: z4.string().optional()
3360
+ });
2845
3361
  PersistedUsage = z4.object({
2846
3362
  used: z4.number().optional(),
2847
3363
  size: z4.number().optional(),
@@ -2888,6 +3404,7 @@ var init_session_store = __esm({
2888
3404
  currentUsage: PersistedUsage.optional(),
2889
3405
  agentCommands: z4.array(PersistedAgentCommand).optional(),
2890
3406
  agentModes: z4.array(PersistedAgentMode).optional(),
3407
+ agentModels: z4.array(PersistedAgentModel).optional(),
2891
3408
  createdAt: z4.string(),
2892
3409
  updatedAt: z4.string()
2893
3410
  });
@@ -3383,8 +3900,55 @@ function mapToolCallUpdate(u) {
3383
3900
  if (status !== void 0) {
3384
3901
  event.status = status;
3385
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
+ }
3386
3912
  return event;
3387
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
+ }
3388
3952
  function mapPlan(u) {
3389
3953
  const entries = u.entries;
3390
3954
  if (!Array.isArray(entries)) {
@@ -3427,7 +3991,16 @@ function mapModel(u) {
3427
3991
  }
3428
3992
  function mapTurnComplete(u) {
3429
3993
  const stopReason = readString(u, "stopReason");
3430
- return stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" };
3994
+ const meta = u._meta;
3995
+ const amended = meta?.["hydra-acp"]?.amended !== void 0 && meta["hydra-acp"].amended !== null;
3996
+ const out = { kind: "turn-complete" };
3997
+ if (stopReason !== void 0) {
3998
+ out.stopReason = stopReason;
3999
+ }
4000
+ if (amended) {
4001
+ out.amended = true;
4002
+ }
4003
+ return out;
3431
4004
  }
3432
4005
  function extractContentText(content) {
3433
4006
  if (typeof content === "string") {
@@ -4761,6 +5334,34 @@ async function killSession(config, serviceToken, id, fetchImpl = fetch) {
4761
5334
  throw new Error(`daemon returned HTTP ${response.status}`);
4762
5335
  }
4763
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
+ }
4764
5365
  async function deleteSession(config, serviceToken, id, fetchImpl = fetch) {
4765
5366
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
4766
5367
  const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
@@ -4831,6 +5432,7 @@ async function pickSession(term, opts) {
4831
5432
  let cwdOnly = false;
4832
5433
  let mode = "normal";
4833
5434
  let pendingAction = null;
5435
+ let renameBuffer = "";
4834
5436
  let transientStatus = null;
4835
5437
  let termHeight = readTermHeight(term);
4836
5438
  let termWidth = readTermWidth(term);
@@ -4939,6 +5541,12 @@ async function pickSession(term, opts) {
4939
5541
  term.dim.noFormat(` working on ${shortId2(pendingAction.sessionId)}\u2026`);
4940
5542
  return;
4941
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
+ }
4942
5550
  if (transientStatus !== null) {
4943
5551
  term.dim.noFormat(` ${transientStatus}`);
4944
5552
  return;
@@ -5056,6 +5664,37 @@ async function pickSession(term, opts) {
5056
5664
  renderFromScratch();
5057
5665
  }
5058
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
+ };
5059
5698
  const performAction = async (kind) => {
5060
5699
  if (!pendingAction) {
5061
5700
  return;
@@ -5128,19 +5767,65 @@ async function pickSession(term, opts) {
5128
5767
  renderFromScratch();
5129
5768
  return;
5130
5769
  }
5131
- if (mode === "confirm-kill" || mode === "confirm-delete") {
5132
- if (data?.isCharacter && (name === "y" || name === "Y")) {
5133
- const kind = mode === "confirm-kill" ? "kill" : "delete";
5134
- void performAction(kind);
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);
5135
5781
  return;
5136
5782
  }
5137
- if (name === "ESCAPE" || name === "CTRL_C" || name === "ENTER" || name === "KP_ENTER" || data?.isCharacter && (name === "n" || name === "N")) {
5783
+ if (name === "ESCAPE" || name === "CTRL_C") {
5138
5784
  mode = "normal";
5139
5785
  pendingAction = null;
5786
+ renameBuffer = "";
5140
5787
  paintIndicator();
5141
5788
  return;
5142
5789
  }
5143
- return;
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
+ }
5816
+ if (mode === "confirm-kill" || mode === "confirm-delete") {
5817
+ if (data?.isCharacter && (name === "y" || name === "Y")) {
5818
+ const kind = mode === "confirm-kill" ? "kill" : "delete";
5819
+ void performAction(kind);
5820
+ return;
5821
+ }
5822
+ if (name === "ESCAPE" || name === "CTRL_C" || name === "ENTER" || name === "KP_ENTER" || data?.isCharacter && (name === "n" || name === "N")) {
5823
+ mode = "normal";
5824
+ pendingAction = null;
5825
+ paintIndicator();
5826
+ return;
5827
+ }
5828
+ return;
5144
5829
  }
5145
5830
  clearTransient();
5146
5831
  if (!searchActive && data?.isCharacter && name === "?") {
@@ -5230,6 +5915,29 @@ async function pickSession(term, opts) {
5230
5915
  paintIndicator();
5231
5916
  return;
5232
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
+ }
5233
5941
  if ((name === "d" || name === "D") && selectedIdx > 0) {
5234
5942
  const session = visible[selectedIdx - 1];
5235
5943
  if (!session) {
@@ -5360,6 +6068,8 @@ var init_picker = __esm({
5360
6068
  null,
5361
6069
  ["k", "kill the selected live session"],
5362
6070
  ["d", "delete the selected cold session"],
6071
+ ["t", "retitle the selected session"],
6072
+ ["T", "regenerate title via agent (live session)"],
5363
6073
  null,
5364
6074
  ["c", "create new session"],
5365
6075
  ["?", "toggle this help"],
@@ -5947,6 +6657,12 @@ function mapKeyName(name) {
5947
6657
  case "ALT_ENTER":
5948
6658
  case "META_ENTER":
5949
6659
  return "alt-enter";
6660
+ case "SHIFT_ENTER":
6661
+ return "shift-enter";
6662
+ case "CTRL_ENTER":
6663
+ return "ctrl-enter";
6664
+ case "CTRL_J":
6665
+ return "ctrl-enter";
5950
6666
  case "ALT_B":
5951
6667
  case "META_B":
5952
6668
  return "alt-b";
@@ -6216,12 +6932,15 @@ var init_screen = __esm({
6216
6932
  this.term.fullscreen(false);
6217
6933
  this.term("\n");
6218
6934
  }
6219
- // Enables bracketed paste mode on the terminal and rewires stdin so we
6220
- // see the \x1b[200~/\x1b[201~ markers BEFORE terminal-kit's key
6221
- // parser. Non-paste data is forwarded to terminal-kit unchanged; paste
6222
- // content is buffered and dispatched as a single "paste" KeyEvent.
6935
+ // Enables bracketed paste mode + modifyOtherKeys on the terminal and
6936
+ // rewires stdin so we see the \x1b[200~/\x1b[201~ paste markers and
6937
+ // CSI-u modified-key sequences (Shift+Enter etc.) BEFORE terminal-kit's
6938
+ // key parser. Non-special data is forwarded to terminal-kit unchanged.
6223
6939
  installBracketedPaste() {
6224
6940
  process.stdout.write("\x1B[?2004h");
6941
+ process.stdout.write("\x1B[>4;2m");
6942
+ process.stdout.write("\x1B[>5;1m");
6943
+ process.stdout.write("\x1B[>1u");
6225
6944
  const t = this.term;
6226
6945
  if (!t.stdin || typeof t.onStdin !== "function") {
6227
6946
  return;
@@ -6232,6 +6951,9 @@ var init_screen = __esm({
6232
6951
  }
6233
6952
  uninstallBracketedPaste() {
6234
6953
  process.stdout.write("\x1B[?2004l");
6954
+ process.stdout.write("\x1B[>4;0m");
6955
+ process.stdout.write("\x1B[>5;0m");
6956
+ process.stdout.write("\x1B[<u");
6235
6957
  const t = this.term;
6236
6958
  if (!t.stdin || this.terminalKitStdinHandler === null) {
6237
6959
  return;
@@ -6244,6 +6966,38 @@ var init_screen = __esm({
6244
6966
  }
6245
6967
  handleRawStdin(chunk) {
6246
6968
  let text = chunk.toString("binary");
6969
+ if (!this.pasteActive) {
6970
+ const markers = [
6971
+ { seq: "\x1B[13;2u", name: "shift-enter" },
6972
+ { seq: "\x1B[27;2;13~", name: "shift-enter" },
6973
+ { seq: "\x1B[13;5u", name: "ctrl-enter" },
6974
+ { seq: "\x1B[27;5;13~", name: "ctrl-enter" },
6975
+ // Bare LF — universal fallback for terminals without
6976
+ // modifyOtherKeys / kitty protocol. Last so the longer escape
6977
+ // sequences match first and we don't double-fire.
6978
+ { seq: "\n", name: "ctrl-enter" }
6979
+ ];
6980
+ for (const { seq, name } of markers) {
6981
+ if (text.includes(seq)) {
6982
+ const parts = text.split(seq);
6983
+ for (let i = 0; i < parts.length; i++) {
6984
+ if (parts[i].length > 0) {
6985
+ this.handleRawStdin(Buffer.from(parts[i], "binary"));
6986
+ }
6987
+ if (i < parts.length - 1) {
6988
+ this.onKey([{ type: "key", name }]);
6989
+ }
6990
+ }
6991
+ return;
6992
+ }
6993
+ }
6994
+ }
6995
+ this.handleRawStdinSegment(text);
6996
+ }
6997
+ // Inner stdin-segment handler — paste-marker detection and forwarding
6998
+ // to terminal-kit. Split out so shift-enter interception can call it
6999
+ // for the non-shift-enter portions of a mixed chunk.
7000
+ handleRawStdinSegment(text) {
6247
7001
  const startMarker = "\x1B[200~";
6248
7002
  const endMarker = "\x1B[201~";
6249
7003
  while (text.length > 0) {
@@ -7534,11 +8288,16 @@ var init_screen = __esm({
7534
8288
  const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
7535
8289
  const right = this.bannerRightContent();
7536
8290
  const rightSig = right ? `${right.kind}|${right.text}` : "";
7537
- 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;
7538
8293
  this.paintRow(row, sig, () => {
7539
8294
  const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
7540
8295
  if (this.banner.status === "busy") {
7541
- 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
+ }
7542
8301
  if (elapsedStr) {
7543
8302
  this.term(" ").dim(elapsedStr);
7544
8303
  }
@@ -7944,6 +8703,9 @@ var init_input = __esm({
7944
8703
  switch (name) {
7945
8704
  case "enter":
7946
8705
  return this.send();
8706
+ case "shift-enter":
8707
+ case "ctrl-enter":
8708
+ return this.amend();
7947
8709
  case "alt-enter":
7948
8710
  this.insertNewline();
7949
8711
  return [];
@@ -8130,22 +8892,64 @@ var init_input = __esm({
8130
8892
  this.setCurrentLine(line + next);
8131
8893
  }
8132
8894
  }
8895
+ // ^U: kill from cursor to start of current line. At col 0 with a line
8896
+ // above:
8897
+ // - If the current line is empty, collapse it (kill just the
8898
+ // newline) so the cursor lands at the end of the previous line.
8899
+ // Don't slurp that line's contents.
8900
+ // - Otherwise, kill the previous line entirely + the joining
8901
+ // newline, so ^U from the start of a non-empty line walks up
8902
+ // line-by-line.
8903
+ // Single-line behavior is unchanged.
8133
8904
  killLine() {
8134
- const line = this.currentLine();
8135
- const killed = line.slice(0, this.col);
8136
- if (killed.length > 0) {
8137
- this.killBuffer = killed;
8905
+ if (this.col > 0) {
8906
+ const line = this.currentLine();
8907
+ this.killBuffer = line.slice(0, this.col);
8908
+ this.setCurrentLine(line.slice(this.col));
8909
+ this.col = 0;
8910
+ return;
8138
8911
  }
8139
- this.setCurrentLine(line.slice(this.col));
8140
- this.col = 0;
8912
+ if (this.row === 0) {
8913
+ return;
8914
+ }
8915
+ if (this.currentLine().length === 0) {
8916
+ this.killBuffer = "\n";
8917
+ this.buffer.splice(this.row, 1);
8918
+ this.row -= 1;
8919
+ this.col = this.currentLine().length;
8920
+ return;
8921
+ }
8922
+ const prev = this.buffer[this.row - 1] ?? "";
8923
+ this.killBuffer = prev + "\n";
8924
+ this.buffer.splice(this.row - 1, 1);
8925
+ this.row -= 1;
8141
8926
  }
8927
+ // ^K: kill from cursor to end of current line. At end-of-line with a
8928
+ // line below:
8929
+ // - If the current line is empty, collapse it (kill just the
8930
+ // newline) so what was the next line takes its place. Don't slurp
8931
+ // that line's contents.
8932
+ // - Otherwise, kill the joining newline + the entire next line, so
8933
+ // ^K from the end of a non-empty line walks down line-by-line.
8934
+ // Single-line behavior is unchanged.
8142
8935
  killToEnd() {
8143
8936
  const line = this.currentLine();
8144
- const killed = line.slice(this.col);
8145
- if (killed.length > 0) {
8146
- this.killBuffer = killed;
8937
+ if (this.col < line.length) {
8938
+ this.killBuffer = line.slice(this.col);
8939
+ this.setCurrentLine(line.slice(0, this.col));
8940
+ return;
8941
+ }
8942
+ if (this.row >= this.buffer.length - 1) {
8943
+ return;
8147
8944
  }
8148
- this.setCurrentLine(line.slice(0, this.col));
8945
+ if (line.length === 0) {
8946
+ this.killBuffer = "\n";
8947
+ this.buffer.splice(this.row, 1);
8948
+ return;
8949
+ }
8950
+ const next = this.buffer[this.row + 1] ?? "";
8951
+ this.killBuffer = "\n" + next;
8952
+ this.buffer.splice(this.row + 1, 1);
8149
8953
  }
8150
8954
  killWord() {
8151
8955
  const line = this.currentLine();
@@ -8494,6 +9298,31 @@ var init_input = __esm({
8494
9298
  this.clearBuffer();
8495
9299
  return [{ type: "send", text, planMode, attachments }];
8496
9300
  }
9301
+ // Shift+Enter: amend the in-flight turn. Editing a queued slot
9302
+ // delegates to the existing queue-edit / queue-remove path — Shift+Enter
9303
+ // there has no special meaning since the entry is already queued (not
9304
+ // running). With an empty draft and no attachments we emit nothing
9305
+ // (no-op). Otherwise emit an "amend" effect; the app decides whether
9306
+ // to route through amend_prompt or fall through to a regular send.
9307
+ amend() {
9308
+ const text = this.bufferText();
9309
+ if (this.queueIndex >= 0 && this.queueIndex < this.queue.length) {
9310
+ const index = this.queueIndex;
9311
+ const attachments2 = [...this.attachments];
9312
+ this.clearBuffer();
9313
+ if (text.trim().length === 0) {
9314
+ return [{ type: "queue-remove", index }];
9315
+ }
9316
+ return [{ type: "queue-edit", index, text, attachments: attachments2 }];
9317
+ }
9318
+ if (text.trim().length === 0 && this.attachments.length === 0) {
9319
+ return [];
9320
+ }
9321
+ const planMode = this.planMode;
9322
+ const attachments = [...this.attachments];
9323
+ this.clearBuffer();
9324
+ return [{ type: "amend", text, planMode, attachments }];
9325
+ }
8497
9326
  // Home: jump to the very start of the prompt buffer. If we're already
8498
9327
  // there, fall through to scrolling the scrollback to its top.
8499
9328
  handleHome() {
@@ -8605,26 +9434,34 @@ async function readLinux(env) {
8605
9434
  reason: "install wl-clipboard (Wayland) or xclip (X11) to paste from the clipboard"
8606
9435
  };
8607
9436
  }
8608
- try {
8609
- const buf = await runCapture(env.spawn, tool.cmd, tool.imageArgs);
8610
- if (buf.length > 0) {
8611
- if (buf.length > MAX_ATTACHMENT_BYTES) {
9437
+ const targets = await listTargets(env, tool);
9438
+ const imageMime = pickImageTarget(targets);
9439
+ if (imageMime) {
9440
+ try {
9441
+ const buf = await runCapture(
9442
+ env.spawn,
9443
+ tool.cmd,
9444
+ tool.imageArgs(imageMime)
9445
+ );
9446
+ if (buf.length > 0) {
9447
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
9448
+ return {
9449
+ ok: false,
9450
+ reason: `clipboard image is ${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)}`
9451
+ };
9452
+ }
8612
9453
  return {
8613
- ok: false,
8614
- reason: `clipboard image is ${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)}`
9454
+ ok: true,
9455
+ kind: "image",
9456
+ attachment: {
9457
+ mimeType: imageMime,
9458
+ data: buf.toString("base64"),
9459
+ sizeBytes: buf.length
9460
+ }
8615
9461
  };
8616
9462
  }
8617
- return {
8618
- ok: true,
8619
- kind: "image",
8620
- attachment: {
8621
- mimeType: "image/png",
8622
- data: buf.toString("base64"),
8623
- sizeBytes: buf.length
8624
- }
8625
- };
9463
+ } catch {
8626
9464
  }
8627
- } catch {
8628
9465
  }
8629
9466
  try {
8630
9467
  const buf = await runCapture(env.spawn, tool.cmd, tool.textArgs);
@@ -8644,7 +9481,8 @@ async function detectLinuxTool(env) {
8644
9481
  if (env.env.WAYLAND_DISPLAY && await which(env, "wl-paste")) {
8645
9482
  return {
8646
9483
  cmd: "wl-paste",
8647
- imageArgs: ["-t", "image/png"],
9484
+ listTargetsArgs: ["--list-types"],
9485
+ imageArgs: (mime) => ["-t", mime],
8648
9486
  // -n: drop trailing newline wl-paste adds by default. We further
8649
9487
  // normalize line endings below, but this avoids a spurious
8650
9488
  // empty trailing row from a single-line clipboard text.
@@ -8654,12 +9492,30 @@ async function detectLinuxTool(env) {
8654
9492
  if (env.env.DISPLAY && await which(env, "xclip")) {
8655
9493
  return {
8656
9494
  cmd: "xclip",
8657
- imageArgs: ["-selection", "clipboard", "-t", "image/png", "-o"],
9495
+ listTargetsArgs: ["-selection", "clipboard", "-t", "TARGETS", "-o"],
9496
+ imageArgs: (mime) => ["-selection", "clipboard", "-t", mime, "-o"],
8658
9497
  textArgs: ["-selection", "clipboard", "-o"]
8659
9498
  };
8660
9499
  }
8661
9500
  return null;
8662
9501
  }
9502
+ function pickImageTarget(targets) {
9503
+ const offered = new Set(targets.map((t) => t.toLowerCase()));
9504
+ for (const mime of SUPPORTED_IMAGE_MIMES) {
9505
+ if (offered.has(mime)) {
9506
+ return mime;
9507
+ }
9508
+ }
9509
+ return null;
9510
+ }
9511
+ async function listTargets(env, tool) {
9512
+ try {
9513
+ const buf = await runCapture(env.spawn, tool.cmd, tool.listTargetsArgs);
9514
+ return buf.toString("utf-8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
9515
+ } catch {
9516
+ return [];
9517
+ }
9518
+ }
8663
9519
  function normalizeText(text) {
8664
9520
  return text.replace(/\r\n?/g, "\n");
8665
9521
  }
@@ -8754,7 +9610,7 @@ function runCapture(spawn6, cmd, args) {
8754
9610
  });
8755
9611
  });
8756
9612
  }
8757
- var defaultEnv;
9613
+ var defaultEnv, SUPPORTED_IMAGE_MIMES;
8758
9614
  var init_clipboard = __esm({
8759
9615
  "src/tui/clipboard.ts"() {
8760
9616
  "use strict";
@@ -8765,6 +9621,12 @@ var init_clipboard = __esm({
8765
9621
  spawn: nodeSpawn,
8766
9622
  tmpdir: os4.tmpdir
8767
9623
  };
9624
+ SUPPORTED_IMAGE_MIMES = [
9625
+ "image/png",
9626
+ "image/jpeg",
9627
+ "image/gif",
9628
+ "image/webp"
9629
+ ];
8768
9630
  }
8769
9631
  });
8770
9632
 
@@ -8812,8 +9674,29 @@ var init_completion = __esm({
8812
9674
  }
8813
9675
  });
8814
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
+
8815
9698
  // src/tui/format.ts
8816
- import chalk from "chalk";
9699
+ import chalk2 from "chalk";
8817
9700
  import { highlight, supportsLanguage } from "cli-highlight";
8818
9701
  function formatEvent(event) {
8819
9702
  switch (event.kind) {
@@ -8905,7 +9788,8 @@ function parseAgentMarkdown(text) {
8905
9788
  codeBuffer = [];
8906
9789
  codeLang = "";
8907
9790
  };
8908
- for (const line of lines) {
9791
+ for (let i = 0; i < lines.length; i++) {
9792
+ const line = lines[i];
8909
9793
  const fence = line.match(/^\s*```\s*(\w*)\s*$/);
8910
9794
  if (fence) {
8911
9795
  if (!inCode) {
@@ -8933,6 +9817,19 @@ function parseAgentMarkdown(text) {
8933
9817
  });
8934
9818
  continue;
8935
9819
  }
9820
+ const next = lines[i + 1];
9821
+ if (line.includes("|") && next !== void 0 && isTableSeparatorLine(next) && parseTableRow(line).length === parseTableRow(next).length) {
9822
+ const header = parseTableRow(line);
9823
+ const body = [];
9824
+ let j = i + 2;
9825
+ while (j < lines.length && lines[j].includes("|")) {
9826
+ body.push(parseTableRow(lines[j]));
9827
+ j++;
9828
+ }
9829
+ out.push(...formatTable(header, body));
9830
+ i = j - 1;
9831
+ continue;
9832
+ }
8936
9833
  const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
8937
9834
  if (bullet) {
8938
9835
  const indent = bullet[1] ?? "";
@@ -8967,6 +9864,70 @@ function parseAgentMarkdown(text) {
8967
9864
  }
8968
9865
  return out;
8969
9866
  }
9867
+ function parseTableRow(line) {
9868
+ let s = line.trim();
9869
+ if (s.startsWith("|")) {
9870
+ s = s.slice(1);
9871
+ }
9872
+ if (s.endsWith("|")) {
9873
+ s = s.slice(0, -1);
9874
+ }
9875
+ return s.split("|").map((c) => c.trim());
9876
+ }
9877
+ function isTableSeparatorLine(line) {
9878
+ if (!line.includes("|")) {
9879
+ return false;
9880
+ }
9881
+ const cells = parseTableRow(line);
9882
+ if (cells.length === 0) {
9883
+ return false;
9884
+ }
9885
+ return cells.every((c) => /^:?-+:?$/.test(c));
9886
+ }
9887
+ function formatTable(header, body) {
9888
+ const cols = header.length;
9889
+ const widths = new Array(cols).fill(0);
9890
+ for (let c = 0; c < cols; c++) {
9891
+ widths[c] = header[c]?.length ?? 0;
9892
+ }
9893
+ for (const row of body) {
9894
+ for (let c = 0; c < cols; c++) {
9895
+ const cell = row[c] ?? "";
9896
+ if (cell.length > widths[c]) {
9897
+ widths[c] = cell.length;
9898
+ }
9899
+ }
9900
+ }
9901
+ const renderRow = (cells, style) => {
9902
+ const padded = [];
9903
+ for (let c = 0; c < cols; c++) {
9904
+ const cell = cells[c] ?? "";
9905
+ const w = widths[c];
9906
+ const marked = applyInlineMarkup(cell);
9907
+ padded.push(marked + " ".repeat(Math.max(0, w - cell.length)));
9908
+ }
9909
+ return {
9910
+ prefix: " ",
9911
+ body: padded.join(" \u2502 "),
9912
+ bodyStyle: style
9913
+ };
9914
+ };
9915
+ const out = [];
9916
+ out.push(renderRow(header, "heading-3"));
9917
+ const rules = [];
9918
+ for (let c = 0; c < cols; c++) {
9919
+ rules.push("\u2500".repeat(widths[c]));
9920
+ }
9921
+ out.push({
9922
+ prefix: " ",
9923
+ body: rules.join("\u2500\u253C\u2500"),
9924
+ bodyStyle: "dim"
9925
+ });
9926
+ for (const row of body) {
9927
+ out.push(renderRow(row, "agent"));
9928
+ }
9929
+ return out;
9930
+ }
8970
9931
  function highlightFencedBlock(lang, lines) {
8971
9932
  if (lang.length === 0 || !supportsLanguage(lang)) {
8972
9933
  return lines.map((body) => ({ body, ansi: false }));
@@ -9028,12 +9989,22 @@ function formatToolLine2(state) {
9028
9989
  } else {
9029
9990
  title = `${initial} \xB7 ${latest}`;
9030
9991
  }
9031
- return {
9032
- prefix: ` ${toolStatusIcon(state.status)} `,
9033
- prefixStyle: toolIconStyle(state.status),
9034
- body: title,
9035
- bodyStyle: toolStatusStyle(state.status)
9036
- };
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;
9037
10008
  }
9038
10009
  function toolStatusIcon(status) {
9039
10010
  switch (status) {
@@ -9132,7 +10103,8 @@ var highlightChalk, HIGHLIGHT_THEME;
9132
10103
  var init_format = __esm({
9133
10104
  "src/tui/format.ts"() {
9134
10105
  "use strict";
9135
- highlightChalk = new chalk.Instance({ level: 3 });
10106
+ init_render_update();
10107
+ highlightChalk = new chalk2.Instance({ level: 3 });
9136
10108
  HIGHLIGHT_THEME = {
9137
10109
  keyword: highlightChalk.blueBright,
9138
10110
  built_in: highlightChalk.cyan,
@@ -9194,8 +10166,9 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9194
10166
  term.grabInput(false);
9195
10167
  process.exit(0);
9196
10168
  }
9197
- const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
9198
- 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);
9199
10172
  const protocol = config.daemon.tls ? "wss" : "ws";
9200
10173
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
9201
10174
  const subprotocols = ["acp.v1", `hydra-acp-token.${serviceToken}`];
@@ -9221,6 +10194,13 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9221
10194
  });
9222
10195
  const conn = new JsonRpcConnection(stream);
9223
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
+ });
9224
10204
  let bufferedEvents = [];
9225
10205
  let applyRenderEvent = null;
9226
10206
  let teardownStarted = false;
@@ -9235,36 +10215,49 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9235
10215
  }
9236
10216
  };
9237
10217
  let pendingTurns = 0;
10218
+ let currentHeadMessageId;
9238
10219
  let sessionBusySince = null;
9239
10220
  let sessionElapsedTimer = null;
10221
+ let lastUpdateAt = null;
10222
+ let upstreamInterruptedSeen = false;
9240
10223
  const adjustPendingTurns = (delta) => {
9241
10224
  const before = pendingTurns;
9242
10225
  pendingTurns = Math.max(0, pendingTurns + delta);
9243
10226
  const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
9244
10227
  if (before === 0 && pendingTurns > 0) {
9245
10228
  sessionBusySince = Date.now();
10229
+ lastUpdateAt = Date.now();
9246
10230
  dispatcherRef?.setTurnRunning(true);
9247
10231
  if (screenReady) {
9248
- screenRef.setBanner({ status: "busy", elapsedMs: 0 });
10232
+ screenRef.setBanner({ status: "busy", elapsedMs: 0, stalled: false });
9249
10233
  }
9250
10234
  if (sessionElapsedTimer === null && screenReady) {
9251
10235
  sessionElapsedTimer = setInterval(() => {
9252
10236
  if (sessionBusySince === null || screenRef === null) {
9253
10237
  return;
9254
10238
  }
9255
- 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
+ });
9256
10244
  renderToolsBlock();
9257
10245
  }, 1e3);
9258
10246
  }
9259
10247
  } else if (before > 0 && pendingTurns === 0) {
9260
10248
  sessionBusySince = null;
10249
+ lastUpdateAt = null;
9261
10250
  dispatcherRef?.setTurnRunning(false);
9262
10251
  if (sessionElapsedTimer !== null) {
9263
10252
  clearInterval(sessionElapsedTimer);
9264
10253
  sessionElapsedTimer = null;
9265
10254
  }
9266
10255
  if (screenReady) {
9267
- screenRef.setBanner({ status: "ready", elapsedMs: void 0 });
10256
+ screenRef.setBanner({
10257
+ status: "ready",
10258
+ elapsedMs: void 0,
10259
+ stalled: false
10260
+ });
9268
10261
  }
9269
10262
  }
9270
10263
  void delta;
@@ -9285,6 +10278,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9285
10278
  const { update } = params ?? {};
9286
10279
  const event = mapUpdate(update);
9287
10280
  debugLogUpdate(update, event);
10281
+ lastUpdateAt = Date.now();
9288
10282
  const rawTag = update?.sessionUpdate;
9289
10283
  if (typeof rawTag === "string" && !STATE_UPDATE_KINDS2.has(rawTag)) {
9290
10284
  const u = update ?? {};
@@ -9326,13 +10320,29 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9326
10320
  screenRef.setBanner({ status: "cold", elapsedMs: void 0 });
9327
10321
  }
9328
10322
  });
10323
+ const amendPendingPaintTimers = /* @__PURE__ */ new Map();
10324
+ const AMEND_CHIP_DISPLAY_DELAY_MS = 200;
9329
10325
  conn.onNotification("hydra-acp/prompt_queue_added", (params) => {
9330
10326
  if (teardownStarted) return;
9331
10327
  const p = params ?? {};
9332
10328
  if (typeof p.messageId !== "string") return;
9333
- queueCache.set(p.messageId, chipFromPrompt(p.messageId, p.prompt));
9334
- if (screenRef && dispatcherRef) {
9335
- refreshQueueDisplay();
10329
+ const isAmendPending = typeof p._meta?.["hydra-acp"]?.amending === "string";
10330
+ if (isAmendPending) {
10331
+ const mid = p.messageId;
10332
+ const prompt = p.prompt;
10333
+ const timer = setTimeout(() => {
10334
+ amendPendingPaintTimers.delete(mid);
10335
+ queueCache.set(mid, chipFromPrompt(mid, prompt));
10336
+ if (screenRef && dispatcherRef) {
10337
+ refreshQueueDisplay();
10338
+ }
10339
+ }, AMEND_CHIP_DISPLAY_DELAY_MS);
10340
+ amendPendingPaintTimers.set(mid, timer);
10341
+ } else {
10342
+ queueCache.set(p.messageId, chipFromPrompt(p.messageId, p.prompt));
10343
+ if (screenRef && dispatcherRef) {
10344
+ refreshQueueDisplay();
10345
+ }
9336
10346
  }
9337
10347
  if (ownClientId !== void 0 && p.originator?.clientId === ownClientId) {
9338
10348
  const echo = pendingEchoes.shift();
@@ -9377,6 +10387,14 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9377
10387
  if (teardownStarted) return;
9378
10388
  const p = params ?? {};
9379
10389
  if (typeof p.messageId !== "string") return;
10390
+ if (p.reason === "started") {
10391
+ currentHeadMessageId = p.messageId;
10392
+ }
10393
+ const pendingTimer = amendPendingPaintTimers.get(p.messageId);
10394
+ if (pendingTimer !== void 0) {
10395
+ clearTimeout(pendingTimer);
10396
+ amendPendingPaintTimers.delete(p.messageId);
10397
+ }
9380
10398
  const hadChip = queueCache.delete(p.messageId);
9381
10399
  if (hadChip && screenRef && dispatcherRef) {
9382
10400
  refreshQueueDisplay();
@@ -9395,6 +10413,22 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9395
10413
  }
9396
10414
  }
9397
10415
  });
10416
+ conn.onNotification("hydra-acp/prompt_amended", (params) => {
10417
+ if (teardownStarted) return;
10418
+ const p = params ?? {};
10419
+ if (typeof p.cancelledMessageId !== "string") return;
10420
+ const cancelledId = p.cancelledMessageId;
10421
+ amendedMessageIds.add(cancelledId);
10422
+ if (currentTurnEcho !== null && currentTurnEcho.messageId !== void 0 && currentTurnEcho.messageId === cancelledId) {
10423
+ appendRender({
10424
+ kind: "turn-complete",
10425
+ stopReason: "cancelled",
10426
+ amended: true
10427
+ });
10428
+ currentTurnEcho = null;
10429
+ amendedMessageIds.delete(cancelledId);
10430
+ }
10431
+ });
9398
10432
  const handlePermissionResolved = (update) => {
9399
10433
  const u = update ?? {};
9400
10434
  const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : void 0;
@@ -9502,6 +10536,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9502
10536
  let upstreamSessionId;
9503
10537
  let agentInfoName;
9504
10538
  let agentAcceptsImages = true;
10539
+ let daemonSupportsAmend = false;
9505
10540
  try {
9506
10541
  const initResult = await conn.request("initialize", {
9507
10542
  protocolVersion: ACP_PROTOCOL_VERSION,
@@ -9516,6 +10551,8 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9516
10551
  if (imageCap === false) {
9517
10552
  agentAcceptsImages = false;
9518
10553
  }
10554
+ const hydraMeta = extractHydraMeta(initResult?._meta ?? void 0);
10555
+ daemonSupportsAmend = hydraMeta.promptAmending === true;
9519
10556
  } catch {
9520
10557
  }
9521
10558
  let resolvedSessionId = ctx.sessionId;
@@ -9811,6 +10848,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9811
10848
  };
9812
10849
  const sessionbarAgent = resolvedAgentId || agentInfoName || "?";
9813
10850
  const usage = { ...initialUsage ?? {} };
10851
+ installStatus.finalize();
9814
10852
  screen.start();
9815
10853
  screen.setSessionbar({
9816
10854
  agent: sessionbarAgent,
@@ -9916,6 +10954,18 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9916
10954
  }
9917
10955
  return true;
9918
10956
  };
10957
+ const buildHelpEntries = () => {
10958
+ const enqueueDesc = "enqueue prompt (sends now, or queues during a turn)";
10959
+ const amendDesc = "amend the in-flight turn (cancel + replace)";
10960
+ const head = config.tui.defaultEnterAction === "amend" ? [
10961
+ ["Enter", amendDesc],
10962
+ ["Ctrl+Enter / Shift+Enter", enqueueDesc]
10963
+ ] : [
10964
+ ["Enter", enqueueDesc],
10965
+ ["Ctrl+Enter / Shift+Enter", amendDesc]
10966
+ ];
10967
+ return [...head, ...HELP_ENTRIES_TAIL];
10968
+ };
9919
10969
  const toggleHelpModal = () => {
9920
10970
  if (screen.isHelpPromptActive()) {
9921
10971
  screen.setHelpPrompt(null);
@@ -9923,7 +10973,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
9923
10973
  }
9924
10974
  screen.setHelpPrompt({
9925
10975
  title: "Hotkeys",
9926
- entries: HELP_ENTRIES2,
10976
+ entries: buildHelpEntries(),
9927
10977
  hint: "any key dismisses \xB7 /help lists commands"
9928
10978
  });
9929
10979
  };
@@ -10026,7 +11076,18 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10026
11076
  const handleEffect = (effect) => {
10027
11077
  switch (effect.type) {
10028
11078
  case "send":
10029
- enqueuePrompt(effect.text, effect.attachments);
11079
+ if (config.tui.defaultEnterAction === "amend") {
11080
+ amendPrompt(effect.text, effect.attachments);
11081
+ } else {
11082
+ enqueuePrompt(effect.text, effect.attachments);
11083
+ }
11084
+ return;
11085
+ case "amend":
11086
+ if (config.tui.defaultEnterAction === "amend") {
11087
+ enqueuePrompt(effect.text, effect.attachments);
11088
+ } else {
11089
+ amendPrompt(effect.text, effect.attachments);
11090
+ }
10030
11091
  return;
10031
11092
  case "queue-edit": {
10032
11093
  const mid = queueMessageIdAt(effect.index);
@@ -10234,6 +11295,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10234
11295
  const queueCache = /* @__PURE__ */ new Map();
10235
11296
  const pendingEchoes = [];
10236
11297
  const ownPendingByMid = /* @__PURE__ */ new Map();
11298
+ const amendedMessageIds = /* @__PURE__ */ new Set();
10237
11299
  let currentTurnEcho = null;
10238
11300
  const refreshQueueDisplay = () => {
10239
11301
  const entries = [...queueCache.values()];
@@ -10268,6 +11330,75 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10268
11330
  saveHistory(historyFile, history).catch(() => void 0);
10269
11331
  void runPrompt(text, attachments);
10270
11332
  };
11333
+ const amendPrompt = (text, attachments) => {
11334
+ screen.scrollToBottom();
11335
+ if (handleBuiltinCommand(text)) {
11336
+ return;
11337
+ }
11338
+ history = appendEntry(history, text);
11339
+ dispatcher.setHistory(history);
11340
+ saveHistory(historyFile, history).catch(() => void 0);
11341
+ if (!daemonSupportsAmend || currentHeadMessageId === void 0) {
11342
+ void runPrompt(text, attachments);
11343
+ return;
11344
+ }
11345
+ const target = currentHeadMessageId;
11346
+ const blocks = [];
11347
+ if (text.length > 0) {
11348
+ blocks.push({ type: "text", text });
11349
+ }
11350
+ for (const a of attachments) {
11351
+ blocks.push({ type: "image", data: a.data, mimeType: a.mimeType });
11352
+ }
11353
+ const echo = { text, attachments, flushed: false };
11354
+ pendingEchoes.push(echo);
11355
+ const popEcho = () => {
11356
+ const idx = pendingEchoes.indexOf(echo);
11357
+ if (idx >= 0) {
11358
+ pendingEchoes.splice(idx, 1);
11359
+ }
11360
+ if (echo.messageId !== void 0) {
11361
+ ownPendingByMid.delete(echo.messageId);
11362
+ }
11363
+ };
11364
+ conn.request("hydra-acp/amend_prompt", {
11365
+ sessionId: resolvedSessionId,
11366
+ targetMessageId: target,
11367
+ prompt: blocks
11368
+ }).then((raw) => {
11369
+ const res = raw;
11370
+ if (res.amended && res.reason === "ok") {
11371
+ adjustPendingTurns(1);
11372
+ return;
11373
+ }
11374
+ popEcho();
11375
+ if (res.reason === "target_completed") {
11376
+ screen.notify(
11377
+ "previous response finished \u2014 press Enter to send as a new turn"
11378
+ );
11379
+ dispatcher.setBuffer(text, attachments);
11380
+ screen.refreshPrompt();
11381
+ return;
11382
+ }
11383
+ if (res.reason === "target_cancelled") {
11384
+ screen.notify("amend skipped \u2014 previous turn was cancelled");
11385
+ dispatcher.setBuffer(text, attachments);
11386
+ screen.refreshPrompt();
11387
+ return;
11388
+ }
11389
+ if (res.reason === "target_not_found") {
11390
+ screen.notify("amend skipped \u2014 no matching prompt");
11391
+ dispatcher.setBuffer(text, attachments);
11392
+ screen.refreshPrompt();
11393
+ return;
11394
+ }
11395
+ }).catch((err) => {
11396
+ popEcho();
11397
+ screen.notify(`amend failed: ${err.message}`);
11398
+ dispatcher.setBuffer(text, attachments);
11399
+ screen.refreshPrompt();
11400
+ });
11401
+ };
10271
11402
  const handleModeToggle = async (_on) => {
10272
11403
  if (agentModes.length === 0) {
10273
11404
  screen.notify("no modes advertised by agent");
@@ -10492,9 +11623,18 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10492
11623
  turnInFlight = null;
10493
11624
  adjustPendingTurns(-1);
10494
11625
  if (echo.flushed && currentTurnEcho === echo) {
10495
- appendRender(
10496
- stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" }
10497
- );
11626
+ const wasAmended = echo.messageId !== void 0 && amendedMessageIds.has(echo.messageId);
11627
+ if (wasAmended && echo.messageId !== void 0) {
11628
+ amendedMessageIds.delete(echo.messageId);
11629
+ }
11630
+ const tc = { kind: "turn-complete" };
11631
+ if (stopReason !== void 0) {
11632
+ tc.stopReason = stopReason;
11633
+ }
11634
+ if (wasAmended) {
11635
+ tc.amended = true;
11636
+ }
11637
+ appendRender(tc);
10498
11638
  currentTurnEcho = null;
10499
11639
  }
10500
11640
  if (pendingPrefill !== null) {
@@ -10591,7 +11731,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10591
11731
  for (const id of visibleIds) {
10592
11732
  const state = toolStates.get(id);
10593
11733
  if (state) {
10594
- lines.push(formatToolLine2(state));
11734
+ lines.push(...formatToolLine2(state));
10595
11735
  }
10596
11736
  }
10597
11737
  screen.upsertLines("tools", lines);
@@ -10602,7 +11742,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10602
11742
  toolsBlockStopReason = null;
10603
11743
  renderToolsBlock();
10604
11744
  };
10605
- const recordToolCall = (id, title, status) => {
11745
+ const recordToolCall = (id, title, status, errorText) => {
10606
11746
  const wasNew = !toolStates.has(id);
10607
11747
  const existing = toolStates.get(id);
10608
11748
  const state = existing ?? {
@@ -10619,6 +11759,9 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10619
11759
  if (!existing) {
10620
11760
  state.status = status ?? "pending";
10621
11761
  }
11762
+ if (errorText !== void 0) {
11763
+ state.errorText = errorText;
11764
+ }
10622
11765
  toolStates.set(id, state);
10623
11766
  if (wasNew) {
10624
11767
  if (toolsBlockStartedAt === null) {
@@ -10710,7 +11853,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10710
11853
  }
10711
11854
  if (event.kind === "tool-call") {
10712
11855
  closeAgentText();
10713
- recordToolCall(event.toolCallId, event.title, event.status);
11856
+ recordToolCall(event.toolCallId, event.title, event.status, void 0);
10714
11857
  renderToolsBlock();
10715
11858
  return;
10716
11859
  }
@@ -10725,7 +11868,15 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10725
11868
  }
10726
11869
  if (event.kind === "tool-call-update") {
10727
11870
  closeAgentText();
10728
- 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
+ }
10729
11880
  renderToolsBlock();
10730
11881
  return;
10731
11882
  }
@@ -10737,8 +11888,13 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10737
11888
  screen.appendLines(formatted);
10738
11889
  }
10739
11890
  if (event.kind === "turn-complete") {
11891
+ currentHeadMessageId = void 0;
10740
11892
  closeAgentText();
10741
- if (lastPlanEvent !== null && event.stopReason !== void 0 && event.stopReason !== "end_turn") {
11893
+ let effectiveStopReason = event.amended ? "amended" : event.stopReason;
11894
+ if (!event.amended && upstreamInterruptedSeen && (effectiveStopReason === void 0 || effectiveStopReason === "end_turn")) {
11895
+ effectiveStopReason = "error";
11896
+ }
11897
+ if (lastPlanEvent !== null && effectiveStopReason !== void 0 && effectiveStopReason !== "end_turn") {
10742
11898
  const lines = formatEvent({ ...lastPlanEvent, stopped: true });
10743
11899
  if (lines.length > 0) {
10744
11900
  screen.upsertLines("plan", lines);
@@ -10748,15 +11904,15 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10748
11904
  screen.clearKey("plan");
10749
11905
  if (toolsBlockStartedAt !== null) {
10750
11906
  toolsBlockEndedAt = Date.now();
10751
- toolsBlockStopReason = event.stopReason ?? null;
11907
+ toolsBlockStopReason = effectiveStopReason ?? null;
10752
11908
  renderToolsBlock();
10753
11909
  screen.clearKey("tools");
10754
- } else if (event.stopReason !== void 0 && event.stopReason !== "end_turn") {
11910
+ } else if (effectiveStopReason !== void 0 && effectiveStopReason !== "end_turn") {
10755
11911
  screen.appendLines([
10756
11912
  {
10757
11913
  prefix: "\u26A0 ",
10758
11914
  prefixStyle: "tool-status-fail",
10759
- body: `turn ended: ${event.stopReason}`,
11915
+ body: `turn ended: ${effectiveStopReason}`,
10760
11916
  bodyStyle: "tool-status-fail"
10761
11917
  }
10762
11918
  ]);
@@ -10767,6 +11923,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10767
11923
  toolsBlockEndedAt = null;
10768
11924
  toolsBlockStopReason = null;
10769
11925
  toolsExpanded = false;
11926
+ upstreamInterruptedSeen = false;
10770
11927
  screen.ensureSeparator();
10771
11928
  }
10772
11929
  };
@@ -10876,9 +12033,10 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10876
12033
  if (resp.error) {
10877
12034
  throw new Error(resp.error.message);
10878
12035
  }
10879
- const result = resp.result ?? {};
10880
- if (typeof result.historyPolicy === "string") {
10881
- appliedPolicy = result.historyPolicy;
12036
+ const fields = parseReattachResponse(resp.result);
12037
+ appliedPolicy = fields.appliedPolicy;
12038
+ if (fields.clientId !== void 0) {
12039
+ ownClientId = fields.clientId;
10882
12040
  }
10883
12041
  } catch (err) {
10884
12042
  attachErr = err;
@@ -11004,21 +12162,110 @@ function writeDebugLine(payload) {
11004
12162
  } catch {
11005
12163
  }
11006
12164
  }
11007
- function rotateIfBig(target) {
11008
- try {
11009
- const stat4 = statSync(target);
11010
- if (stat4.size < logMaxBytes) {
12165
+ function createInstallStatusLine(term, baseLabel) {
12166
+ let finalized = false;
12167
+ let lastText = "";
12168
+ let osc94Active = false;
12169
+ const writeOsc94 = (state) => {
12170
+ if (finalized) {
11011
12171
  return;
11012
12172
  }
11013
- renameSync(target, `${target}.0`);
11014
- } catch {
11015
- }
11016
- }
11017
- var HELP_ENTRIES2, logMaxBytes;
11018
- var init_app = __esm({
11019
- "src/tui/app.ts"() {
11020
- "use strict";
11021
- init_connection();
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
+ }
12254
+ function rotateIfBig(target) {
12255
+ try {
12256
+ const stat4 = statSync(target);
12257
+ if (stat4.size < logMaxBytes) {
12258
+ return;
12259
+ }
12260
+ renameSync(target, `${target}.0`);
12261
+ } catch {
12262
+ }
12263
+ }
12264
+ var STALL_THRESHOLD_MS, HELP_ENTRIES_TAIL, logMaxBytes;
12265
+ var init_app = __esm({
12266
+ "src/tui/app.ts"() {
12267
+ "use strict";
12268
+ init_connection();
11022
12269
  init_types();
11023
12270
  init_resilient_ws();
11024
12271
  init_config();
@@ -11036,10 +12283,11 @@ var init_app = __esm({
11036
12283
  init_attachments();
11037
12284
  init_clipboard();
11038
12285
  init_completion();
12286
+ init_reconnect_state();
11039
12287
  init_render_update();
11040
12288
  init_format();
11041
- HELP_ENTRIES2 = [
11042
- ["Enter", "send prompt (or queue while a turn is running)"],
12289
+ STALL_THRESHOLD_MS = 12e4;
12290
+ HELP_ENTRIES_TAIL = [
11043
12291
  ["Alt+Enter", "newline in prompt"],
11044
12292
  ["Shift+Tab", "cycle agent modes (plan / accept-edits / etc.)"],
11045
12293
  ["Tab", "indent \xB7 slash-command completion"],
@@ -11200,6 +12448,7 @@ init_config();
11200
12448
  init_service_token();
11201
12449
  import * as fsp7 from "fs/promises";
11202
12450
  import { setTimeout as sleep2 } from "timers/promises";
12451
+ import chalk from "chalk";
11203
12452
 
11204
12453
  // src/daemon/server.ts
11205
12454
  init_config();
@@ -11268,8 +12517,10 @@ async function ensureBinary(args) {
11268
12517
  }
11269
12518
  await downloadAndExtract({
11270
12519
  agentId: args.agentId,
12520
+ version: args.version,
11271
12521
  archiveUrl: args.target.archive,
11272
- installDir
12522
+ installDir,
12523
+ onProgress: args.onProgress
11273
12524
  });
11274
12525
  if (!await fileExists(cmdPath)) {
11275
12526
  throw new Error(
@@ -11289,9 +12540,16 @@ async function downloadAndExtract(args) {
11289
12540
  const archivePath = await downloadTo({
11290
12541
  url: args.archiveUrl,
11291
12542
  dir: tempDir,
11292
- agentId: args.agentId
12543
+ agentId: args.agentId,
12544
+ version: args.version,
12545
+ onProgress: args.onProgress
11293
12546
  });
11294
12547
  logSink(`hydra-acp: extracting ${args.agentId}`);
12548
+ safeEmit(args.onProgress, {
12549
+ phase: "extract",
12550
+ agentId: args.agentId,
12551
+ version: args.version
12552
+ });
11295
12553
  await extract(archivePath, tempDir);
11296
12554
  await fsp.unlink(archivePath).catch(() => void 0);
11297
12555
  try {
@@ -11302,16 +12560,35 @@ async function downloadAndExtract(args) {
11302
12560
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(
11303
12561
  () => void 0
11304
12562
  );
12563
+ safeEmit(args.onProgress, {
12564
+ phase: "installed",
12565
+ agentId: args.agentId,
12566
+ version: args.version
12567
+ });
11305
12568
  return;
11306
12569
  }
11307
12570
  throw err;
11308
12571
  }
11309
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
+ });
11310
12578
  } catch (err) {
11311
12579
  await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
11312
12580
  throw err;
11313
12581
  }
11314
12582
  }
12583
+ function safeEmit(cb, event) {
12584
+ if (!cb) {
12585
+ return;
12586
+ }
12587
+ try {
12588
+ cb(event);
12589
+ } catch {
12590
+ }
12591
+ }
11315
12592
  async function downloadTo(args) {
11316
12593
  const filename = inferArchiveName(args.url);
11317
12594
  const dest = path2.join(args.dir, filename);
@@ -11324,17 +12601,34 @@ async function downloadTo(args) {
11324
12601
  const total = Number(response.headers.get("content-length") ?? "0");
11325
12602
  const out = fs4.createWriteStream(dest);
11326
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
+ });
11327
12610
  let received = 0;
11328
- let lastEmit = Date.now();
11329
- 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;
11330
12615
  nodeStream.on("data", (chunk) => {
11331
12616
  received += chunk.length;
11332
12617
  const now = Date.now();
11333
- if (now - lastEmit < EMIT_INTERVAL_MS) {
11334
- 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));
11335
12631
  }
11336
- lastEmit = now;
11337
- logSink(formatProgress(args.agentId, received, total));
11338
12632
  });
11339
12633
  await new Promise((resolve5, reject) => {
11340
12634
  nodeStream.on("error", reject);
@@ -11349,6 +12643,13 @@ async function downloadTo(args) {
11349
12643
  /* done */
11350
12644
  true
11351
12645
  ));
12646
+ safeEmit(args.onProgress, {
12647
+ phase: "download_done",
12648
+ agentId: args.agentId,
12649
+ version: args.version,
12650
+ receivedBytes: received,
12651
+ totalBytes: total
12652
+ });
11352
12653
  return dest;
11353
12654
  }
11354
12655
  function formatProgress(agentId, received, total, done = false) {
@@ -11448,9 +12749,11 @@ async function ensureNpmPackage(args) {
11448
12749
  }
11449
12750
  await installInto({
11450
12751
  agentId: args.agentId,
12752
+ version: args.version,
11451
12753
  packageSpec: args.packageSpec,
11452
12754
  installDir,
11453
- registry: args.registry
12755
+ registry: args.registry,
12756
+ onProgress: args.onProgress
11454
12757
  });
11455
12758
  if (!await fileExists2(binPath)) {
11456
12759
  throw new Error(
@@ -11466,6 +12769,12 @@ async function installInto(args) {
11466
12769
  logSink2(
11467
12770
  `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
11468
12771
  );
12772
+ safeEmit2(args.onProgress, {
12773
+ phase: "install_start",
12774
+ agentId: args.agentId,
12775
+ version: args.version,
12776
+ packageSpec: args.packageSpec
12777
+ });
11469
12778
  await runNpmInstall({
11470
12779
  packageSpec: args.packageSpec,
11471
12780
  cwd: tempDir,
@@ -11479,11 +12788,21 @@ async function installInto(args) {
11479
12788
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
11480
12789
  () => void 0
11481
12790
  );
12791
+ safeEmit2(args.onProgress, {
12792
+ phase: "installed",
12793
+ agentId: args.agentId,
12794
+ version: args.version
12795
+ });
11482
12796
  return;
11483
12797
  }
11484
12798
  throw err;
11485
12799
  }
11486
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
+ });
11487
12806
  } catch (err) {
11488
12807
  await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
11489
12808
  () => void 0
@@ -11491,44 +12810,87 @@ async function installInto(args) {
11491
12810
  throw err;
11492
12811
  }
11493
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;
11494
12824
  function runNpmInstall(args) {
11495
- return new Promise((resolve5, reject) => {
11496
- const registryArgs = args.registry ? ["--registry", args.registry] : [];
11497
- const child = spawn2(
11498
- "npm",
11499
- ["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
11500
- {
11501
- cwd: args.cwd,
11502
- stdio: ["ignore", "pipe", "pipe"]
11503
- }
11504
- );
11505
- let stderrTail = "";
11506
- child.stdout?.on("data", (chunk) => {
11507
- void chunk;
11508
- });
11509
- child.stderr?.setEncoding("utf8");
11510
- child.stderr?.on("data", (chunk) => {
11511
- stderrTail = (stderrTail + chunk).slice(-4096);
11512
- });
11513
- child.on("error", (err) => {
11514
- const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
11515
- reject(new Error(msg));
11516
- });
11517
- child.on("exit", (code, signal) => {
11518
- if (code === 0) {
11519
- 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);
11520
12847
  return;
11521
12848
  }
11522
- const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
11523
- const tail = stderrTail.trim();
11524
- reject(
11525
- new Error(
11526
- 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})
11527
12879
  stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
11528
- )
11529
- );
12880
+ )
12881
+ );
12882
+ });
11530
12883
  });
11531
- });
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
+ }
11532
12894
  }
11533
12895
  async function fileExists2(p) {
11534
12896
  try {
@@ -11726,12 +13088,14 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
11726
13088
  };
11727
13089
  }
11728
13090
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
13091
+ const npmCb = options.onInstallProgress;
11729
13092
  const binPath = await ensureNpmPackage({
11730
13093
  agentId: agent.id,
11731
13094
  version,
11732
13095
  packageSpec: npx.package,
11733
13096
  bin,
11734
- registry: options.npmRegistry
13097
+ registry: options.npmRegistry,
13098
+ onProgress: npmCb ? (e) => npmCb({ source: "npm", ...e }) : void 0
11735
13099
  });
11736
13100
  return {
11737
13101
  command: binPath,
@@ -11747,10 +13111,12 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
11747
13111
  `Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
11748
13112
  );
11749
13113
  }
13114
+ const binCb = options.onInstallProgress;
11750
13115
  const cmdPath = await ensureBinary({
11751
13116
  agentId: agent.id,
11752
13117
  version,
11753
- target
13118
+ target,
13119
+ onProgress: binCb ? (e) => binCb({ source: "binary", ...e }) : void 0
11754
13120
  });
11755
13121
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
11756
13122
  return {
@@ -12168,7 +13534,8 @@ var SessionManager = class {
12168
13534
  cwd: params.cwd,
12169
13535
  agentArgs: params.agentArgs,
12170
13536
  mcpServers: params.mcpServers,
12171
- model: params.model
13537
+ model: params.model,
13538
+ onInstallProgress: params.onInstallProgress
12172
13539
  });
12173
13540
  const session = new Session({
12174
13541
  cwd: params.cwd,
@@ -12185,7 +13552,8 @@ var SessionManager = class {
12185
13552
  historyMaxEntries: this.sessionHistoryMaxEntries,
12186
13553
  currentModel: fresh.initialModel,
12187
13554
  currentMode: fresh.initialMode,
12188
- agentModes: fresh.initialModes
13555
+ agentModes: fresh.initialModes,
13556
+ agentModels: fresh.initialModels
12189
13557
  });
12190
13558
  await this.attachManagerHooks(session);
12191
13559
  return session;
@@ -12230,7 +13598,10 @@ var SessionManager = class {
12230
13598
  if (params.upstreamSessionId === "") {
12231
13599
  return this.doResurrectFromImport(params);
12232
13600
  }
12233
- 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
+ });
12234
13605
  const agent = this.spawner({
12235
13606
  agentId: params.agentId,
12236
13607
  cwd: params.cwd,
@@ -12288,6 +13659,7 @@ var SessionManager = class {
12288
13659
  currentUsage: params.currentUsage,
12289
13660
  agentCommands: params.agentCommands,
12290
13661
  agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
13662
+ agentModels: params.agentModels ?? nonEmptyOrUndefined(extractInitialModels(loadResult ?? {})),
12291
13663
  // Only gate the first-prompt title heuristic when we actually have
12292
13664
  // a title to preserve. A title-less session (lost to a write race
12293
13665
  // or never seeded) should re-derive from the next prompt rather
@@ -12311,7 +13683,8 @@ var SessionManager = class {
12311
13683
  agentId: params.agentId,
12312
13684
  cwd,
12313
13685
  agentArgs: params.agentArgs,
12314
- mcpServers: []
13686
+ mcpServers: [],
13687
+ onInstallProgress: params.onInstallProgress
12315
13688
  });
12316
13689
  const session = new Session({
12317
13690
  sessionId: params.hydraSessionId,
@@ -12334,6 +13707,7 @@ var SessionManager = class {
12334
13707
  currentUsage: params.currentUsage,
12335
13708
  agentCommands: params.agentCommands,
12336
13709
  agentModes: params.agentModes ?? fresh.initialModes,
13710
+ agentModels: params.agentModels ?? fresh.initialModels,
12337
13711
  firstPromptSeeded: !!params.title,
12338
13712
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
12339
13713
  });
@@ -12363,7 +13737,10 @@ var SessionManager = class {
12363
13737
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
12364
13738
  throw err;
12365
13739
  }
12366
- 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
+ });
12367
13744
  const agent = this.spawner({
12368
13745
  agentId: params.agentId,
12369
13746
  cwd: params.cwd,
@@ -12389,15 +13766,25 @@ var SessionManager = class {
12389
13766
  );
12390
13767
  }
12391
13768
  let initialModel = extractInitialModel(newResult);
13769
+ const initialModels = extractInitialModels(newResult);
12392
13770
  const desired = params.model ?? this.defaultModels[params.agentId];
12393
13771
  if (desired && desired !== initialModel) {
12394
- try {
12395
- await agent.connection.request("session/set_model", {
12396
- sessionId: sessionIdRaw,
12397
- modelId: desired
12398
- });
12399
- initialModel = desired;
12400
- } 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
+ );
12401
13788
  }
12402
13789
  }
12403
13790
  const initialModes = extractInitialModes(newResult);
@@ -12407,6 +13794,7 @@ var SessionManager = class {
12407
13794
  upstreamSessionId: sessionIdRaw,
12408
13795
  agentMeta: newResult._meta,
12409
13796
  initialModel,
13797
+ initialModels: initialModels.length > 0 ? initialModels : void 0,
12410
13798
  initialModes: initialModes.length > 0 ? initialModes : void 0,
12411
13799
  initialMode
12412
13800
  };
@@ -12469,6 +13857,15 @@ var SessionManager = class {
12469
13857
  }))
12470
13858
  }).catch(() => void 0);
12471
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
+ });
12472
13869
  this.sessions.set(session.sessionId, session);
12473
13870
  await this.enqueueMetaWrite(session.sessionId, async () => {
12474
13871
  const existing = await this.store.read(session.sessionId);
@@ -12512,6 +13909,7 @@ var SessionManager = class {
12512
13909
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
12513
13910
  agentCommands: record.agentCommands,
12514
13911
  agentModes: record.agentModes,
13912
+ agentModels: record.agentModels,
12515
13913
  createdAt: record.createdAt
12516
13914
  };
12517
13915
  }
@@ -12755,6 +14153,26 @@ var SessionManager = class {
12755
14153
  const record = await this.store.read(sessionId).catch(() => void 0);
12756
14154
  return record !== void 0;
12757
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
+ }
12758
14176
  // Persist a title update from Session.setTitle. The on-disk record
12759
14177
  // was written at create time; updating it here keeps the session
12760
14178
  // record's title in sync with what was broadcast to clients so a
@@ -12807,6 +14225,7 @@ var SessionManager = class {
12807
14225
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
12808
14226
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
12809
14227
  ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
14228
+ ...update.agentModels !== void 0 ? { agentModels: update.agentModels } : {},
12810
14229
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
12811
14230
  });
12812
14231
  });
@@ -12908,6 +14327,18 @@ function mergeForPersistence(session, existing) {
12908
14327
  return out;
12909
14328
  }) : void 0;
12910
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;
12911
14342
  return recordFromMemorySession({
12912
14343
  sessionId: session.sessionId,
12913
14344
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -12924,6 +14355,7 @@ function mergeForPersistence(session, existing) {
12924
14355
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
12925
14356
  agentCommands,
12926
14357
  agentModes,
14358
+ agentModels,
12927
14359
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
12928
14360
  });
12929
14361
  }
@@ -12989,6 +14421,40 @@ function asString(value) {
12989
14421
  function nonEmptyOrUndefined(arr) {
12990
14422
  return arr.length > 0 ? arr : void 0;
12991
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
+ }
12992
14458
  function extractInitialModes(result) {
12993
14459
  const direct = parseModesList(result.availableModes);
12994
14460
  if (direct.length > 0) {
@@ -13966,6 +15432,35 @@ function registerSessionRoutes(app, manager, defaults) {
13966
15432
  }
13967
15433
  reply.code(204).send();
13968
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
+ });
13969
15464
  app.delete("/v1/sessions/:id", async (request, reply) => {
13970
15465
  const raw = request.params.id;
13971
15466
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -14508,7 +16003,8 @@ function registerAcpWsEndpoint(app, deps) {
14508
16003
  mcpServers: params.mcpServers,
14509
16004
  title: hydraMeta.name,
14510
16005
  agentArgs: hydraMeta.agentArgs,
14511
- model: hydraMeta.model
16006
+ model: hydraMeta.model,
16007
+ onInstallProgress: makeInstallProgressForwarder(connection)
14512
16008
  });
14513
16009
  const client = bindClientToSession(connection, session, state);
14514
16010
  const { entries: replay } = await session.attach(client, "full");
@@ -14524,6 +16020,7 @@ function registerAcpWsEndpoint(app, deps) {
14524
16020
  })();
14525
16021
  });
14526
16022
  const modesPayload = buildModesPayload(session);
16023
+ const modelsPayload = buildModelsPayload(session);
14527
16024
  return {
14528
16025
  sessionId: session.sessionId,
14529
16026
  // session/new is implicitly an attach; mirror session/attach's
@@ -14532,6 +16029,7 @@ function registerAcpWsEndpoint(app, deps) {
14532
16029
  // events without an extra round-trip.
14533
16030
  clientId: client.clientId,
14534
16031
  ...modesPayload ? { modes: modesPayload } : {},
16032
+ ...modelsPayload ? { models: modelsPayload } : {},
14535
16033
  _meta: buildResponseMeta(session)
14536
16034
  };
14537
16035
  });
@@ -14567,7 +16065,10 @@ function registerAcpWsEndpoint(app, deps) {
14567
16065
  err.code = JsonRpcErrorCodes.SessionNotFound;
14568
16066
  throw err;
14569
16067
  }
14570
- session = await deps.manager.resurrect(resurrectParams);
16068
+ session = await deps.manager.resurrect({
16069
+ ...resurrectParams,
16070
+ onInstallProgress: makeInstallProgressForwarder(connection)
16071
+ });
14571
16072
  }
14572
16073
  const client = bindClientToSession(
14573
16074
  connection,
@@ -14593,6 +16094,7 @@ function registerAcpWsEndpoint(app, deps) {
14593
16094
  }
14594
16095
  session.replayPendingPermissions(client);
14595
16096
  const modesPayload = buildModesPayload(session);
16097
+ const modelsPayload = buildModelsPayload(session);
14596
16098
  return {
14597
16099
  sessionId: session.sessionId,
14598
16100
  clientId: client.clientId,
@@ -14604,6 +16106,7 @@ function registerAcpWsEndpoint(app, deps) {
14604
16106
  historyPolicy: appliedPolicy,
14605
16107
  replayed: replay.length,
14606
16108
  ...modesPayload ? { modes: modesPayload } : {},
16109
+ ...modelsPayload ? { models: modelsPayload } : {},
14607
16110
  _meta: buildResponseMeta(session)
14608
16111
  };
14609
16112
  });
@@ -14711,6 +16214,22 @@ function registerAcpWsEndpoint(app, deps) {
14711
16214
  }
14712
16215
  return session.updateQueuedPrompt(params.messageId, params.prompt);
14713
16216
  });
16217
+ connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
16218
+ const params = AmendPromptParams.parse(raw);
16219
+ const att = state.attached.get(params.sessionId);
16220
+ if (!att) {
16221
+ const err = new Error("not attached to session");
16222
+ err.code = JsonRpcErrorCodes.SessionNotFound;
16223
+ throw err;
16224
+ }
16225
+ const session = deps.manager.get(params.sessionId);
16226
+ if (!session) {
16227
+ const err = new Error(`session ${params.sessionId} not found`);
16228
+ err.code = JsonRpcErrorCodes.SessionNotFound;
16229
+ throw err;
16230
+ }
16231
+ return session.amendPrompt(att.clientId, params);
16232
+ });
14714
16233
  connection.onRequest("session/load", async (raw) => {
14715
16234
  const rawObj = raw ?? {};
14716
16235
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -14743,15 +16262,39 @@ function registerAcpWsEndpoint(app, deps) {
14743
16262
  }
14744
16263
  session.replayPendingPermissions(client);
14745
16264
  const modesPayload = buildModesPayload(session);
16265
+ const modelsPayload = buildModelsPayload(session);
14746
16266
  return {
14747
16267
  sessionId: session.sessionId,
14748
16268
  // Same as session/new: include clientId so the deferred-echo
14749
16269
  // path in queue-aware clients can recognize own broadcasts.
14750
16270
  clientId: client.clientId,
14751
16271
  ...modesPayload ? { modes: modesPayload } : {},
16272
+ ...modelsPayload ? { models: modelsPayload } : {},
14752
16273
  _meta: buildResponseMeta(session)
14753
16274
  };
14754
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
+ });
14755
16298
  connection.setDefaultHandler(async (rawParams, method) => {
14756
16299
  if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
14757
16300
  const err = new Error(`Method not found: ${method}`);
@@ -14774,6 +16317,26 @@ function registerAcpWsEndpoint(app, deps) {
14774
16317
  });
14775
16318
  });
14776
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
+ }
14777
16340
  function buildModesPayload(session) {
14778
16341
  const modes = session.availableModes();
14779
16342
  if (modes.length === 0) {
@@ -14794,6 +16357,94 @@ function buildModesPayload(session) {
14794
16357
  const currentModeId = session.currentMode ?? modes[0].id;
14795
16358
  return { currentModeId, availableModes };
14796
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
+ }
14797
16448
  function buildResponseMeta(session) {
14798
16449
  const ours = {
14799
16450
  upstreamSessionId: session.upstreamSessionId,
@@ -14823,6 +16474,10 @@ function buildResponseMeta(session) {
14823
16474
  if (modes.length > 0) {
14824
16475
  ours.availableModes = modes;
14825
16476
  }
16477
+ const models = session.availableModels();
16478
+ if (models.length > 0) {
16479
+ ours.availableModels = models;
16480
+ }
14826
16481
  if (session.turnStartedAt !== void 0) {
14827
16482
  ours.turnStartedAt = session.turnStartedAt;
14828
16483
  }
@@ -14863,10 +16518,17 @@ function buildInitializeResult() {
14863
16518
  ],
14864
16519
  // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
14865
16520
  // ACP clients ignore the field; capability-aware clients learn here
14866
- // that hydra accepts concurrent session/prompt requests and emits
14867
- // prompt_queue_* notifications so they can stop running their own
14868
- // local queue.
14869
- _meta: mergeMeta(void 0, { promptQueueing: true })
16521
+ // which hydra-acp extensions the daemon supports so they can gate
16522
+ // UI surface accordingly. promptPipelining is false until the
16523
+ // streaming-input probe lands (Option A in the steering brief);
16524
+ // the others are unconditional method-availability flags.
16525
+ _meta: mergeMeta(void 0, {
16526
+ promptQueueing: true,
16527
+ promptCancelling: true,
16528
+ promptUpdating: true,
16529
+ promptAmending: true,
16530
+ promptPipelining: false
16531
+ })
14870
16532
  };
14871
16533
  }
14872
16534
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
@@ -15051,6 +16713,7 @@ function ensureLoopbackOrTls(config) {
15051
16713
 
15052
16714
  // src/cli/commands/daemon.ts
15053
16715
  init_daemon_bootstrap();
16716
+ init_hydra_version();
15054
16717
 
15055
16718
  // src/cli/commands/log-tail.ts
15056
16719
  import * as fs16 from "fs";
@@ -15281,6 +16944,8 @@ async function runDaemonStatus() {
15281
16944
  const info = await readPidFile();
15282
16945
  if (!info) {
15283
16946
  process.stdout.write("Daemon: not running\n");
16947
+ process.stdout.write(`CLI version: ${HYDRA_VERSION}
16948
+ `);
15284
16949
  return;
15285
16950
  }
15286
16951
  const alive = isProcessAlive(info.pid);
@@ -15288,6 +16953,52 @@ async function runDaemonStatus() {
15288
16953
  `Daemon: ${alive ? "running" : "stale pid file"} pid=${info.pid} host=${info.host} port=${info.port} started=${info.startedAt}
15289
16954
  `
15290
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
+ }
15291
17002
  }
15292
17003
  async function readPidFile() {
15293
17004
  try {