@qoniai/qoni 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -23,7 +23,7 @@ __export(index_exports, {
23
23
  ActionKinds: () => ActionKinds,
24
24
  InteractionHandle: () => InteractionHandle,
25
25
  InteractionStatuses: () => InteractionStatuses,
26
- InteractionTypes: () => InteractionTypes,
26
+ InteractionTypes: () => InteractionTypes2,
27
27
  MonitorHandle: () => MonitorHandle,
28
28
  Qoni: () => Qoni,
29
29
  QoniAuthError: () => QoniAuthError,
@@ -566,8 +566,22 @@ var QoniScopes = {
566
566
  // interaction). Not part of any product bundle: the AccessKey policy must
567
567
  // allow it, and the delegation must request it explicitly.
568
568
  SITE_LOGIN_REQUEST: "webagent.site_login:request",
569
- // Persist that login into the browser profile (`OpenLoginResult.confirm()`).
570
- SITE_LOGIN_CONFIRM: "webagent.site_login:confirm"
569
+ // Persist that login into the browser profile (`InteractionHandle.confirmSignedIn()`
570
+ // after `openLogin()`).
571
+ SITE_LOGIN_CONFIRM: "webagent.site_login:confirm",
572
+ // The delegating user's own data and the Agent's own mailbox (Personal
573
+ // Agent guide). Not part of any product bundle: request them explicitly;
574
+ // the AccessKey policy must allow them.
575
+ // Read the user's Profile (name, phone, shipping address, card brand + last 4).
576
+ USER_PROFILE_READ: "user.profile:read",
577
+ // Write answers back into the user's Profile.
578
+ USER_PROFILE_WRITE: "user.profile:write",
579
+ // Use the user's saved payment card once the user approves a payment.
580
+ USER_PAYMENT_USE: "user.payment:use",
581
+ // Read mail received by the Agent's own mailbox (for example verification codes).
582
+ AGENT_MAIL_READ: "agent.mail:read",
583
+ // Send mail from the Agent's own mailbox.
584
+ AGENT_MAIL_SEND: "agent.mail:send"
571
585
  };
572
586
  var QoniProductScopes = {
573
587
  doAnything: [
@@ -620,6 +634,15 @@ var QoniScopeBundles = {
620
634
  };
621
635
 
622
636
  // src/interactions.ts
637
+ var InteractionTypes2 = {
638
+ SiteLogin: "site_login",
639
+ TakeControl: "take_control",
640
+ AskUser: "ask_user",
641
+ Confirmation: "confirmation",
642
+ Wait: "wait",
643
+ /** @deprecated `clarification` is presented as `ask_user`; use `InteractionTypes.AskUser`. */
644
+ Clarification: "ask_user"
645
+ };
623
646
  function asString2(value) {
624
647
  return typeof value === "string" && value ? value : void 0;
625
648
  }
@@ -648,6 +671,49 @@ function normalizeEvidence(c) {
648
671
  const artifactId = asString2(c.evidenceArtifactId) ?? (isJsonObject(c.evidence) ? asString2(c.evidence.artifactId) : void 0);
649
672
  return artifactId ? { artifactId } : void 0;
650
673
  }
674
+ var ANSWER_TYPES = /* @__PURE__ */ new Set([
675
+ "text",
676
+ "number",
677
+ "boolean",
678
+ "single_choice",
679
+ "multiple_choice"
680
+ ]);
681
+ function normalizeOptions(value) {
682
+ if (!Array.isArray(value)) return [];
683
+ return value.flatMap((raw) => {
684
+ if (typeof raw === "string") return [{ value: raw, label: raw }];
685
+ if (!isJsonObject(raw) || typeof raw.value !== "string") return [];
686
+ return [{ value: raw.value, label: typeof raw.label === "string" ? raw.label : raw.value }];
687
+ });
688
+ }
689
+ function isAnswerType(value) {
690
+ return value !== void 0 && ANSWER_TYPES.has(value);
691
+ }
692
+ function askUserPayload(c) {
693
+ const options = normalizeOptions(c.options);
694
+ const native = asString2(c.answerType);
695
+ const hint = asString2(c.inputType);
696
+ let answerType;
697
+ if (isAnswerType(native)) answerType = native;
698
+ else if (isAnswerType(hint) && hint !== "single_choice" && hint !== "multiple_choice") answerType = hint;
699
+ else answerType = options.length > 0 ? hint === "multiple_choice" ? "multiple_choice" : "single_choice" : "text";
700
+ const payload = { question: asString2(c.question) ?? "", answerType };
701
+ if (options.length > 0) payload.options = options;
702
+ return payload;
703
+ }
704
+ var BASE_FIELDS = /* @__PURE__ */ new Set([
705
+ "id",
706
+ "type",
707
+ "status",
708
+ "createdAt",
709
+ "resolvedAt",
710
+ "expiresAt",
711
+ "title",
712
+ "prompt",
713
+ "evidence",
714
+ "evidenceArtifactId",
715
+ "actions"
716
+ ]);
651
717
  function shapePayload(type, c) {
652
718
  switch (type) {
653
719
  case "site_login": {
@@ -664,14 +730,10 @@ function shapePayload(type, c) {
664
730
  const payload = { sites };
665
731
  const monitorId = asString2(c.monitorId);
666
732
  if (monitorId) payload.monitorId = monitorId;
667
- return payload;
668
- }
669
- case "clarification": {
670
- const payload = { question: asString2(c.question) ?? "" };
671
- const inputType = asString2(c.inputType);
672
- if (inputType) payload.inputType = inputType;
673
- return payload;
733
+ return { ...payload };
674
734
  }
735
+ case "ask_user":
736
+ return { ...askUserPayload(c) };
675
737
  case "confirmation":
676
738
  return { summary: asString2(c.summary) ?? "" };
677
739
  case "take_control": {
@@ -680,7 +742,7 @@ function shapePayload(type, c) {
680
742
  const reason = asString2(c.reason);
681
743
  if (surface) payload.surface = surface;
682
744
  if (reason) payload.reason = reason;
683
- return payload;
745
+ return { ...payload };
684
746
  }
685
747
  case "wait": {
686
748
  const payload = {
@@ -690,14 +752,20 @@ function shapePayload(type, c) {
690
752
  const retryable = asBool(c.retryable);
691
753
  if (until) payload.until = until;
692
754
  if (retryable !== void 0) payload.retryable = retryable;
755
+ return { ...payload };
756
+ }
757
+ default: {
758
+ const payload = {};
759
+ for (const [key, value] of Object.entries(c)) if (!BASE_FIELDS.has(key)) payload[key] = value;
693
760
  return payload;
694
761
  }
695
762
  }
696
763
  }
697
764
  function normalizeInteraction(payload) {
698
765
  const c = camelizeRecord(payload);
699
- const type = asString2(c.type);
700
- if (!type) return null;
766
+ const wireType = asString2(c.type);
767
+ if (!wireType) return null;
768
+ const type = wireType === "clarification" ? "ask_user" : wireType;
701
769
  const interaction = {
702
770
  id: asString2(c.id) ?? "",
703
771
  type,
@@ -717,15 +785,109 @@ function normalizeInteraction(payload) {
717
785
  if (evidence) interaction.evidence = evidence;
718
786
  return interaction;
719
787
  }
788
+ var SETTLED_INTERACTION_STATUSES = /* @__PURE__ */ new Set(["resolved", "expired", "canceled"]);
789
+ function isSettlingShell(next) {
790
+ if (!SETTLED_INTERACTION_STATUSES.has(next.status) || next.actions.length > 0) return false;
791
+ switch (next.type) {
792
+ case "ask_user":
793
+ return next.payload.question === "";
794
+ case "site_login":
795
+ return next.payload.sites.every((site) => !site.siteId);
796
+ case "confirmation":
797
+ return next.payload.summary === "";
798
+ default:
799
+ return false;
800
+ }
801
+ }
802
+ function foldInteraction(previous, next) {
803
+ if (!previous || previous.id !== next.id || !isSettlingShell(next)) return next;
804
+ const folded = { ...previous, status: next.status, actions: next.actions };
805
+ if (next.resolvedAt) folded.resolvedAt = next.resolvedAt;
806
+ return folded;
807
+ }
720
808
  function interactionActionScopes(action, productManageScope) {
721
809
  return action.kind === ActionKinds.openLogin ? [QoniScopes.SITE_LOGIN_REQUEST] : [productManageScope];
722
810
  }
811
+ function loginSlotIdle(slot) {
812
+ return slot.inflight === 0 && (!slot.session || slot.ended === true || slot.session.completed === true);
813
+ }
814
+ function markLoginEnded(store, interaction) {
815
+ if (!SETTLED_INTERACTION_STATUSES.has(interaction.status)) return;
816
+ const slot = store.get(interaction.id);
817
+ if (slot) slot.ended = true;
818
+ }
819
+ var SAVE_RETRY_PROBES = /* @__PURE__ */ new Set(["release_failed"]);
820
+ function isNoPendingLogin(err) {
821
+ return err instanceof QoniError && err.status === 409 && err.code === "no_pending_login";
822
+ }
823
+ var INTERACTION_MEMORY_LIMIT = 256;
824
+ function rememberBounded(map, key, value, limit = INTERACTION_MEMORY_LIMIT, evictable = () => true) {
825
+ map.delete(key);
826
+ map.set(key, value);
827
+ if (map.size <= limit) return;
828
+ for (const [candidate, entry] of map) {
829
+ if (map.size <= limit) break;
830
+ if (candidate !== key && evictable(entry)) map.delete(candidate);
831
+ }
832
+ }
833
+ function interactionSettled(interaction) {
834
+ return SETTLED_INTERACTION_STATUSES.has(interaction.status);
835
+ }
836
+ function controlSession(response) {
837
+ const raw = isJsonObject(response) ? response : {};
838
+ const fields = camelizeRecord(raw);
839
+ const session = { liveUrl: asString2(fields.standaloneControlUrl) ?? asString2(fields.liveUrl) ?? "", raw };
840
+ const expiresAt = asString2(fields.controlTokenExpiresAt) ?? asString2(fields.expiresAt);
841
+ const status = asString2(fields.status);
842
+ if (expiresAt) session.expiresAt = expiresAt;
843
+ if (status) session.status = status;
844
+ return session;
845
+ }
846
+ function describeOptions(payload) {
847
+ return (payload.options ?? []).map((option) => option.value).join(", ");
848
+ }
849
+ function checkAnswer(payload, value) {
850
+ const fail = (expected) => {
851
+ throw new QoniValidationError(`answer() for a ${payload.answerType} question expects ${expected}`);
852
+ };
853
+ const values = new Set((payload.options ?? []).map((option) => option.value));
854
+ switch (payload.answerType) {
855
+ case "text":
856
+ if (typeof value !== "string") fail("a string");
857
+ return;
858
+ case "number":
859
+ if (typeof value !== "number" || !Number.isFinite(value)) fail("a finite number");
860
+ return;
861
+ case "boolean":
862
+ if (typeof value !== "boolean") fail("a boolean");
863
+ return;
864
+ case "single_choice":
865
+ if (typeof value !== "string" || values.size > 0 && !values.has(value)) {
866
+ fail(`one option value: ${describeOptions(payload)}`);
867
+ }
868
+ return;
869
+ case "multiple_choice":
870
+ if (!Array.isArray(value)) fail(`an array of option values: ${describeOptions(payload)}`);
871
+ if (value.some((item) => typeof item !== "string" || values.size > 0 && !values.has(item))) {
872
+ fail(`option values only: ${describeOptions(payload)}`);
873
+ }
874
+ return;
875
+ }
876
+ }
723
877
  var InteractionHandle = class {
724
- constructor(interaction, post) {
878
+ /**
879
+ * @param loginSessions Login sessions opened on interactions, by interaction
880
+ * id. A RunHandle / MonitorHandle shares one store across the handles it
881
+ * hands out, so `confirmSignedIn()` finds the login `openLogin()` opened
882
+ * even on the handle of a later event.
883
+ */
884
+ constructor(interaction, post, loginSessions = /* @__PURE__ */ new Map()) {
725
885
  this.post = post;
886
+ this.loginSessions = loginSessions;
726
887
  this.interaction = interaction;
727
888
  }
728
889
  post;
890
+ loginSessions;
729
891
  interaction;
730
892
  get id() {
731
893
  return this.interaction.id;
@@ -743,9 +905,18 @@ var InteractionHandle = class {
743
905
  can(kind) {
744
906
  return this.interaction.actions.some((a) => a.kind === kind);
745
907
  }
746
- /** Answer a clarification ask. */
747
- async answer(text) {
748
- await this.invoke(ActionKinds.answer, { response: text });
908
+ /**
909
+ * Answer an `ask_user` question. The value follows `payload.answerType`:
910
+ * `text` → string, `number` → number, `boolean` → boolean, `single_choice` →
911
+ * one option `value`, `multiple_choice` → an array of option values. A value
912
+ * that does not fit throws `QoniValidationError` before anything is sent.
913
+ */
914
+ async answer(value) {
915
+ const action = this.#declaredAction(ActionKinds.answer);
916
+ if (this.interaction.type === "ask_user") {
917
+ checkAnswer(this.interaction.payload, value);
918
+ }
919
+ await this.post(action, { response: value });
749
920
  }
750
921
  /** Skip / dismiss the interaction (proceed without acting on it). */
751
922
  async skip() {
@@ -763,8 +934,8 @@ var InteractionHandle = class {
763
934
  * Start an interactive login session for a `site_login` interaction. The
764
935
  * backend needs the site to sign in to: pass `siteId`, or omit it when the
765
936
  * interaction lists exactly one site. Show `liveUrl` to the user; when they
766
- * finish, call `confirm()` on the result to save the login, and act on its
767
- * outcome (see {@link OpenLoginResult.confirm}) before {@link confirmSignedIn}.
937
+ * say they are signed in, call {@link confirmSignedIn}, which saves the login
938
+ * and lets the agent continue.
768
939
  * The delegation must include `QoniScopes.SITE_LOGIN_REQUEST` and
769
940
  * `QoniScopes.SITE_LOGIN_CONFIRM`; the AccessKey policy must allow both.
770
941
  */
@@ -777,57 +948,82 @@ var InteractionHandle = class {
777
948
  }
778
949
  body.profile_id = options.profileId;
779
950
  }
780
- const response = await this.invoke(ActionKinds.openLogin, body);
951
+ const slot = this.#loginSlot();
952
+ const call = ++slot.calls;
953
+ slot.inflight += 1;
954
+ let response;
955
+ try {
956
+ response = await this.invoke(ActionKinds.openLogin, body);
957
+ } finally {
958
+ slot.inflight -= 1;
959
+ }
781
960
  const raw = isJsonObject(response) ? response : {};
782
961
  const fields = camelizeRecord(raw);
783
962
  const profileId = asString2(fields.profileId) ?? "";
784
963
  const siteId = asString2(fields.siteId) ?? String(body.site_id);
964
+ const sessionId = asString2(fields.sessionId) ?? "";
965
+ const current = slot.session;
966
+ const reused = current !== void 0 && sessionId !== "" && current.sessionId === sessionId && current.profileId === profileId && current.siteId === siteId;
967
+ const session = reused ? current : { profileId, siteId, sessionId, call };
968
+ if (reused) {
969
+ current.call = Math.max(current.call, call);
970
+ } else if (profileId && (current?.call ?? 0) < call) {
971
+ if (current) current.replaced = true;
972
+ slot.session = session;
973
+ } else {
974
+ session.replaced = true;
975
+ }
785
976
  return {
786
977
  profileId,
787
978
  siteId,
788
- sessionId: asString2(fields.sessionId) ?? "",
979
+ sessionId,
789
980
  liveUrl: asString2(fields.liveUrl) ?? "",
790
981
  raw,
791
982
  confirm: async (confirmOptions = {}) => {
792
983
  if (!profileId) {
793
984
  throw new QoniValidationError("openLogin response did not include a profileId to confirm");
794
985
  }
795
- const confirmBody = { site_id: siteId };
796
- if (confirmOptions.displayLabel !== void 0) confirmBody.display_label = confirmOptions.displayLabel;
797
- const response2 = await this.post(
798
- {
799
- kind: ActionKinds.openLogin,
800
- label: "Confirm login",
801
- method: "POST",
802
- endpoint: `/profiles/${encodeURIComponent(profileId)}/confirm`
803
- },
804
- confirmBody,
805
- { requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
806
- );
807
- const raw2 = isJsonObject(response2) ? response2 : {};
808
- const fields2 = camelizeRecord(raw2);
809
- const state = asString2(fields2.state) ?? "";
810
- return {
811
- profileId: asString2(fields2.profileId) ?? profileId,
812
- active: state === "active",
813
- state,
814
- probeResult: asString2(fields2.probeResult) ?? "",
815
- raw: raw2
816
- };
986
+ if (session.replaced) {
987
+ throw new QoniValidationError(
988
+ "this login session was replaced by a newer openLogin(); confirm the newer one (or call confirmSignedIn(), which saves the current session)"
989
+ );
990
+ }
991
+ return this.#saveOnce(session, confirmOptions);
817
992
  }
818
993
  };
819
994
  }
820
- /** Tell the agent the user has finished signing in. */
995
+ /**
996
+ * The user finished signing in through the live browser `openLogin()`
997
+ * opened. Saves that login (so later tasks reuse it), then tells the agent
998
+ * to recheck the site and continue. If the user is not signed in after all,
999
+ * the agent raises a new `site_login` interaction.
1000
+ *
1001
+ * Saving needs `QoniScopes.SITE_LOGIN_CONFIRM` on the delegation; without
1002
+ * `openLogin()` on this interaction, only the recheck is sent.
1003
+ */
821
1004
  async confirmSignedIn() {
822
- await this.invoke(ActionKinds.confirmSignedIn);
1005
+ const action = this.#declaredAction(ActionKinds.confirmSignedIn);
1006
+ const session = this.loginSessions.get(this.id)?.session;
1007
+ if (!session) {
1008
+ await this.post(action, void 0);
1009
+ return;
1010
+ }
1011
+ session.done ??= this.#saveThenRecheck(session, action).catch((err) => {
1012
+ session.done = void 0;
1013
+ throw err;
1014
+ });
1015
+ await session.done;
823
1016
  }
824
- /** Connect to the live browser for a take_control interaction. */
1017
+ /**
1018
+ * Take over the live browser for a take_control interaction. Resolves to the
1019
+ * signed control URL to show the user (`liveUrl`) and when it expires.
1020
+ */
825
1021
  async connectControl() {
826
- await this.invoke(ActionKinds.connectControl);
1022
+ return controlSession(await this.invoke(ActionKinds.connectControl));
827
1023
  }
828
- /** Refresh the live control surface / live url. */
1024
+ /** Get a fresh control URL when the current one expired. */
829
1025
  async refreshControl() {
830
- await this.invoke(ActionKinds.refreshControl);
1026
+ return controlSession(await this.invoke(ActionKinds.refreshControl));
831
1027
  }
832
1028
  /** Hand control back to the agent. */
833
1029
  async releaseControl() {
@@ -841,6 +1037,81 @@ var InteractionHandle = class {
841
1037
  async switchProfile() {
842
1038
  await this.invoke(ActionKinds.switchProfile);
843
1039
  }
1040
+ /** `POST /profiles/{profileId}/confirm` — persist the login of `session`. */
1041
+ async #confirmLogin(session, options = {}) {
1042
+ const confirmBody = { site_id: session.siteId };
1043
+ if (options.displayLabel !== void 0) confirmBody.display_label = options.displayLabel;
1044
+ const response = await this.post(
1045
+ {
1046
+ kind: ActionKinds.openLogin,
1047
+ label: "Confirm login",
1048
+ method: "POST",
1049
+ endpoint: `/profiles/${encodeURIComponent(session.profileId)}/confirm`
1050
+ },
1051
+ confirmBody,
1052
+ { requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
1053
+ );
1054
+ const raw = isJsonObject(response) ? response : {};
1055
+ const fields = camelizeRecord(raw);
1056
+ const state = asString2(fields.state) ?? "";
1057
+ return {
1058
+ profileId: asString2(fields.profileId) ?? session.profileId,
1059
+ active: state === "active",
1060
+ state,
1061
+ probeResult: asString2(fields.probeResult) ?? "",
1062
+ raw
1063
+ };
1064
+ }
1065
+ /** The save for `session`, started once; a failed attempt is forgotten so a retry saves again. */
1066
+ #saveOnce(session, options) {
1067
+ session.save ??= this.#confirmLogin(session, options).then(
1068
+ (result) => {
1069
+ if (SAVE_RETRY_PROBES.has(result.probeResult)) session.save = void 0;
1070
+ return result;
1071
+ },
1072
+ (err) => {
1073
+ if (!isNoPendingLogin(err)) session.save = void 0;
1074
+ throw err;
1075
+ }
1076
+ );
1077
+ return session.save;
1078
+ }
1079
+ /**
1080
+ * Save the login, then let the agent recheck. A user who is not signed in
1081
+ * yet still gets a recheck (the agent asks again), and so does 409
1082
+ * `no_pending_login`. A save the backend could not complete (`release_failed`)
1083
+ * throws a retryable error instead of telling the agent to go on.
1084
+ */
1085
+ async #saveThenRecheck(session, action) {
1086
+ let result;
1087
+ try {
1088
+ result = await this.#saveOnce(session);
1089
+ } catch (err) {
1090
+ if (!isNoPendingLogin(err)) throw err;
1091
+ }
1092
+ if (result && SAVE_RETRY_PROBES.has(result.probeResult)) {
1093
+ throw new QoniError(
1094
+ `the login could not be saved yet (probeResult "${result.probeResult}"); the login browser is kept \u2014 call confirmSignedIn() again to retry`,
1095
+ { code: "site_login.save_failed", retryable: true }
1096
+ );
1097
+ }
1098
+ if (session.replaced) {
1099
+ throw new QoniError(
1100
+ "the login was reopened while it was being saved; call confirmSignedIn() again to save the newer login",
1101
+ { code: "site_login.session_replaced", retryable: true }
1102
+ );
1103
+ }
1104
+ await this.post(action, void 0);
1105
+ session.completed = true;
1106
+ }
1107
+ #loginSlot() {
1108
+ let slot = this.loginSessions.get(this.id);
1109
+ if (!slot) {
1110
+ slot = { calls: 0, inflight: 0 };
1111
+ rememberBounded(this.loginSessions, this.id, slot, INTERACTION_MEMORY_LIMIT, loginSlotIdle);
1112
+ }
1113
+ return slot;
1114
+ }
844
1115
  /** Resolve the site for `openLogin`: explicit, or the only listed site. */
845
1116
  #loginSiteId(requested) {
846
1117
  const payload = this.interaction.payload;
@@ -896,6 +1167,10 @@ var RunHandle = class {
896
1167
  /** Pass back to `run({ session })` to reuse the same backend session. */
897
1168
  sessionRef;
898
1169
  capture;
1170
+ /** Login sessions opened on this run's interactions — see {@link interactionHandle}. */
1171
+ #loginSessions = /* @__PURE__ */ new Map();
1172
+ /** Latest card per interaction id, so lifecycle updates fold onto it. */
1173
+ #interactions = /* @__PURE__ */ new Map();
899
1174
  /** Refresh and return the run's current state. */
900
1175
  async status() {
901
1176
  const detail = await this.ops.getDetail();
@@ -909,8 +1184,14 @@ var RunHandle = class {
909
1184
  async *events(opts = {}) {
910
1185
  for await (const wire of this.ops.streamEvents(opts)) {
911
1186
  await opts.onWireEvent?.(wire);
912
- const event = normalizeRunEvent(wire, { topRunId: this.id });
1187
+ let event = normalizeRunEvent(wire, { topRunId: this.id });
913
1188
  if (!event) continue;
1189
+ if (event.type === "interaction") {
1190
+ const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
1191
+ rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
1192
+ markLoginEnded(this.#loginSessions, card);
1193
+ event = { ...event, data: card };
1194
+ }
914
1195
  if (event.type === "screenshot" && !this.capture.screenshots) continue;
915
1196
  if (isVideoFrame(wire) && !this.capture.videoFrames) continue;
916
1197
  yield event;
@@ -983,12 +1264,16 @@ var RunHandle = class {
983
1264
  * Wrap an {@link Interaction} (from `event.data` of an `interaction` event)
984
1265
  * in a typed {@link InteractionHandle} bound to this run. The handle's action
985
1266
  * methods (`answer` / `confirmSignedIn` / `releaseControl` / …) post to the
986
- * endpoint the backend declared for that action.
1267
+ * endpoint the backend declared for that action. Handles this RunHandle
1268
+ * hands out share the login sessions `openLogin()` opens, so
1269
+ * `confirmSignedIn()` on the handle of a later event still saves that login.
1270
+ * A RunHandle from another `attach()` (or process) does not see them.
987
1271
  */
988
1272
  interactionHandle(interaction) {
989
1273
  return new InteractionHandle(
990
1274
  interaction,
991
- (action, body, options) => this.ops.postAction(action, body, options)
1275
+ (action, body, options) => this.ops.postAction(action, body, options),
1276
+ this.#loginSessions
992
1277
  );
993
1278
  }
994
1279
  /**
@@ -1813,6 +2098,10 @@ var MonitorHandle = class {
1813
2098
  }
1814
2099
  ops;
1815
2100
  id;
2101
+ /** Login sessions opened on this monitor's interactions — see {@link interactionHandle}. */
2102
+ #loginSessions = /* @__PURE__ */ new Map();
2103
+ /** Latest card per interaction id, so settling shells fold onto it. */
2104
+ #interactions = /* @__PURE__ */ new Map();
1816
2105
  /** Refresh and return the monitor (normalized camelCase fields). */
1817
2106
  async get() {
1818
2107
  return normalizeMonitorRecord(await this.ops.get());
@@ -1857,7 +2146,13 @@ var MonitorHandle = class {
1857
2146
  */
1858
2147
  async *events(opts = {}) {
1859
2148
  for await (const wire of this.ops.streamEvents(opts)) {
1860
- const event = normalizeRunEvent(wire);
2149
+ let event = normalizeRunEvent(wire);
2150
+ if (event?.type === "interaction") {
2151
+ const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
2152
+ rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
2153
+ markLoginEnded(this.#loginSessions, card);
2154
+ event = { ...event, data: card };
2155
+ }
1861
2156
  if (event) yield event;
1862
2157
  }
1863
2158
  }
@@ -1865,12 +2160,14 @@ var MonitorHandle = class {
1865
2160
  * Wrap an {@link Interaction} (from `event.data` of an `interaction` event on
1866
2161
  * the monitor stream — e.g. a `site_login` to unlock a `needs_reauth` park)
1867
2162
  * in a typed {@link InteractionHandle}. The handle's action methods post to
1868
- * the endpoint the backend declared for that action.
2163
+ * the endpoint the backend declared for that action. Handles of one
2164
+ * MonitorHandle share the login sessions `openLogin()` opens.
1869
2165
  */
1870
2166
  interactionHandle(interaction) {
1871
2167
  return new InteractionHandle(
1872
2168
  interaction,
1873
- (action, body, options) => this.ops.postAction(action, body, options)
2169
+ (action, body, options) => this.ops.postAction(action, body, options),
2170
+ this.#loginSessions
1874
2171
  );
1875
2172
  }
1876
2173
  /**
@@ -3035,10 +3332,10 @@ function validateScopeFormat(scope) {
3035
3332
  );
3036
3333
  }
3037
3334
  if (KNOWN_SCOPES.includes(scope)) return;
3038
- const prefixed = KNOWN_SCOPES.find((known) => known.endsWith(`.${scope}`) || known === `webagent.${scope}`);
3039
- if (prefixed) {
3335
+ const prefixed = KNOWN_SCOPES.filter((known) => known.endsWith(`.${scope}`));
3336
+ if (prefixed.length > 0) {
3040
3337
  throw new QoniValidationError(
3041
- `delegateToken: scope "${scope}" is missing its service prefix \u2014 write "${prefixed}"`
3338
+ `delegateToken: scope "${scope}" is missing its service prefix \u2014 write ` + prefixed.map((known) => `"${known}"`).join(" or ")
3042
3339
  );
3043
3340
  }
3044
3341
  if (!/^[a-z0-9_]+(\.[a-z0-9_]+)+:[a-z0-9_]+$/.test(scope)) {