@hydra-acp/cli 0.1.8 → 0.1.9

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
@@ -392,11 +392,22 @@ var init_types = __esm({
392
392
  MethodNotFound: -32601,
393
393
  InvalidParams: -32602,
394
394
  InternalError: -32603,
395
+ // -32001…-32003 reserved for RFD #533 attach semantics:
396
+ // -32001 Session not found
397
+ // -32002 Not authorised to attach
398
+ // -32003 Session does not support multi-client attach
399
+ // We emit -32001 (matching); the other two are reserved for spec
400
+ // alignment even though we don't currently emit them (we bearer-auth
401
+ // at WS upgrade time and always support multi-client attach).
395
402
  SessionNotFound: -32001,
396
- PermissionDenied: -32002,
397
- AlreadyAttached: -32003,
403
+ NotAuthorisedToAttach: -32002,
404
+ MultiClientNotSupported: -32003,
398
405
  AgentNotInstalled: -32005,
399
- BundleAlreadyImported: -32010
406
+ // Hydra-internal codes — outside the RFD's reserved range so they
407
+ // can't collide with future spec assignments.
408
+ BundleAlreadyImported: -32010,
409
+ PermissionDenied: -32011,
410
+ AlreadyAttached: -32012
400
411
  };
401
412
  InitializeParams = z3.object({
402
413
  protocolVersion: z3.number().optional(),
@@ -406,7 +417,12 @@ var init_types = __esm({
406
417
  version: z3.string().optional()
407
418
  }).optional()
408
419
  });
409
- HistoryPolicy = z3.enum(["full", "pending_only", "none"]);
420
+ HistoryPolicy = z3.enum([
421
+ "full",
422
+ "pending_only",
423
+ "none",
424
+ "after_message"
425
+ ]);
410
426
  SessionNewParams = z3.object({
411
427
  cwd: z3.string(),
412
428
  agentId: z3.string().optional(),
@@ -422,6 +438,18 @@ var init_types = __esm({
422
438
  SessionAttachParams = z3.object({
423
439
  sessionId: z3.string(),
424
440
  historyPolicy: HistoryPolicy.default("full"),
441
+ // Required when historyPolicy is "after_message"; ignored otherwise.
442
+ // The proxy replays history entries strictly after the entry whose
443
+ // messageId matches this value. If the id isn't found in the buffer,
444
+ // the response.historyPolicy field surfaces "full" so the caller
445
+ // knows we fell back. Per RFD #533.
446
+ afterMessageId: z3.string().optional(),
447
+ // Caller-assigned opaque id (e.g. a UUID). When provided, the proxy
448
+ // echoes it in resolvedBy/sentBy and lifecycle events so other
449
+ // clients can disambiguate multiple instances of the same
450
+ // clientInfo.name. When omitted, the proxy assigns one and returns
451
+ // it in the response. Per RFD #533.
452
+ clientId: z3.string().optional(),
425
453
  clientInfo: z3.object({
426
454
  name: z3.string(),
427
455
  version: z3.string().optional()
@@ -708,6 +736,9 @@ var init_hydra_commands = __esm({
708
736
 
709
737
  // src/core/session.ts
710
738
  import { customAlphabet } from "nanoid";
739
+ function generateMessageId() {
740
+ return `m_${generateHydraId()}`;
741
+ }
711
742
  function stripHydraSessionPrefix(id) {
712
743
  return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
713
744
  }
@@ -772,6 +803,97 @@ function extractAdvertisedCommands(params) {
772
803
  }
773
804
  return out;
774
805
  }
806
+ function ensureMessageIdOnUpdate(method, params) {
807
+ if (method !== "session/update" || !params || typeof params !== "object") {
808
+ return params;
809
+ }
810
+ const p = params;
811
+ if (!p.update || typeof p.update !== "object" || Array.isArray(p.update)) {
812
+ return params;
813
+ }
814
+ const u = p.update;
815
+ if (typeof u.messageId === "string") {
816
+ return params;
817
+ }
818
+ return {
819
+ ...params,
820
+ update: { ...p.update, messageId: generateMessageId() }
821
+ };
822
+ }
823
+ function findMessageIdIndex(history, target) {
824
+ for (let i = 0; i < history.length; i++) {
825
+ const entry = history[i];
826
+ if (!entry || entry.method !== "session/update") {
827
+ continue;
828
+ }
829
+ const params = entry.params;
830
+ if (params?.update?.messageId === target) {
831
+ return i;
832
+ }
833
+ }
834
+ return -1;
835
+ }
836
+ function extractToolCallId(params) {
837
+ if (!params || typeof params !== "object") {
838
+ return void 0;
839
+ }
840
+ const toolCall = params.toolCall;
841
+ if (!toolCall || typeof toolCall !== "object") {
842
+ return void 0;
843
+ }
844
+ const id = toolCall.toolCallId;
845
+ return typeof id === "string" ? id : void 0;
846
+ }
847
+ function buildPermissionResolvedUpdate(args) {
848
+ const outcome = extractOutcome(args.result);
849
+ const update = {
850
+ sessionUpdate: "permission_resolved"
851
+ };
852
+ if (args.toolCallId !== void 0) {
853
+ update.toolCallId = args.toolCallId;
854
+ }
855
+ if (outcome) {
856
+ update.outcome = outcome;
857
+ if (outcome.kind === "selected" && typeof outcome.optionId === "string") {
858
+ update.chosenOptionId = outcome.optionId;
859
+ }
860
+ }
861
+ update.resolvedBy = buildResolvedBy(args.resolver);
862
+ return update;
863
+ }
864
+ function extractOutcome(result) {
865
+ if (!result || typeof result !== "object") {
866
+ return void 0;
867
+ }
868
+ const raw = result.outcome;
869
+ if (!raw || typeof raw !== "object") {
870
+ return void 0;
871
+ }
872
+ const kind = raw.kind;
873
+ if (typeof kind !== "string") {
874
+ return void 0;
875
+ }
876
+ const out = { kind };
877
+ const optionId = raw.optionId;
878
+ if (typeof optionId === "string") {
879
+ out.optionId = optionId;
880
+ }
881
+ const reason = raw.reason;
882
+ if (typeof reason === "string") {
883
+ out.reason = reason;
884
+ }
885
+ return out;
886
+ }
887
+ function buildResolvedBy(client) {
888
+ const out = { clientId: client.clientId };
889
+ if (client.clientInfo?.name) {
890
+ out.name = client.clientInfo.name;
891
+ }
892
+ if (client.clientInfo?.version) {
893
+ out.version = client.clientInfo.version;
894
+ }
895
+ return out;
896
+ }
775
897
  function extractPromptText(prompt) {
776
898
  if (typeof prompt === "string") {
777
899
  return prompt;
@@ -979,6 +1101,30 @@ var init_session = __esm({
979
1101
  get attachedCount() {
980
1102
  return this.clients.size;
981
1103
  }
1104
+ // Roster of currently-attached clients, optionally excluding one
1105
+ // clientId. Used by the daemon to populate connectedClients on the
1106
+ // session/attach response (per RFD #533) — the freshly-attaching
1107
+ // client wants to see who else is on the session but not itself in
1108
+ // the list.
1109
+ connectedClients(excludeClientId) {
1110
+ const out = [];
1111
+ for (const client of this.clients.values()) {
1112
+ if (excludeClientId && client.clientId === excludeClientId) {
1113
+ continue;
1114
+ }
1115
+ const entry = {
1116
+ clientId: client.clientId
1117
+ };
1118
+ if (client.clientInfo?.name) {
1119
+ entry.name = client.clientInfo.name;
1120
+ }
1121
+ if (client.clientInfo?.version) {
1122
+ entry.version = client.clientInfo.version;
1123
+ }
1124
+ out.push(entry);
1125
+ }
1126
+ return out;
1127
+ }
982
1128
  // Wall-clock when the in-flight agent turn began, or undefined when
983
1129
  // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
984
1130
  // so the daemon can hand a fresh attacher mid-turn the right elapsed
@@ -1010,10 +1156,12 @@ var init_session = __esm({
1010
1156
  };
1011
1157
  }
1012
1158
  // Register a client and (asynchronously) load the replay slice it
1013
- // should receive. Validation errors throw synchronously so callers
1014
- // can rely on either the registration being in effect or having
1015
- // thrown; the disk-load is the only async work.
1016
- attach(client, historyPolicy) {
1159
+ // should receive. Returns both the slice to replay and the actual
1160
+ // historyPolicy applied (which may differ from the requested one
1161
+ // when after_message falls back to full). Validation errors throw
1162
+ // synchronously so callers can rely on either the registration being
1163
+ // in effect or having thrown; the disk-load is the only async work.
1164
+ attach(client, historyPolicy, opts = {}) {
1017
1165
  if (this.closed) {
1018
1166
  throw withCode(
1019
1167
  new Error("session is closed"),
@@ -1029,9 +1177,20 @@ var init_session = __esm({
1029
1177
  this.clients.set(client.clientId, client);
1030
1178
  this.updatedAt = Date.now();
1031
1179
  if (historyPolicy === "none" || historyPolicy === "pending_only") {
1032
- return Promise.resolve([]);
1180
+ return Promise.resolve({ entries: [], appliedPolicy: historyPolicy });
1181
+ }
1182
+ return this.loadReplay(historyPolicy, opts);
1183
+ }
1184
+ async loadReplay(historyPolicy, opts) {
1185
+ const all = await this.getHistorySnapshot();
1186
+ if (historyPolicy === "after_message") {
1187
+ const cutoff = opts.afterMessageId ? findMessageIdIndex(all, opts.afterMessageId) : -1;
1188
+ if (cutoff < 0) {
1189
+ return { entries: all, appliedPolicy: "full" };
1190
+ }
1191
+ return { entries: all.slice(cutoff + 1), appliedPolicy: "after_message" };
1033
1192
  }
1034
- return this.getHistorySnapshot();
1193
+ return { entries: all, appliedPolicy: "full" };
1035
1194
  }
1036
1195
  // Dispatch in-flight permission requests to a freshly-attached
1037
1196
  // client. Called by the daemon's WS handler *after* it finishes
@@ -1043,8 +1202,39 @@ var init_session = __esm({
1043
1202
  }
1044
1203
  }
1045
1204
  detach(clientId) {
1046
- if (this.clients.delete(clientId)) {
1047
- this.updatedAt = Date.now();
1205
+ const leaving = this.clients.get(clientId);
1206
+ if (!leaving) {
1207
+ return;
1208
+ }
1209
+ this.clients.delete(clientId);
1210
+ this.updatedAt = Date.now();
1211
+ this.broadcastClientDisconnected(leaving);
1212
+ }
1213
+ // Notify remaining attached clients that a peer just left, per
1214
+ // RFD #533. Fires for both explicit session/detach and ws-close
1215
+ // teardown (acp-ws calls Session.detach() in both paths). The
1216
+ // notification is broadcast (not recorded) — peer presence is
1217
+ // transient, not part of conversation history.
1218
+ broadcastClientDisconnected(client) {
1219
+ const info = {
1220
+ clientId: client.clientId
1221
+ };
1222
+ if (client.clientInfo?.name) {
1223
+ info.name = client.clientInfo.name;
1224
+ }
1225
+ if (client.clientInfo?.version) {
1226
+ info.version = client.clientInfo.version;
1227
+ }
1228
+ const update = {
1229
+ sessionUpdate: "client_disconnected",
1230
+ client: info,
1231
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1232
+ };
1233
+ for (const peer of this.clients.values()) {
1234
+ void peer.connection.notify("session/update", {
1235
+ sessionId: this.sessionId,
1236
+ update
1237
+ }).catch(() => void 0);
1048
1238
  }
1049
1239
  }
1050
1240
  async prompt(clientId, params) {
@@ -1097,6 +1287,7 @@ var init_session = __esm({
1097
1287
  sessionId: this.sessionId,
1098
1288
  update: {
1099
1289
  sessionUpdate: "prompt_received",
1290
+ messageId: generateMessageId(),
1100
1291
  prompt: promptParams.prompt,
1101
1292
  sentBy
1102
1293
  }
@@ -1122,7 +1313,8 @@ var init_session = __esm({
1122
1313
  broadcastTurnComplete(originatorClientId, response) {
1123
1314
  const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
1124
1315
  const update = {
1125
- sessionUpdate: "turn_complete"
1316
+ sessionUpdate: "turn_complete",
1317
+ messageId: generateMessageId()
1126
1318
  };
1127
1319
  if (stopReason !== void 0) {
1128
1320
  update.stopReason = stopReason;
@@ -1739,10 +1931,11 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1739
1931
  recordAndBroadcast(method, params, excludeClientId) {
1740
1932
  const rewritten = this.rewriteForClient(params);
1741
1933
  const recordable = !isStateUpdate(method, rewritten);
1934
+ const broadcast = recordable ? ensureMessageIdOnUpdate(method, rewritten) : rewritten;
1742
1935
  if (recordable) {
1743
1936
  const entry = {
1744
1937
  method,
1745
- params: rewritten,
1938
+ params: broadcast,
1746
1939
  recordedAt: Date.now()
1747
1940
  };
1748
1941
  this.lastRecordedAt = entry.recordedAt;
@@ -1770,7 +1963,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1770
1963
  if (excludeClientId && client.clientId === excludeClientId) {
1771
1964
  continue;
1772
1965
  }
1773
- void client.connection.notify(method, rewritten).catch(() => void 0);
1966
+ void client.connection.notify(method, broadcast).catch(() => void 0);
1774
1967
  }
1775
1968
  }
1776
1969
  async handlePermissionRequest(params) {
@@ -1782,11 +1975,13 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1782
1975
  );
1783
1976
  }
1784
1977
  const clientParams = this.rewriteForClient(params);
1978
+ const toolCallId = extractToolCallId(clientParams);
1785
1979
  return new Promise((resolve5, reject) => {
1786
1980
  let settled = false;
1787
1981
  const outbound = [];
1788
1982
  const entry = { addClient: sendTo };
1789
1983
  this.inFlightPermissions.add(entry);
1984
+ const sessionId = this.sessionId;
1790
1985
  const settle = (fn) => {
1791
1986
  if (settled) {
1792
1987
  return;
@@ -1799,22 +1994,25 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1799
1994
  if (settled) {
1800
1995
  return;
1801
1996
  }
1802
- const { id, response } = client.connection.requestWithId(
1997
+ const response = client.connection.request(
1803
1998
  "session/request_permission",
1804
1999
  clientParams
1805
2000
  );
1806
- outbound.push({ client, id });
2001
+ outbound.push({ client });
1807
2002
  void response.then((result) => {
1808
2003
  settle(() => {
2004
+ const update = buildPermissionResolvedUpdate({
2005
+ toolCallId,
2006
+ result,
2007
+ resolver: client
2008
+ });
1809
2009
  for (const o of outbound) {
1810
2010
  if (o.client.clientId === client.clientId) {
1811
2011
  continue;
1812
2012
  }
1813
- void o.client.connection.notify("session/permission_resolved", {
1814
- ...clientParams,
1815
- requestId: o.id,
1816
- resolvedBy: client.clientId,
1817
- result
2013
+ void o.client.connection.notify("session/update", {
2014
+ sessionId,
2015
+ update
1818
2016
  }).catch(() => void 0);
1819
2017
  }
1820
2018
  resolve5(result);
@@ -4355,7 +4553,7 @@ var init_screen = __esm({
4355
4553
  this.streamingActive = false;
4356
4554
  this.lines.push(...lines);
4357
4555
  this.trackLines(lines);
4358
- this.adjustScrollForLineChange(lines.length);
4556
+ this.adjustScrollForRowChange(this.wrappedRowsOfMany(lines));
4359
4557
  this.trimScrollback();
4360
4558
  this.scheduleRepaint();
4361
4559
  }
@@ -4363,19 +4561,40 @@ var init_screen = __esm({
4363
4561
  this.streamingActive = false;
4364
4562
  this.lines.push(line);
4365
4563
  this.trackLine(line);
4366
- this.adjustScrollForLineChange(1);
4564
+ this.adjustScrollForRowChange(this.wrappedRowsOf(line));
4367
4565
  this.trimScrollback();
4368
4566
  this.scheduleRepaint();
4369
4567
  }
4370
4568
  // When scrolled away from the bottom, shift scrollOffset to keep the
4371
4569
  // user's visible window anchored on the same content as the lines
4372
- // array grows. Without this, every new line silently scrolls the view
4373
- // up by one row the original bug the user reported.
4374
- adjustScrollForLineChange(delta) {
4570
+ // array grows. `delta` is measured in WRAPPED ROWS the same unit
4571
+ // scrollOffset uses so a single logical line that wraps to N rows
4572
+ // contributes N, not 1. Counting logical lines here was the original
4573
+ // bug: any wrapped append would slide the view up by N−1 rows.
4574
+ adjustScrollForRowChange(delta) {
4375
4575
  if (this.scrollOffset > 0 && delta !== 0) {
4376
4576
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
4377
4577
  }
4378
4578
  }
4579
+ // Wrapped-row count for a single line at the current terminal width.
4580
+ // Reuses the wrap cache, and synchronises the cache's width with the
4581
+ // current width so a resize that hasn't yet been picked up by
4582
+ // drawScrollback can't return stale counts during an insert.
4583
+ wrappedRowsOf(line) {
4584
+ const w = this.term.width;
4585
+ if (this.wrapCacheWidth !== w) {
4586
+ this.wrapCache.clear();
4587
+ this.wrapCacheWidth = w;
4588
+ }
4589
+ return this.wrapOne(line, w).length;
4590
+ }
4591
+ wrappedRowsOfMany(lines) {
4592
+ let n = 0;
4593
+ for (const line of lines) {
4594
+ n += this.wrappedRowsOf(line);
4595
+ }
4596
+ return n;
4597
+ }
4379
4598
  trackLine(line) {
4380
4599
  this.lineIds.set(line, this.nextLineId++);
4381
4600
  }
@@ -4425,12 +4644,14 @@ var init_screen = __esm({
4425
4644
  }
4426
4645
  const existing = this.keyedBlocks.get(key);
4427
4646
  let touchesEnd = false;
4428
- let scrollDelta = 0;
4647
+ let rowDelta = 0;
4429
4648
  if (existing) {
4430
4649
  const oldEnd = existing.start + existing.count;
4431
4650
  touchesEnd = oldEnd >= this.lines.length;
4651
+ const oldRows = this.wrappedRowsOfMany(
4652
+ this.lines.slice(existing.start, oldEnd)
4653
+ );
4432
4654
  const delta = newLines.length - existing.count;
4433
- scrollDelta = delta;
4434
4655
  const removed = this.lines.splice(
4435
4656
  existing.start,
4436
4657
  existing.count,
@@ -4448,20 +4669,21 @@ var init_screen = __esm({
4448
4669
  }
4449
4670
  }
4450
4671
  }
4672
+ rowDelta = this.wrappedRowsOfMany(newLines) - oldRows;
4451
4673
  } else {
4452
4674
  touchesEnd = true;
4453
- scrollDelta = newLines.length;
4454
4675
  this.keyedBlocks.set(key, {
4455
4676
  start: this.lines.length,
4456
4677
  count: newLines.length
4457
4678
  });
4458
4679
  this.lines.push(...newLines);
4459
4680
  this.trackLines(newLines);
4681
+ rowDelta = this.wrappedRowsOfMany(newLines);
4460
4682
  }
4461
4683
  if (touchesEnd) {
4462
4684
  this.streamingActive = false;
4463
4685
  }
4464
- this.adjustScrollForLineChange(scrollDelta);
4686
+ this.adjustScrollForRowChange(rowDelta);
4465
4687
  this.trimScrollback();
4466
4688
  this.scheduleRepaint();
4467
4689
  }
@@ -4475,12 +4697,14 @@ var init_screen = __esm({
4475
4697
  }
4476
4698
  const fragments = text.split("\n");
4477
4699
  const [first, ...rest] = fragments;
4478
- let added = 0;
4700
+ let rowDelta = 0;
4479
4701
  if (this.streamingActive && this.lines.length > 0) {
4480
4702
  const last = this.lines[this.lines.length - 1];
4481
4703
  if (last) {
4704
+ const before = this.wrappedRowsOf(last);
4482
4705
  this.forgetLine(last);
4483
4706
  last.body += first ?? "";
4707
+ rowDelta += this.wrappedRowsOf(last) - before;
4484
4708
  }
4485
4709
  } else {
4486
4710
  if (this.lines.length > 0) {
@@ -4490,7 +4714,7 @@ var init_screen = __esm({
4490
4714
  const sep = { body: "" };
4491
4715
  this.lines.push(sep);
4492
4716
  this.trackLine(sep);
4493
- added += 1;
4717
+ rowDelta += this.wrappedRowsOf(sep);
4494
4718
  }
4495
4719
  }
4496
4720
  const initial = {
@@ -4503,7 +4727,7 @@ var init_screen = __esm({
4503
4727
  }
4504
4728
  this.lines.push(initial);
4505
4729
  this.trackLine(initial);
4506
- added += 1;
4730
+ rowDelta += this.wrappedRowsOf(initial);
4507
4731
  }
4508
4732
  const continuationPrefix = " ".repeat(prefix.length);
4509
4733
  for (const piece of rest) {
@@ -4514,10 +4738,10 @@ var init_screen = __esm({
4514
4738
  };
4515
4739
  this.lines.push(cont);
4516
4740
  this.trackLine(cont);
4517
- added += 1;
4741
+ rowDelta += this.wrappedRowsOf(cont);
4518
4742
  }
4519
4743
  this.streamingActive = true;
4520
- this.adjustScrollForLineChange(added);
4744
+ this.adjustScrollForRowChange(rowDelta);
4521
4745
  this.trimScrollback();
4522
4746
  this.scheduleRepaint();
4523
4747
  }
@@ -4628,6 +4852,9 @@ var init_screen = __esm({
4628
4852
  return;
4629
4853
  }
4630
4854
  const touchesEnd = existing.start + existing.count >= this.lines.length;
4855
+ const removedRows = this.wrappedRowsOfMany(
4856
+ this.lines.slice(existing.start, existing.start + existing.count)
4857
+ );
4631
4858
  const removed = this.lines.splice(existing.start, existing.count);
4632
4859
  for (const line of removed) {
4633
4860
  this.forgetLine(line);
@@ -4641,7 +4868,7 @@ var init_screen = __esm({
4641
4868
  if (touchesEnd) {
4642
4869
  this.streamingActive = false;
4643
4870
  }
4644
- this.adjustScrollForLineChange(-existing.count);
4871
+ this.adjustScrollForRowChange(-removedRows);
4645
4872
  this.scheduleRepaint();
4646
4873
  }
4647
4874
  redraw() {
@@ -4726,7 +4953,7 @@ var init_screen = __esm({
4726
4953
  this.lines.push(sep);
4727
4954
  this.trackLine(sep);
4728
4955
  this.streamingActive = false;
4729
- this.adjustScrollForLineChange(1);
4956
+ this.adjustScrollForRowChange(this.wrappedRowsOf(sep));
4730
4957
  this.trimScrollback();
4731
4958
  this.scheduleRepaint();
4732
4959
  }
@@ -7072,13 +7299,25 @@ async function runSession(term, config, opts, exitHint) {
7072
7299
  } else if (event?.kind === "turn-complete") {
7073
7300
  adjustPendingTurns(-1);
7074
7301
  }
7302
+ if (rawTag === "permission_resolved") {
7303
+ handlePermissionResolved(update);
7304
+ return;
7305
+ }
7075
7306
  appendRender(event);
7076
7307
  maybeDismissPermissionByToolUpdate(update);
7077
7308
  });
7078
- conn.onNotification("session/permission_resolved", (params) => {
7079
- const p = params ?? {};
7080
- dismissPermissionExternally(p.toolCall?.toolCallId, p.result);
7081
- });
7309
+ const handlePermissionResolved = (update) => {
7310
+ const u = update ?? {};
7311
+ const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : void 0;
7312
+ let outcome;
7313
+ if (u.outcome && typeof u.outcome === "object") {
7314
+ outcome = u.outcome;
7315
+ } else if (typeof u.chosenOptionId === "string") {
7316
+ outcome = { kind: "selected", optionId: u.chosenOptionId };
7317
+ }
7318
+ const result = outcome ? { outcome } : void 0;
7319
+ dismissPermissionExternally(toolCallId, result);
7320
+ };
7082
7321
  let pendingPermission = null;
7083
7322
  const dismissPermissionExternally = (toolCallId, result) => {
7084
7323
  if (!pendingPermission) {
@@ -10705,7 +10944,7 @@ function registerSessionRoutes(app, manager, defaults) {
10705
10944
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
10706
10945
  reply.header(
10707
10946
  "Content-Disposition",
10708
- `attachment; filename="hydra-${id}-${stamp}.hydra"`
10947
+ `attachment; filename="${id}-${stamp}.hydra"`
10709
10948
  );
10710
10949
  reply.code(200).send(bundle);
10711
10950
  });
@@ -11071,15 +11310,20 @@ function registerAcpWsEndpoint(app, deps) {
11071
11310
  connection,
11072
11311
  session,
11073
11312
  state,
11074
- params.clientInfo
11313
+ params.clientInfo,
11314
+ params.clientId
11315
+ );
11316
+ const { entries: replay, appliedPolicy } = await session.attach(
11317
+ client,
11318
+ params.historyPolicy,
11319
+ { afterMessageId: params.afterMessageId }
11075
11320
  );
11076
- const replay = await session.attach(client, params.historyPolicy);
11077
11321
  state.attached.set(session.sessionId, {
11078
11322
  sessionId: session.sessionId,
11079
11323
  clientId: client.clientId
11080
11324
  });
11081
11325
  app.log.info(
11082
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
11326
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
11083
11327
  );
11084
11328
  for (const note of replay) {
11085
11329
  await connection.notify(note.method, note.params);
@@ -11087,6 +11331,13 @@ function registerAcpWsEndpoint(app, deps) {
11087
11331
  session.replayPendingPermissions(client);
11088
11332
  return {
11089
11333
  sessionId: session.sessionId,
11334
+ clientId: client.clientId,
11335
+ connectedClients: session.connectedClients(client.clientId),
11336
+ // appliedPolicy surfaces whether after_message fell back to full
11337
+ // (because afterMessageId wasn't found in history) — RFD #533
11338
+ // says the response.historyPolicy should reflect what actually
11339
+ // ran, not what was asked for.
11340
+ historyPolicy: appliedPolicy,
11090
11341
  replayed: replay.length,
11091
11342
  _meta: buildResponseMeta(session)
11092
11343
  };
@@ -11102,7 +11353,7 @@ function registerAcpWsEndpoint(app, deps) {
11102
11353
  const session = deps.manager.get(params.sessionId);
11103
11354
  session?.detach(att.clientId);
11104
11355
  state.attached.delete(params.sessionId);
11105
- return { detached: true };
11356
+ return { sessionId: params.sessionId, status: "detached" };
11106
11357
  });
11107
11358
  connection.onRequest("session/list", async (raw) => {
11108
11359
  const params = SessionListParams.parse(raw ?? {});
@@ -11175,7 +11426,7 @@ function registerAcpWsEndpoint(app, deps) {
11175
11426
  session = await deps.manager.resurrect(fromDisk);
11176
11427
  }
11177
11428
  const client = bindClientToSession(connection, session, state);
11178
- const replay = await session.attach(client, "pending_only");
11429
+ const { entries: replay } = await session.attach(client, "pending_only");
11179
11430
  state.attached.set(session.sessionId, {
11180
11431
  sessionId: session.sessionId,
11181
11432
  clientId: client.clientId
@@ -11269,11 +11520,11 @@ function buildInitializeResult() {
11269
11520
  ]
11270
11521
  };
11271
11522
  }
11272
- function bindClientToSession(connection, session, state, clientInfo) {
11523
+ function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
11273
11524
  void state;
11274
11525
  void session;
11275
11526
  return {
11276
- clientId: `cli_${nanoid2(8)}`,
11527
+ clientId: callerClientId ?? `cli_${nanoid2(8)}`,
11277
11528
  connection,
11278
11529
  clientInfo
11279
11530
  };
@@ -12216,10 +12467,22 @@ var SessionTracker = class {
12216
12467
  contexts = /* @__PURE__ */ new Map();
12217
12468
  pending = /* @__PURE__ */ new Map();
12218
12469
  pendingPermissions = /* @__PURE__ */ new Map();
12470
+ // Secondary index — same entries as `pendingPermissions`, keyed by the
12471
+ // tool call id from the request_permission params. Used to correlate
12472
+ // the daemon's `session/update`/`permission_resolved` events back to the
12473
+ // pending downstream request, since per-recipient JSON-RPC ids are no
12474
+ // longer carried on the wire.
12475
+ pendingPermissionsByToolCall = /* @__PURE__ */ new Map();
12476
+ // Most recent messageId observed on a session/update from the daemon
12477
+ // (prompt_received / turn_complete), keyed by sessionId. Used by the
12478
+ // reconnect-replay path to send historyPolicy:"after_message" with
12479
+ // afterMessageId so the daemon only replays the delta we missed.
12480
+ lastMessageIds = /* @__PURE__ */ new Map();
12219
12481
  observeFromClient(msg) {
12220
12482
  if (isResponse2(msg)) {
12221
- if (this.pendingPermissions.has(msg.id)) {
12222
- this.pendingPermissions.delete(msg.id);
12483
+ const existing = this.pendingPermissions.get(msg.id);
12484
+ if (existing) {
12485
+ this.deletePendingPermission(existing);
12223
12486
  }
12224
12487
  return;
12225
12488
  }
@@ -12246,16 +12509,34 @@ var SessionTracker = class {
12246
12509
  }
12247
12510
  }
12248
12511
  observeFromServer(msg) {
12512
+ if (!isRequest(msg) && !isResponse2(msg) && "method" in msg) {
12513
+ if (msg.method === "session/update") {
12514
+ const params = msg.params ?? {};
12515
+ const sessionId2 = typeof params.sessionId === "string" ? params.sessionId : void 0;
12516
+ const messageId = typeof params.update?.messageId === "string" ? params.update.messageId : void 0;
12517
+ if (sessionId2 && messageId) {
12518
+ this.lastMessageIds.set(sessionId2, messageId);
12519
+ }
12520
+ }
12521
+ return;
12522
+ }
12249
12523
  if (isRequest(msg)) {
12250
12524
  if (msg.method === "session/request_permission") {
12251
12525
  const params = msg.params ?? {};
12252
12526
  const sessionId2 = typeof params.sessionId === "string" ? params.sessionId : void 0;
12253
12527
  if (sessionId2) {
12254
- this.pendingPermissions.set(msg.id, {
12528
+ const toolCall = params.toolCall;
12529
+ const toolCallId = toolCall && typeof toolCall.toolCallId === "string" ? toolCall.toolCallId : void 0;
12530
+ const entry = {
12255
12531
  requestId: msg.id,
12256
12532
  sessionId: sessionId2,
12533
+ toolCallId,
12257
12534
  params
12258
- });
12535
+ };
12536
+ this.pendingPermissions.set(msg.id, entry);
12537
+ if (toolCallId) {
12538
+ this.pendingPermissionsByToolCall.set(toolCallId, entry);
12539
+ }
12259
12540
  }
12260
12541
  }
12261
12542
  return;
@@ -12303,6 +12584,14 @@ var SessionTracker = class {
12303
12584
  }
12304
12585
  forget(sessionId) {
12305
12586
  this.contexts.delete(sessionId);
12587
+ this.lastMessageIds.delete(sessionId);
12588
+ }
12589
+ // Latest messageId observed for `sessionId`, or undefined if we
12590
+ // haven't seen one (no prompt_received/turn_complete has flowed
12591
+ // through yet). Used by reconnect-replay to issue
12592
+ // historyPolicy:"after_message" with afterMessageId.
12593
+ lastMessageId(sessionId) {
12594
+ return this.lastMessageIds.get(sessionId);
12306
12595
  }
12307
12596
  clearPending() {
12308
12597
  this.pending.clear();
@@ -12310,15 +12599,29 @@ var SessionTracker = class {
12310
12599
  takePendingPermissions() {
12311
12600
  const out = [...this.pendingPermissions.values()];
12312
12601
  this.pendingPermissions.clear();
12602
+ this.pendingPermissionsByToolCall.clear();
12313
12603
  return out;
12314
12604
  }
12315
12605
  takePendingPermission(requestId) {
12316
12606
  const found = this.pendingPermissions.get(requestId);
12317
12607
  if (found) {
12318
- this.pendingPermissions.delete(requestId);
12608
+ this.deletePendingPermission(found);
12609
+ }
12610
+ return found;
12611
+ }
12612
+ takePendingPermissionByToolCall(toolCallId) {
12613
+ const found = this.pendingPermissionsByToolCall.get(toolCallId);
12614
+ if (found) {
12615
+ this.deletePendingPermission(found);
12319
12616
  }
12320
12617
  return found;
12321
12618
  }
12619
+ deletePendingPermission(entry) {
12620
+ this.pendingPermissions.delete(entry.requestId);
12621
+ if (entry.toolCallId) {
12622
+ this.pendingPermissionsByToolCall.delete(entry.toolCallId);
12623
+ }
12624
+ }
12322
12625
  };
12323
12626
  function isRequest(msg) {
12324
12627
  return "method" in msg && "id" in msg && msg.id !== void 0;
@@ -12354,7 +12657,7 @@ async function runShim(opts) {
12354
12657
  `
12355
12658
  );
12356
12659
  for (const ctx of contexts) {
12357
- await replayAttach(upstream, ctx);
12660
+ await replayAttach(upstream, ctx, tracker.lastMessageId(ctx.sessionId));
12358
12661
  }
12359
12662
  }
12360
12663
  });
@@ -12413,25 +12716,47 @@ function wireShim({
12413
12716
  });
12414
12717
  }
12415
12718
  function maybeReplyToResolvedPermission(msg, tracker, downstream) {
12416
- if (!isPermissionResolvedNotification(msg)) {
12719
+ const update = extractPermissionResolvedUpdate(msg);
12720
+ if (!update) {
12417
12721
  return;
12418
12722
  }
12419
- const params = msg.params ?? {};
12420
- if (params.requestId === void 0) {
12723
+ const toolCallId = typeof update.toolCallId === "string" ? update.toolCallId : void 0;
12724
+ if (!toolCallId) {
12421
12725
  return;
12422
12726
  }
12423
- const pending = tracker.takePendingPermission(params.requestId);
12727
+ const pending = tracker.takePendingPermissionByToolCall(toolCallId);
12424
12728
  if (!pending) {
12425
12729
  return;
12426
12730
  }
12731
+ const outcome = reconstructOutcome(update);
12427
12732
  void downstream.send({
12428
12733
  jsonrpc: "2.0",
12429
12734
  id: pending.requestId,
12430
- result: params.result ?? null
12735
+ result: outcome ? { outcome } : null
12431
12736
  }).catch(() => void 0);
12432
12737
  }
12433
- function isPermissionResolvedNotification(msg) {
12434
- return "method" in msg && msg.method === "session/permission_resolved" && !("id" in msg && msg.id !== void 0);
12738
+ function extractPermissionResolvedUpdate(msg) {
12739
+ if (!isSessionUpdateNotification(msg)) {
12740
+ return void 0;
12741
+ }
12742
+ const params = msg.params ?? {};
12743
+ const update = params.update;
12744
+ if (!update || typeof update !== "object" || update.sessionUpdate !== "permission_resolved") {
12745
+ return void 0;
12746
+ }
12747
+ return update;
12748
+ }
12749
+ function isSessionUpdateNotification(msg) {
12750
+ return "method" in msg && msg.method === "session/update" && !("id" in msg && msg.id !== void 0);
12751
+ }
12752
+ function reconstructOutcome(update) {
12753
+ if (update.outcome && typeof update.outcome === "object") {
12754
+ return update.outcome;
12755
+ }
12756
+ if (typeof update.chosenOptionId === "string") {
12757
+ return { kind: "selected", optionId: update.chosenOptionId };
12758
+ }
12759
+ return void 0;
12435
12760
  }
12436
12761
  async function cancelPendingPermissions(tracker, downstream) {
12437
12762
  const pendings = tracker.takePendingPermissions();
@@ -12443,21 +12768,26 @@ async function cancelPendingPermissions(tracker, downstream) {
12443
12768
  `
12444
12769
  );
12445
12770
  for (const pending of pendings) {
12446
- const params = {
12447
- ...pending.params,
12448
- resolvedBy: "hydra-acp",
12449
- result: {
12450
- outcome: { kind: "cancelled", reason: "daemon-disconnected" }
12451
- }
12771
+ const sessionId = typeof pending.params.sessionId === "string" ? pending.params.sessionId : void 0;
12772
+ if (!sessionId) {
12773
+ continue;
12774
+ }
12775
+ const update = {
12776
+ sessionUpdate: "permission_resolved",
12777
+ outcome: { kind: "cancelled", reason: "daemon-disconnected" },
12778
+ resolvedBy: { clientId: "hydra-acp" }
12452
12779
  };
12780
+ if (pending.toolCallId) {
12781
+ update.toolCallId = pending.toolCallId;
12782
+ }
12453
12783
  await downstream.send({
12454
12784
  jsonrpc: "2.0",
12455
- method: "session/permission_resolved",
12456
- params
12785
+ method: "session/update",
12786
+ params: { sessionId, update }
12457
12787
  }).catch(() => void 0);
12458
12788
  }
12459
12789
  }
12460
- async function replayAttach(stream, ctx) {
12790
+ async function replayAttach(stream, ctx, afterMessageId) {
12461
12791
  const resumeHints = {
12462
12792
  upstreamSessionId: ctx.upstreamSessionId,
12463
12793
  agentId: ctx.agentId,
@@ -12469,19 +12799,21 @@ async function replayAttach(stream, ctx) {
12469
12799
  if (ctx.agentArgs && ctx.agentArgs.length > 0) {
12470
12800
  resumeHints.agentArgs = ctx.agentArgs;
12471
12801
  }
12802
+ const params = {
12803
+ sessionId: ctx.sessionId,
12804
+ _meta: { "hydra-acp": { resume: resumeHints } }
12805
+ };
12806
+ if (afterMessageId) {
12807
+ params.historyPolicy = "after_message";
12808
+ params.afterMessageId = afterMessageId;
12809
+ } else {
12810
+ params.historyPolicy = "pending_only";
12811
+ }
12472
12812
  const request = {
12473
12813
  jsonrpc: "2.0",
12474
12814
  id: `resume-${ctx.sessionId}-${Date.now()}`,
12475
12815
  method: "session/attach",
12476
- params: {
12477
- sessionId: ctx.sessionId,
12478
- historyPolicy: "pending_only",
12479
- _meta: {
12480
- "hydra-acp": {
12481
- resume: resumeHints
12482
- }
12483
- }
12484
- }
12816
+ params
12485
12817
  };
12486
12818
  try {
12487
12819
  const resp = await stream.request(request);