@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.js CHANGED
@@ -518,8 +518,22 @@ var QoniScopes = {
518
518
  // interaction). Not part of any product bundle: the AccessKey policy must
519
519
  // allow it, and the delegation must request it explicitly.
520
520
  SITE_LOGIN_REQUEST: "webagent.site_login:request",
521
- // Persist that login into the browser profile (`OpenLoginResult.confirm()`).
522
- SITE_LOGIN_CONFIRM: "webagent.site_login:confirm"
521
+ // Persist that login into the browser profile (`InteractionHandle.confirmSignedIn()`
522
+ // after `openLogin()`).
523
+ SITE_LOGIN_CONFIRM: "webagent.site_login:confirm",
524
+ // The delegating user's own data and the Agent's own mailbox (Personal
525
+ // Agent guide). Not part of any product bundle: request them explicitly;
526
+ // the AccessKey policy must allow them.
527
+ // Read the user's Profile (name, phone, shipping address, card brand + last 4).
528
+ USER_PROFILE_READ: "user.profile:read",
529
+ // Write answers back into the user's Profile.
530
+ USER_PROFILE_WRITE: "user.profile:write",
531
+ // Use the user's saved payment card once the user approves a payment.
532
+ USER_PAYMENT_USE: "user.payment:use",
533
+ // Read mail received by the Agent's own mailbox (for example verification codes).
534
+ AGENT_MAIL_READ: "agent.mail:read",
535
+ // Send mail from the Agent's own mailbox.
536
+ AGENT_MAIL_SEND: "agent.mail:send"
523
537
  };
524
538
  var QoniProductScopes = {
525
539
  doAnything: [
@@ -572,6 +586,15 @@ var QoniScopeBundles = {
572
586
  };
573
587
 
574
588
  // src/interactions.ts
589
+ var InteractionTypes2 = {
590
+ SiteLogin: "site_login",
591
+ TakeControl: "take_control",
592
+ AskUser: "ask_user",
593
+ Confirmation: "confirmation",
594
+ Wait: "wait",
595
+ /** @deprecated `clarification` is presented as `ask_user`; use `InteractionTypes.AskUser`. */
596
+ Clarification: "ask_user"
597
+ };
575
598
  function asString2(value) {
576
599
  return typeof value === "string" && value ? value : void 0;
577
600
  }
@@ -600,6 +623,49 @@ function normalizeEvidence(c) {
600
623
  const artifactId = asString2(c.evidenceArtifactId) ?? (isJsonObject(c.evidence) ? asString2(c.evidence.artifactId) : void 0);
601
624
  return artifactId ? { artifactId } : void 0;
602
625
  }
626
+ var ANSWER_TYPES = /* @__PURE__ */ new Set([
627
+ "text",
628
+ "number",
629
+ "boolean",
630
+ "single_choice",
631
+ "multiple_choice"
632
+ ]);
633
+ function normalizeOptions(value) {
634
+ if (!Array.isArray(value)) return [];
635
+ return value.flatMap((raw) => {
636
+ if (typeof raw === "string") return [{ value: raw, label: raw }];
637
+ if (!isJsonObject(raw) || typeof raw.value !== "string") return [];
638
+ return [{ value: raw.value, label: typeof raw.label === "string" ? raw.label : raw.value }];
639
+ });
640
+ }
641
+ function isAnswerType(value) {
642
+ return value !== void 0 && ANSWER_TYPES.has(value);
643
+ }
644
+ function askUserPayload(c) {
645
+ const options = normalizeOptions(c.options);
646
+ const native = asString2(c.answerType);
647
+ const hint = asString2(c.inputType);
648
+ let answerType;
649
+ if (isAnswerType(native)) answerType = native;
650
+ else if (isAnswerType(hint) && hint !== "single_choice" && hint !== "multiple_choice") answerType = hint;
651
+ else answerType = options.length > 0 ? hint === "multiple_choice" ? "multiple_choice" : "single_choice" : "text";
652
+ const payload = { question: asString2(c.question) ?? "", answerType };
653
+ if (options.length > 0) payload.options = options;
654
+ return payload;
655
+ }
656
+ var BASE_FIELDS = /* @__PURE__ */ new Set([
657
+ "id",
658
+ "type",
659
+ "status",
660
+ "createdAt",
661
+ "resolvedAt",
662
+ "expiresAt",
663
+ "title",
664
+ "prompt",
665
+ "evidence",
666
+ "evidenceArtifactId",
667
+ "actions"
668
+ ]);
603
669
  function shapePayload(type, c) {
604
670
  switch (type) {
605
671
  case "site_login": {
@@ -616,14 +682,10 @@ function shapePayload(type, c) {
616
682
  const payload = { sites };
617
683
  const monitorId = asString2(c.monitorId);
618
684
  if (monitorId) payload.monitorId = monitorId;
619
- return payload;
620
- }
621
- case "clarification": {
622
- const payload = { question: asString2(c.question) ?? "" };
623
- const inputType = asString2(c.inputType);
624
- if (inputType) payload.inputType = inputType;
625
- return payload;
685
+ return { ...payload };
626
686
  }
687
+ case "ask_user":
688
+ return { ...askUserPayload(c) };
627
689
  case "confirmation":
628
690
  return { summary: asString2(c.summary) ?? "" };
629
691
  case "take_control": {
@@ -632,7 +694,7 @@ function shapePayload(type, c) {
632
694
  const reason = asString2(c.reason);
633
695
  if (surface) payload.surface = surface;
634
696
  if (reason) payload.reason = reason;
635
- return payload;
697
+ return { ...payload };
636
698
  }
637
699
  case "wait": {
638
700
  const payload = {
@@ -642,14 +704,20 @@ function shapePayload(type, c) {
642
704
  const retryable = asBool(c.retryable);
643
705
  if (until) payload.until = until;
644
706
  if (retryable !== void 0) payload.retryable = retryable;
707
+ return { ...payload };
708
+ }
709
+ default: {
710
+ const payload = {};
711
+ for (const [key, value] of Object.entries(c)) if (!BASE_FIELDS.has(key)) payload[key] = value;
645
712
  return payload;
646
713
  }
647
714
  }
648
715
  }
649
716
  function normalizeInteraction(payload) {
650
717
  const c = camelizeRecord(payload);
651
- const type = asString2(c.type);
652
- if (!type) return null;
718
+ const wireType = asString2(c.type);
719
+ if (!wireType) return null;
720
+ const type = wireType === "clarification" ? "ask_user" : wireType;
653
721
  const interaction = {
654
722
  id: asString2(c.id) ?? "",
655
723
  type,
@@ -669,15 +737,109 @@ function normalizeInteraction(payload) {
669
737
  if (evidence) interaction.evidence = evidence;
670
738
  return interaction;
671
739
  }
740
+ var SETTLED_INTERACTION_STATUSES = /* @__PURE__ */ new Set(["resolved", "expired", "canceled"]);
741
+ function isSettlingShell(next) {
742
+ if (!SETTLED_INTERACTION_STATUSES.has(next.status) || next.actions.length > 0) return false;
743
+ switch (next.type) {
744
+ case "ask_user":
745
+ return next.payload.question === "";
746
+ case "site_login":
747
+ return next.payload.sites.every((site) => !site.siteId);
748
+ case "confirmation":
749
+ return next.payload.summary === "";
750
+ default:
751
+ return false;
752
+ }
753
+ }
754
+ function foldInteraction(previous, next) {
755
+ if (!previous || previous.id !== next.id || !isSettlingShell(next)) return next;
756
+ const folded = { ...previous, status: next.status, actions: next.actions };
757
+ if (next.resolvedAt) folded.resolvedAt = next.resolvedAt;
758
+ return folded;
759
+ }
672
760
  function interactionActionScopes(action, productManageScope) {
673
761
  return action.kind === ActionKinds.openLogin ? [QoniScopes.SITE_LOGIN_REQUEST] : [productManageScope];
674
762
  }
763
+ function loginSlotIdle(slot) {
764
+ return slot.inflight === 0 && (!slot.session || slot.ended === true || slot.session.completed === true);
765
+ }
766
+ function markLoginEnded(store, interaction) {
767
+ if (!SETTLED_INTERACTION_STATUSES.has(interaction.status)) return;
768
+ const slot = store.get(interaction.id);
769
+ if (slot) slot.ended = true;
770
+ }
771
+ var SAVE_RETRY_PROBES = /* @__PURE__ */ new Set(["release_failed"]);
772
+ function isNoPendingLogin(err) {
773
+ return err instanceof QoniError && err.status === 409 && err.code === "no_pending_login";
774
+ }
775
+ var INTERACTION_MEMORY_LIMIT = 256;
776
+ function rememberBounded(map, key, value, limit = INTERACTION_MEMORY_LIMIT, evictable = () => true) {
777
+ map.delete(key);
778
+ map.set(key, value);
779
+ if (map.size <= limit) return;
780
+ for (const [candidate, entry] of map) {
781
+ if (map.size <= limit) break;
782
+ if (candidate !== key && evictable(entry)) map.delete(candidate);
783
+ }
784
+ }
785
+ function interactionSettled(interaction) {
786
+ return SETTLED_INTERACTION_STATUSES.has(interaction.status);
787
+ }
788
+ function controlSession(response) {
789
+ const raw = isJsonObject(response) ? response : {};
790
+ const fields = camelizeRecord(raw);
791
+ const session = { liveUrl: asString2(fields.standaloneControlUrl) ?? asString2(fields.liveUrl) ?? "", raw };
792
+ const expiresAt = asString2(fields.controlTokenExpiresAt) ?? asString2(fields.expiresAt);
793
+ const status = asString2(fields.status);
794
+ if (expiresAt) session.expiresAt = expiresAt;
795
+ if (status) session.status = status;
796
+ return session;
797
+ }
798
+ function describeOptions(payload) {
799
+ return (payload.options ?? []).map((option) => option.value).join(", ");
800
+ }
801
+ function checkAnswer(payload, value) {
802
+ const fail = (expected) => {
803
+ throw new QoniValidationError(`answer() for a ${payload.answerType} question expects ${expected}`);
804
+ };
805
+ const values = new Set((payload.options ?? []).map((option) => option.value));
806
+ switch (payload.answerType) {
807
+ case "text":
808
+ if (typeof value !== "string") fail("a string");
809
+ return;
810
+ case "number":
811
+ if (typeof value !== "number" || !Number.isFinite(value)) fail("a finite number");
812
+ return;
813
+ case "boolean":
814
+ if (typeof value !== "boolean") fail("a boolean");
815
+ return;
816
+ case "single_choice":
817
+ if (typeof value !== "string" || values.size > 0 && !values.has(value)) {
818
+ fail(`one option value: ${describeOptions(payload)}`);
819
+ }
820
+ return;
821
+ case "multiple_choice":
822
+ if (!Array.isArray(value)) fail(`an array of option values: ${describeOptions(payload)}`);
823
+ if (value.some((item) => typeof item !== "string" || values.size > 0 && !values.has(item))) {
824
+ fail(`option values only: ${describeOptions(payload)}`);
825
+ }
826
+ return;
827
+ }
828
+ }
675
829
  var InteractionHandle = class {
676
- constructor(interaction, post) {
830
+ /**
831
+ * @param loginSessions Login sessions opened on interactions, by interaction
832
+ * id. A RunHandle / MonitorHandle shares one store across the handles it
833
+ * hands out, so `confirmSignedIn()` finds the login `openLogin()` opened
834
+ * even on the handle of a later event.
835
+ */
836
+ constructor(interaction, post, loginSessions = /* @__PURE__ */ new Map()) {
677
837
  this.post = post;
838
+ this.loginSessions = loginSessions;
678
839
  this.interaction = interaction;
679
840
  }
680
841
  post;
842
+ loginSessions;
681
843
  interaction;
682
844
  get id() {
683
845
  return this.interaction.id;
@@ -695,9 +857,18 @@ var InteractionHandle = class {
695
857
  can(kind) {
696
858
  return this.interaction.actions.some((a) => a.kind === kind);
697
859
  }
698
- /** Answer a clarification ask. */
699
- async answer(text) {
700
- await this.invoke(ActionKinds.answer, { response: text });
860
+ /**
861
+ * Answer an `ask_user` question. The value follows `payload.answerType`:
862
+ * `text` → string, `number` → number, `boolean` → boolean, `single_choice` →
863
+ * one option `value`, `multiple_choice` → an array of option values. A value
864
+ * that does not fit throws `QoniValidationError` before anything is sent.
865
+ */
866
+ async answer(value) {
867
+ const action = this.#declaredAction(ActionKinds.answer);
868
+ if (this.interaction.type === "ask_user") {
869
+ checkAnswer(this.interaction.payload, value);
870
+ }
871
+ await this.post(action, { response: value });
701
872
  }
702
873
  /** Skip / dismiss the interaction (proceed without acting on it). */
703
874
  async skip() {
@@ -715,8 +886,8 @@ var InteractionHandle = class {
715
886
  * Start an interactive login session for a `site_login` interaction. The
716
887
  * backend needs the site to sign in to: pass `siteId`, or omit it when the
717
888
  * interaction lists exactly one site. Show `liveUrl` to the user; when they
718
- * finish, call `confirm()` on the result to save the login, and act on its
719
- * outcome (see {@link OpenLoginResult.confirm}) before {@link confirmSignedIn}.
889
+ * say they are signed in, call {@link confirmSignedIn}, which saves the login
890
+ * and lets the agent continue.
720
891
  * The delegation must include `QoniScopes.SITE_LOGIN_REQUEST` and
721
892
  * `QoniScopes.SITE_LOGIN_CONFIRM`; the AccessKey policy must allow both.
722
893
  */
@@ -729,57 +900,82 @@ var InteractionHandle = class {
729
900
  }
730
901
  body.profile_id = options.profileId;
731
902
  }
732
- const response = await this.invoke(ActionKinds.openLogin, body);
903
+ const slot = this.#loginSlot();
904
+ const call = ++slot.calls;
905
+ slot.inflight += 1;
906
+ let response;
907
+ try {
908
+ response = await this.invoke(ActionKinds.openLogin, body);
909
+ } finally {
910
+ slot.inflight -= 1;
911
+ }
733
912
  const raw = isJsonObject(response) ? response : {};
734
913
  const fields = camelizeRecord(raw);
735
914
  const profileId = asString2(fields.profileId) ?? "";
736
915
  const siteId = asString2(fields.siteId) ?? String(body.site_id);
916
+ const sessionId = asString2(fields.sessionId) ?? "";
917
+ const current = slot.session;
918
+ const reused = current !== void 0 && sessionId !== "" && current.sessionId === sessionId && current.profileId === profileId && current.siteId === siteId;
919
+ const session = reused ? current : { profileId, siteId, sessionId, call };
920
+ if (reused) {
921
+ current.call = Math.max(current.call, call);
922
+ } else if (profileId && (current?.call ?? 0) < call) {
923
+ if (current) current.replaced = true;
924
+ slot.session = session;
925
+ } else {
926
+ session.replaced = true;
927
+ }
737
928
  return {
738
929
  profileId,
739
930
  siteId,
740
- sessionId: asString2(fields.sessionId) ?? "",
931
+ sessionId,
741
932
  liveUrl: asString2(fields.liveUrl) ?? "",
742
933
  raw,
743
934
  confirm: async (confirmOptions = {}) => {
744
935
  if (!profileId) {
745
936
  throw new QoniValidationError("openLogin response did not include a profileId to confirm");
746
937
  }
747
- const confirmBody = { site_id: siteId };
748
- if (confirmOptions.displayLabel !== void 0) confirmBody.display_label = confirmOptions.displayLabel;
749
- const response2 = await this.post(
750
- {
751
- kind: ActionKinds.openLogin,
752
- label: "Confirm login",
753
- method: "POST",
754
- endpoint: `/profiles/${encodeURIComponent(profileId)}/confirm`
755
- },
756
- confirmBody,
757
- { requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
758
- );
759
- const raw2 = isJsonObject(response2) ? response2 : {};
760
- const fields2 = camelizeRecord(raw2);
761
- const state = asString2(fields2.state) ?? "";
762
- return {
763
- profileId: asString2(fields2.profileId) ?? profileId,
764
- active: state === "active",
765
- state,
766
- probeResult: asString2(fields2.probeResult) ?? "",
767
- raw: raw2
768
- };
938
+ if (session.replaced) {
939
+ throw new QoniValidationError(
940
+ "this login session was replaced by a newer openLogin(); confirm the newer one (or call confirmSignedIn(), which saves the current session)"
941
+ );
942
+ }
943
+ return this.#saveOnce(session, confirmOptions);
769
944
  }
770
945
  };
771
946
  }
772
- /** Tell the agent the user has finished signing in. */
947
+ /**
948
+ * The user finished signing in through the live browser `openLogin()`
949
+ * opened. Saves that login (so later tasks reuse it), then tells the agent
950
+ * to recheck the site and continue. If the user is not signed in after all,
951
+ * the agent raises a new `site_login` interaction.
952
+ *
953
+ * Saving needs `QoniScopes.SITE_LOGIN_CONFIRM` on the delegation; without
954
+ * `openLogin()` on this interaction, only the recheck is sent.
955
+ */
773
956
  async confirmSignedIn() {
774
- await this.invoke(ActionKinds.confirmSignedIn);
957
+ const action = this.#declaredAction(ActionKinds.confirmSignedIn);
958
+ const session = this.loginSessions.get(this.id)?.session;
959
+ if (!session) {
960
+ await this.post(action, void 0);
961
+ return;
962
+ }
963
+ session.done ??= this.#saveThenRecheck(session, action).catch((err) => {
964
+ session.done = void 0;
965
+ throw err;
966
+ });
967
+ await session.done;
775
968
  }
776
- /** Connect to the live browser for a take_control interaction. */
969
+ /**
970
+ * Take over the live browser for a take_control interaction. Resolves to the
971
+ * signed control URL to show the user (`liveUrl`) and when it expires.
972
+ */
777
973
  async connectControl() {
778
- await this.invoke(ActionKinds.connectControl);
974
+ return controlSession(await this.invoke(ActionKinds.connectControl));
779
975
  }
780
- /** Refresh the live control surface / live url. */
976
+ /** Get a fresh control URL when the current one expired. */
781
977
  async refreshControl() {
782
- await this.invoke(ActionKinds.refreshControl);
978
+ return controlSession(await this.invoke(ActionKinds.refreshControl));
783
979
  }
784
980
  /** Hand control back to the agent. */
785
981
  async releaseControl() {
@@ -793,6 +989,81 @@ var InteractionHandle = class {
793
989
  async switchProfile() {
794
990
  await this.invoke(ActionKinds.switchProfile);
795
991
  }
992
+ /** `POST /profiles/{profileId}/confirm` — persist the login of `session`. */
993
+ async #confirmLogin(session, options = {}) {
994
+ const confirmBody = { site_id: session.siteId };
995
+ if (options.displayLabel !== void 0) confirmBody.display_label = options.displayLabel;
996
+ const response = await this.post(
997
+ {
998
+ kind: ActionKinds.openLogin,
999
+ label: "Confirm login",
1000
+ method: "POST",
1001
+ endpoint: `/profiles/${encodeURIComponent(session.profileId)}/confirm`
1002
+ },
1003
+ confirmBody,
1004
+ { requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
1005
+ );
1006
+ const raw = isJsonObject(response) ? response : {};
1007
+ const fields = camelizeRecord(raw);
1008
+ const state = asString2(fields.state) ?? "";
1009
+ return {
1010
+ profileId: asString2(fields.profileId) ?? session.profileId,
1011
+ active: state === "active",
1012
+ state,
1013
+ probeResult: asString2(fields.probeResult) ?? "",
1014
+ raw
1015
+ };
1016
+ }
1017
+ /** The save for `session`, started once; a failed attempt is forgotten so a retry saves again. */
1018
+ #saveOnce(session, options) {
1019
+ session.save ??= this.#confirmLogin(session, options).then(
1020
+ (result) => {
1021
+ if (SAVE_RETRY_PROBES.has(result.probeResult)) session.save = void 0;
1022
+ return result;
1023
+ },
1024
+ (err) => {
1025
+ if (!isNoPendingLogin(err)) session.save = void 0;
1026
+ throw err;
1027
+ }
1028
+ );
1029
+ return session.save;
1030
+ }
1031
+ /**
1032
+ * Save the login, then let the agent recheck. A user who is not signed in
1033
+ * yet still gets a recheck (the agent asks again), and so does 409
1034
+ * `no_pending_login`. A save the backend could not complete (`release_failed`)
1035
+ * throws a retryable error instead of telling the agent to go on.
1036
+ */
1037
+ async #saveThenRecheck(session, action) {
1038
+ let result;
1039
+ try {
1040
+ result = await this.#saveOnce(session);
1041
+ } catch (err) {
1042
+ if (!isNoPendingLogin(err)) throw err;
1043
+ }
1044
+ if (result && SAVE_RETRY_PROBES.has(result.probeResult)) {
1045
+ throw new QoniError(
1046
+ `the login could not be saved yet (probeResult "${result.probeResult}"); the login browser is kept \u2014 call confirmSignedIn() again to retry`,
1047
+ { code: "site_login.save_failed", retryable: true }
1048
+ );
1049
+ }
1050
+ if (session.replaced) {
1051
+ throw new QoniError(
1052
+ "the login was reopened while it was being saved; call confirmSignedIn() again to save the newer login",
1053
+ { code: "site_login.session_replaced", retryable: true }
1054
+ );
1055
+ }
1056
+ await this.post(action, void 0);
1057
+ session.completed = true;
1058
+ }
1059
+ #loginSlot() {
1060
+ let slot = this.loginSessions.get(this.id);
1061
+ if (!slot) {
1062
+ slot = { calls: 0, inflight: 0 };
1063
+ rememberBounded(this.loginSessions, this.id, slot, INTERACTION_MEMORY_LIMIT, loginSlotIdle);
1064
+ }
1065
+ return slot;
1066
+ }
796
1067
  /** Resolve the site for `openLogin`: explicit, or the only listed site. */
797
1068
  #loginSiteId(requested) {
798
1069
  const payload = this.interaction.payload;
@@ -848,6 +1119,10 @@ var RunHandle = class {
848
1119
  /** Pass back to `run({ session })` to reuse the same backend session. */
849
1120
  sessionRef;
850
1121
  capture;
1122
+ /** Login sessions opened on this run's interactions — see {@link interactionHandle}. */
1123
+ #loginSessions = /* @__PURE__ */ new Map();
1124
+ /** Latest card per interaction id, so lifecycle updates fold onto it. */
1125
+ #interactions = /* @__PURE__ */ new Map();
851
1126
  /** Refresh and return the run's current state. */
852
1127
  async status() {
853
1128
  const detail = await this.ops.getDetail();
@@ -861,8 +1136,14 @@ var RunHandle = class {
861
1136
  async *events(opts = {}) {
862
1137
  for await (const wire of this.ops.streamEvents(opts)) {
863
1138
  await opts.onWireEvent?.(wire);
864
- const event = normalizeRunEvent(wire, { topRunId: this.id });
1139
+ let event = normalizeRunEvent(wire, { topRunId: this.id });
865
1140
  if (!event) continue;
1141
+ if (event.type === "interaction") {
1142
+ const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
1143
+ rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
1144
+ markLoginEnded(this.#loginSessions, card);
1145
+ event = { ...event, data: card };
1146
+ }
866
1147
  if (event.type === "screenshot" && !this.capture.screenshots) continue;
867
1148
  if (isVideoFrame(wire) && !this.capture.videoFrames) continue;
868
1149
  yield event;
@@ -935,12 +1216,16 @@ var RunHandle = class {
935
1216
  * Wrap an {@link Interaction} (from `event.data` of an `interaction` event)
936
1217
  * in a typed {@link InteractionHandle} bound to this run. The handle's action
937
1218
  * methods (`answer` / `confirmSignedIn` / `releaseControl` / …) post to the
938
- * endpoint the backend declared for that action.
1219
+ * endpoint the backend declared for that action. Handles this RunHandle
1220
+ * hands out share the login sessions `openLogin()` opens, so
1221
+ * `confirmSignedIn()` on the handle of a later event still saves that login.
1222
+ * A RunHandle from another `attach()` (or process) does not see them.
939
1223
  */
940
1224
  interactionHandle(interaction) {
941
1225
  return new InteractionHandle(
942
1226
  interaction,
943
- (action, body, options) => this.ops.postAction(action, body, options)
1227
+ (action, body, options) => this.ops.postAction(action, body, options),
1228
+ this.#loginSessions
944
1229
  );
945
1230
  }
946
1231
  /**
@@ -1765,6 +2050,10 @@ var MonitorHandle = class {
1765
2050
  }
1766
2051
  ops;
1767
2052
  id;
2053
+ /** Login sessions opened on this monitor's interactions — see {@link interactionHandle}. */
2054
+ #loginSessions = /* @__PURE__ */ new Map();
2055
+ /** Latest card per interaction id, so settling shells fold onto it. */
2056
+ #interactions = /* @__PURE__ */ new Map();
1768
2057
  /** Refresh and return the monitor (normalized camelCase fields). */
1769
2058
  async get() {
1770
2059
  return normalizeMonitorRecord(await this.ops.get());
@@ -1809,7 +2098,13 @@ var MonitorHandle = class {
1809
2098
  */
1810
2099
  async *events(opts = {}) {
1811
2100
  for await (const wire of this.ops.streamEvents(opts)) {
1812
- const event = normalizeRunEvent(wire);
2101
+ let event = normalizeRunEvent(wire);
2102
+ if (event?.type === "interaction") {
2103
+ const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
2104
+ rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
2105
+ markLoginEnded(this.#loginSessions, card);
2106
+ event = { ...event, data: card };
2107
+ }
1813
2108
  if (event) yield event;
1814
2109
  }
1815
2110
  }
@@ -1817,12 +2112,14 @@ var MonitorHandle = class {
1817
2112
  * Wrap an {@link Interaction} (from `event.data` of an `interaction` event on
1818
2113
  * the monitor stream — e.g. a `site_login` to unlock a `needs_reauth` park)
1819
2114
  * in a typed {@link InteractionHandle}. The handle's action methods post to
1820
- * the endpoint the backend declared for that action.
2115
+ * the endpoint the backend declared for that action. Handles of one
2116
+ * MonitorHandle share the login sessions `openLogin()` opens.
1821
2117
  */
1822
2118
  interactionHandle(interaction) {
1823
2119
  return new InteractionHandle(
1824
2120
  interaction,
1825
- (action, body, options) => this.ops.postAction(action, body, options)
2121
+ (action, body, options) => this.ops.postAction(action, body, options),
2122
+ this.#loginSessions
1826
2123
  );
1827
2124
  }
1828
2125
  /**
@@ -2987,10 +3284,10 @@ function validateScopeFormat(scope) {
2987
3284
  );
2988
3285
  }
2989
3286
  if (KNOWN_SCOPES.includes(scope)) return;
2990
- const prefixed = KNOWN_SCOPES.find((known) => known.endsWith(`.${scope}`) || known === `webagent.${scope}`);
2991
- if (prefixed) {
3287
+ const prefixed = KNOWN_SCOPES.filter((known) => known.endsWith(`.${scope}`));
3288
+ if (prefixed.length > 0) {
2992
3289
  throw new QoniValidationError(
2993
- `delegateToken: scope "${scope}" is missing its service prefix \u2014 write "${prefixed}"`
3290
+ `delegateToken: scope "${scope}" is missing its service prefix \u2014 write ` + prefixed.map((known) => `"${known}"`).join(" or ")
2994
3291
  );
2995
3292
  }
2996
3293
  if (!/^[a-z0-9_]+(\.[a-z0-9_]+)+:[a-z0-9_]+$/.test(scope)) {
@@ -3212,7 +3509,7 @@ export {
3212
3509
  ActionKinds,
3213
3510
  InteractionHandle,
3214
3511
  InteractionStatuses,
3215
- InteractionTypes,
3512
+ InteractionTypes2 as InteractionTypes,
3216
3513
  MonitorHandle,
3217
3514
  Qoni,
3218
3515
  QoniAuthError,