@pingroom/sdk 0.7.0 → 0.8.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.
Files changed (44) hide show
  1. package/README.md +144 -12
  2. package/dist/client.d.ts +67 -18
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +275 -111
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +8 -3
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +9 -3
  9. package/dist/errors.js.map +1 -1
  10. package/dist/http.d.ts +12 -1
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +40 -17
  13. package/dist/http.js.map +1 -1
  14. package/dist/internal/async.d.ts +2 -0
  15. package/dist/internal/async.d.ts.map +1 -1
  16. package/dist/internal/async.js +6 -0
  17. package/dist/internal/async.js.map +1 -1
  18. package/dist/internal/guards.d.ts +9 -0
  19. package/dist/internal/guards.d.ts.map +1 -1
  20. package/dist/internal/guards.js +41 -1
  21. package/dist/internal/guards.js.map +1 -1
  22. package/dist/internal/retry.d.ts +58 -0
  23. package/dist/internal/retry.d.ts.map +1 -0
  24. package/dist/internal/retry.js +108 -0
  25. package/dist/internal/retry.js.map +1 -0
  26. package/dist/internal/url.d.ts +9 -0
  27. package/dist/internal/url.d.ts.map +1 -1
  28. package/dist/internal/url.js +25 -0
  29. package/dist/internal/url.js.map +1 -1
  30. package/dist/linkPing.d.ts.map +1 -1
  31. package/dist/linkPing.js +6 -2
  32. package/dist/linkPing.js.map +1 -1
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +10 -2
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/types.d.ts +182 -18
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/dist/webhooks.d.ts +20 -3
  41. package/dist/webhooks.d.ts.map +1 -1
  42. package/dist/webhooks.js +14 -9
  43. package/dist/webhooks.js.map +1 -1
  44. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
2
2
  import { HttpClient } from './http.js';
3
- import { combineSignals, sleep } from './internal/async.js';
4
- import { assertActionNumber, assertMaxLength, assertStructuredData, MAX_PING_TITLE_LENGTH, MAX_PUBLIC_PING_MESSAGE_LENGTH, normalizeRedeemCode, requireNonEmpty, requirePresentString, } from './internal/guards.js';
3
+ import { combineSignals, sleep, throwIfAborted } from './internal/async.js';
4
+ import { assertActionNumber, assertMaxLength, assertStructuredData, MAX_PING_TITLE_LENGTH, MAX_PUBLIC_PING_MESSAGE_LENGTH, normalizeIdempotencyKey, normalizeRedeemCode, requireNonEmpty, requirePresentString, } from './internal/guards.js';
5
+ import { isTransientError, pollUntilSettled, transientDelayMs } from './internal/retry.js';
5
6
  import { linkPing } from './linkPing.js';
6
7
  import { locationPing } from './locationPing.js';
7
8
  import { McpClient } from './mcp.js';
8
9
  const DEFAULT_BASE_URL = 'https://api.pingroom.io';
10
+ /** Node's util.inspect hook, named through the global registry so no import is needed. */
11
+ const INSPECT = Symbol.for('nodejs.util.inspect.custom');
9
12
  /** Server cap per uploaded file; a larger body is a 413 the server answers before reading it. */
10
13
  const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
11
14
  const MAX_TRIGGER_ATTACHMENTS = 4;
@@ -15,14 +18,70 @@ const MAX_QUICK_ACTION_PAGES = 4;
15
18
  const LONG_POLL_BUFFER_MS = 10_000;
16
19
  const DEFAULT_WAIT_SECONDS = 20;
17
20
  const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
18
- const MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS = 2100;
21
+ /**
22
+ * Minimum spacing between the starts of two handoff-wait polls. The route is
23
+ * throttled at 30 requests a minute per human; a short or zero `timeout`
24
+ * would otherwise let one loop exhaust it.
25
+ */
26
+ const MIN_HANDOFF_POLL_INTERVAL_MS = 2100;
27
+ /**
28
+ * Minimum spacing between the starts of two approval or question wait polls.
29
+ * The server normally holds each one, but a proxy or a zero hold that answers
30
+ * `pending` at once would otherwise spin the loop and keep the human's shared
31
+ * 30-per-minute wait bucket exhausted. Same floor as the CLI.
32
+ */
33
+ const MIN_WAIT_POLL_INTERVAL_MS = 1000;
19
34
  function envBaseUrl() {
20
35
  const proc = globalThis.process;
21
36
  return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
22
37
  }
23
38
  function enc(segment) {
39
+ // encodeURIComponent leaves `.` and `..` alone, and URL parsing then
40
+ // resolves them as dot segments: `live.get('AB12', '..')` would request the
41
+ // room itself. No id is empty or a dot segment, so refuse them.
42
+ if (segment === '' || segment === '.' || segment === '..') {
43
+ throw new PingRoomError('A path parameter may not be empty, `.` or `..`.', { code: 'invalid_request' });
44
+ }
24
45
  return encodeURIComponent(segment);
25
46
  }
47
+ /**
48
+ * Lift the SDK-only `idempotencyKey` out of an input and into the
49
+ * `Idempotency-Key` header the server reads. The server trims the key and
50
+ * accepts 1–255 characters; as a header it must also be printable ASCII.
51
+ */
52
+ function splitIdempotencyKey(input) {
53
+ const { idempotencyKey, ...body } = input;
54
+ if (idempotencyKey === undefined) {
55
+ return { body };
56
+ }
57
+ return { body, headers: { 'Idempotency-Key': normalizeIdempotencyKey(idempotencyKey) } };
58
+ }
59
+ /** `onRetry`, when given, must be callable: a TypeError mid-outage would hide the real failure. */
60
+ function assertOnRetry(onRetry) {
61
+ if (onRetry !== undefined && typeof onRetry !== 'function') {
62
+ throw new PingRoomError('`onRetry` must be a function.', { code: 'invalid_request' });
63
+ }
64
+ }
65
+ /**
66
+ * The per-slot checks shared by `updateMany()` and `updateLayout()`: a valid,
67
+ * present, unique action_number and an icon whenever the slot has a label.
68
+ * Two entries for one slot would let the last silently win, and the caller
69
+ * would never learn which of its definitions was stored.
70
+ */
71
+ function assertActionSlots(actions) {
72
+ const seen = new Set();
73
+ for (const action of actions) {
74
+ assertActionNumber(action.action_number);
75
+ if (action.action_number === undefined || action.action_number === null) {
76
+ throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
77
+ }
78
+ if (seen.has(action.action_number)) {
79
+ throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
80
+ }
81
+ seen.add(action.action_number);
82
+ assertQuickActionIcon(action);
83
+ }
84
+ }
26
85
  function dropUndefined(obj) {
27
86
  const out = {};
28
87
  for (const [k, v] of Object.entries(obj)) {
@@ -219,10 +278,56 @@ function validateInboxWait(value, expectedId) {
219
278
  }
220
279
  return value;
221
280
  }
222
- function throwIfAborted(signal) {
223
- if (signal.aborted) {
224
- throw signal.reason ?? new Error('aborted');
281
+ /** UUIDs are case-insensitive; a caller may hold an id in either case. */
282
+ function sameId(received, expected) {
283
+ return typeof received === 'string' && received.toLowerCase() === expected.toLowerCase();
284
+ }
285
+ function invalidWaitResponse(subject, expectedId, value, stateField) {
286
+ throw new PingRoomError(`PingRoom returned an invalid ${subject} wait response.`, {
287
+ code: 'invalid_response',
288
+ body: {
289
+ expected_id: expectedId,
290
+ received_id: isRecord(value) && typeof value['id'] === 'string' ? value['id'] : null,
291
+ [stateField]: isRecord(value) && typeof value[stateField] === 'string' ? value[stateField] : null,
292
+ },
293
+ });
294
+ }
295
+ const APPROVAL_STATUSES = new Set(['pending', 'decided', 'expired', 'cancelled']);
296
+ const QUESTION_STATES = new Set(['pending', 'answered', 'expired', 'cancelled']);
297
+ const HANDOFF_STATES = new Set(['open', 'acked', 'pending', 'answered', 'expired', 'cancelled']);
298
+ /**
299
+ * A wait response is trusted as an outcome only when it is the approval that
300
+ * was asked about, in a known status. A captive portal's HTML, `{}`, or another
301
+ * approval must not read as "decided".
302
+ */
303
+ function validateApprovalWait(value, approvalId) {
304
+ const status = isRecord(value) ? value['status'] : undefined;
305
+ if (!isRecord(value)
306
+ || !sameId(value['id'], approvalId)
307
+ || typeof status !== 'string'
308
+ || !APPROVAL_STATUSES.has(status)
309
+ || !isNullableString(value['decided_at'])
310
+ // A gate acts on `decision`; a decided approval without one, or a pending
311
+ // one already stamped decided, is not an outcome it can act on.
312
+ || (status === 'decided' && !isNonEmptyString(value['decision']))
313
+ || (status === 'pending' && value['decided_at'] !== null)) {
314
+ return invalidWaitResponse('approval', approvalId, value, 'status');
225
315
  }
316
+ return value;
317
+ }
318
+ function validateQuestionWait(value, questionId) {
319
+ const state = isRecord(value) ? value['state'] : undefined;
320
+ if (!isRecord(value) || !sameId(value['id'], questionId) || typeof state !== 'string' || !QUESTION_STATES.has(state)) {
321
+ return invalidWaitResponse('question', questionId, value, 'state');
322
+ }
323
+ return value;
324
+ }
325
+ function validateHandoffWait(value, handoffId) {
326
+ const state = isRecord(value) ? value['state'] : undefined;
327
+ if (!isRecord(value) || !sameId(value['id'], handoffId) || typeof state !== 'string' || !HANDOFF_STATES.has(state)) {
328
+ return invalidWaitResponse('handoff', handoffId, value, 'state');
329
+ }
330
+ return value;
226
331
  }
227
332
  // --- namespaces -----------------------------------------------------------
228
333
  /** Agent credential lifecycle (auth.md flow). */
@@ -290,10 +395,10 @@ class AuthApi {
290
395
  const deadline = startedAt + lifetimeMs;
291
396
  let transientFailures = 0;
292
397
  while (Date.now() < deadline) {
293
- this.throwIfPairingAborted(options.signal);
398
+ throwIfAborted(options.signal);
294
399
  try {
295
400
  const status = await this.pairingStatus({ signal: options.signal });
296
- this.throwIfPairingAborted(options.signal);
401
+ throwIfAborted(options.signal);
297
402
  transientFailures = 0;
298
403
  if (status.status === 'active') {
299
404
  if (typeof status.credential !== 'string'
@@ -356,11 +461,6 @@ class AuthApi {
356
461
  code: 'pairing_expired',
357
462
  });
358
463
  }
359
- throwIfPairingAborted(signal) {
360
- if (signal?.aborted) {
361
- throw signal.reason ?? new Error('aborted');
362
- }
363
- }
364
464
  /** Rotate to a fresh credential (same scopes/handle). */
365
465
  refresh() {
366
466
  return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
@@ -369,6 +469,23 @@ class AuthApi {
369
469
  revoke() {
370
470
  return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
371
471
  }
472
+ /**
473
+ * What this credential is connected to: the robot, the owner's public id and
474
+ * name, the home room and room memberships, and the room grant. Check
475
+ * `owner.id` and `handle` against the account and robot you approved before
476
+ * sending anything.
477
+ */
478
+ connection() {
479
+ return this.http.request('GET', '/api/agent/connection');
480
+ }
481
+ /**
482
+ * Disconnect this agent: revokes the calling credential and its refresh
483
+ * credentials. The client keeps the now-revoked token; clear it with
484
+ * `setToken(null)` or pair again.
485
+ */
486
+ disconnect() {
487
+ return this.http.request('POST', '/api/agent/disconnect', { body: {} });
488
+ }
372
489
  }
373
490
  class RoomsApi {
374
491
  http;
@@ -446,20 +563,7 @@ class ActionsApi {
446
563
  if (actions.length > 16) {
447
564
  throw new PingRoomError('A batch may contain at most 16 action slots.', { code: 'invalid_request' });
448
565
  }
449
- const seen = new Set();
450
- for (const action of actions) {
451
- assertActionNumber(action.action_number);
452
- if (action.action_number === undefined || action.action_number === null) {
453
- throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
454
- }
455
- // Two entries for one slot would let the last silently win, and the
456
- // caller would never learn which of its definitions was stored.
457
- if (seen.has(action.action_number)) {
458
- throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
459
- }
460
- seen.add(action.action_number);
461
- assertQuickActionIcon(action);
462
- }
566
+ assertActionSlots(actions);
463
567
  return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions`, {
464
568
  body: { actions: actions.map((action) => dropUndefined({ ...action })) },
465
569
  });
@@ -475,8 +579,10 @@ class ActionsApi {
475
579
  trigger(inviteCode, actionNumber, input = {}) {
476
580
  assertActionNumber(actionNumber);
477
581
  assertTriggerInput(input);
582
+ const { body, headers } = splitIdempotencyKey(input);
478
583
  return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}/trigger`, {
479
- body: dropUndefined({ ...input }),
584
+ body: dropUndefined({ ...body }),
585
+ ...(headers ? { headers } : {}),
480
586
  });
481
587
  }
482
588
  /**
@@ -507,15 +613,7 @@ class ActionsApi {
507
613
  code: 'invalid_request',
508
614
  });
509
615
  }
510
- const seen = new Set();
511
- for (const action of input.actions) {
512
- assertActionNumber(action.action_number);
513
- if (seen.has(action.action_number)) {
514
- throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
515
- }
516
- seen.add(action.action_number);
517
- assertQuickActionIcon(action);
518
- }
616
+ assertActionSlots(input.actions);
519
617
  return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/layout`, {
520
618
  body: {
521
619
  base_action_ids: input.base_action_ids,
@@ -649,35 +747,39 @@ class NotificationsApi {
649
747
  */
650
748
  async *listen(input = {}) {
651
749
  let cursor = input.after;
652
- if (cursor === undefined) {
653
- const head = await this.wait({ timeout: 0, ...(input.signal ? { signal: input.signal } : {}) });
654
- cursor = head.cursor;
655
- for (const n of head.notifications) {
656
- yield n;
657
- }
658
- }
659
- const backoff = input.backoffMs ?? 1000;
750
+ let headRead = cursor !== undefined;
751
+ const backoffMs = input.backoffMs ?? 1000;
752
+ let failures = 0;
660
753
  while (!input.signal?.aborted) {
661
754
  let result;
662
755
  try {
663
- result = await this.wait({
664
- after: cursor,
665
- ...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
666
- ...(input.limit !== undefined ? { limit: input.limit } : {}),
667
- ...(input.signal ? { signal: input.signal } : {}),
668
- });
756
+ result = headRead
757
+ ? await this.wait({
758
+ after: cursor,
759
+ ...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
760
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
761
+ ...(input.signal ? { signal: input.signal } : {}),
762
+ })
763
+ // No cursor yet: read the current head (an immediate, empty answer)
764
+ // so only pings that land after this call are delivered.
765
+ : await this.wait({ timeout: 0, ...(input.signal ? { signal: input.signal } : {}) });
669
766
  }
670
767
  catch (err) {
671
768
  if (input.signal?.aborted) {
672
769
  return;
673
770
  }
674
- if (input.onError) {
675
- input.onError(err);
676
- await sleep(backoff, input.signal);
677
- continue;
771
+ // A revoked credential or a bad request never heals; only network
772
+ // failures, deadlines, 429 and 5xx are retried.
773
+ if (!isTransientError(err)) {
774
+ throw err;
678
775
  }
679
- throw err;
776
+ failures += 1;
777
+ input.onError?.(err);
778
+ await sleep(transientDelayMs(err, failures, backoffMs), input.signal);
779
+ continue;
680
780
  }
781
+ failures = 0;
782
+ headRead = true;
681
783
  cursor = result.cursor ?? cursor;
682
784
  for (const n of result.notifications) {
683
785
  yield n;
@@ -700,7 +802,11 @@ class AgentsApi {
700
802
  ping(handle, ping) {
701
803
  requireNonEmpty(handle, 'handle');
702
804
  assertPing(ping);
703
- return this.http.request('POST', `/api/agent/agents/${enc(handle)}/ping`, { body: dropUndefined({ ...ping }) });
805
+ const { body, headers } = splitIdempotencyKey(ping);
806
+ return this.http.request('POST', `/api/agent/agents/${enc(handle)}/ping`, {
807
+ body: dropUndefined({ ...body }),
808
+ ...(headers ? { headers } : {}),
809
+ });
704
810
  }
705
811
  }
706
812
  class ApprovalsApi {
@@ -712,8 +818,10 @@ class ApprovalsApi {
712
818
  request(inviteCode, input) {
713
819
  requireNonEmpty(input.question, 'question');
714
820
  assertStructuredData(input.data);
821
+ const { body, headers } = splitIdempotencyKey(input);
715
822
  return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/approvals`, {
716
- body: dropUndefined({ ...input }),
823
+ body: dropUndefined({ ...body }),
824
+ ...(headers ? { headers } : {}),
717
825
  });
718
826
  }
719
827
  get(approvalId) {
@@ -727,20 +835,29 @@ class ApprovalsApi {
727
835
  ...(input.signal ? { signal: input.signal } : {}),
728
836
  });
729
837
  }
730
- /** Block until the human decides (or the request expires), looping wait() under the hood. */
838
+ /**
839
+ * Block until the human decides (or the request expires or is cancelled),
840
+ * looping wait() under the hood, with polls started at least 1 s apart. A
841
+ * response that is not this approval in a known status rejects with
842
+ * `invalid_response`. Network failures, 429 and 5xx are retried with backoff
843
+ * (honouring `retryAfter`), and reported to `onRetry`, until `signal` aborts
844
+ * or the outage outlasts the approval's `expires_at` by 5 minutes (15 minutes
845
+ * of failures while no `expires_at` has been read); that rejects with
846
+ * `unavailable`. See {@link WaitForOutcomeOptions}.
847
+ */
731
848
  async waitForDecision(approvalId, input = {}) {
732
- for (;;) {
733
- if (input.signal?.aborted) {
734
- throw input.signal.reason ?? new Error('aborted');
735
- }
736
- const approval = await this.wait(approvalId, input.signal ? { signal: input.signal } : {});
737
- if (approval.decided_at !== null || approval.status !== 'pending') {
738
- return approval;
739
- }
740
- // An injected transport can resolve pending polls immediately. Yield
741
- // between them so timer-driven cancellation and deadlines can run.
742
- await sleep(0, input.signal);
743
- }
849
+ requireNonEmpty(approvalId, 'approvalId');
850
+ assertOnRetry(input.onRetry);
851
+ return pollUntilSettled({
852
+ poll: () => this.wait(approvalId, input.signal ? { signal: input.signal } : {}),
853
+ validate: (value) => validateApprovalWait(value, approvalId),
854
+ settled: (approval) => approval.status !== 'pending',
855
+ expiresAt: (approval) => approval.expires_at,
856
+ what: `approval ${approvalId}`,
857
+ minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
858
+ ...(input.signal ? { signal: input.signal } : {}),
859
+ ...(input.onRetry ? { onRetry: input.onRetry } : {}),
860
+ });
744
861
  }
745
862
  }
746
863
  /**
@@ -754,18 +871,19 @@ class QuestionsApi {
754
871
  constructor(http) {
755
872
  this.http = http;
756
873
  }
757
- /** Ask a Question in a room; returns the pending Question (id + computed expiry). */
874
+ /**
875
+ * Ask a Question in a room; returns the pending Question (id + computed
876
+ * expiry). Pass `resolution_policy: 'all'` to poll the room: everyone asked
877
+ * votes once, and `results` carries the tally on every read.
878
+ */
758
879
  ask(inviteCode, input) {
759
880
  requireNonEmpty(input.prompt, 'prompt');
760
881
  assertStructuredData(input.data);
761
- const { idempotencyKey, ...body } = input;
762
- if (idempotencyKey !== undefined) {
763
- requireNonEmpty(idempotencyKey, 'idempotencyKey');
764
- assertMaxLength(idempotencyKey, 255, 'idempotencyKey');
765
- }
882
+ assertPollShape(input);
883
+ const { body, headers } = splitIdempotencyKey(input);
766
884
  return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/questions`, {
767
885
  body: dropUndefined(body),
768
- ...(idempotencyKey !== undefined ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
886
+ ...(headers ? { headers } : {}),
769
887
  });
770
888
  }
771
889
  /** Fetch a Question's current state. */
@@ -783,20 +901,29 @@ class QuestionsApi {
783
901
  /**
784
902
  * Block until the human answers (or the question expires/cancels), looping
785
903
  * wait() under the hood. Returns the resolved Question — inspect `state` and
786
- * `answer.value` to branch.
904
+ * `answer.value` to branch. A poll returns once it closes; its `answer` is the
905
+ * leading option, or `null` on a tie, and `results` holds the tally.
906
+ *
907
+ * Polls start at least 1 s apart. A response that is not this Question in a
908
+ * known state rejects with `invalid_response`. Network failures, 429 and 5xx
909
+ * are retried with backoff (honouring `retryAfter`), and reported to
910
+ * `onRetry`, until `signal` aborts or the outage outlasts the Question's
911
+ * `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
912
+ * been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
787
913
  */
788
914
  async waitForAnswer(questionId, input = {}) {
789
- for (;;) {
790
- if (input.signal?.aborted) {
791
- throw input.signal.reason ?? new Error('aborted');
792
- }
793
- const question = await this.wait(questionId, input.signal ? { signal: input.signal } : {});
794
- if (question.state !== 'pending') {
795
- return question;
796
- }
797
- // Yield between polls so timer-driven cancellation can run (see ApprovalsApi).
798
- await sleep(0, input.signal);
799
- }
915
+ requireNonEmpty(questionId, 'questionId');
916
+ assertOnRetry(input.onRetry);
917
+ return pollUntilSettled({
918
+ poll: () => this.wait(questionId, input.signal ? { signal: input.signal } : {}),
919
+ validate: (value) => validateQuestionWait(value, questionId),
920
+ settled: (question) => question.state !== 'pending',
921
+ expiresAt: (question) => question.expires_at,
922
+ what: `question ${questionId}`,
923
+ minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
924
+ ...(input.signal ? { signal: input.signal } : {}),
925
+ ...(input.onRetry ? { onRetry: input.onRetry } : {}),
926
+ });
800
927
  }
801
928
  /** List the asker's Questions, optionally filtered by state. */
802
929
  async list(input = {}) {
@@ -810,6 +937,21 @@ class QuestionsApi {
810
937
  return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
811
938
  }
812
939
  }
940
+ /**
941
+ * A poll asks the whole room and counts option votes, so the server refuses it
942
+ * (422 on `resolution_policy`) alongside a direct scope, a named target, or a
943
+ * typed answer. Fail before the request instead.
944
+ */
945
+ function assertPollShape(input) {
946
+ if (input.resolution_policy !== 'all')
947
+ return;
948
+ const target = input.target_user_id;
949
+ if (input.responder_scope === 'direct'
950
+ || (target !== undefined && target !== null && target !== '')
951
+ || (input.text_input !== undefined && input.text_input !== null)) {
952
+ throw new PingRoomError("A poll (`resolution_policy: 'all'`) asks the whole room with options only; it cannot use `responder_scope: 'direct'`, `target_user_id`, or `text_input`.", { code: 'invalid_request' });
953
+ }
954
+ }
813
955
  /** A bare string option is shorthand for `{ value, label: value }`. */
814
956
  function normalizeHandoffOptions(options) {
815
957
  return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
@@ -865,20 +1007,28 @@ class HandoffsApi {
865
1007
  * Block until the handoff resolves (or expires/cancels), looping the bounded
866
1008
  * long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
867
1009
  * `hold`) all surface as normal terminal results — nothing is thrown for them.
1010
+ *
1011
+ * Polls start at least 2.1 s apart, so a short `timeout` stays under the
1012
+ * route's 30-per-minute limit. A response that is not this handoff in a
1013
+ * known state rejects with `invalid_response`. Network failures, 429 and 5xx
1014
+ * are retried with backoff (honouring `retryAfter`), and reported to
1015
+ * `onRetry`, until `signal` aborts or the outage outlasts the handoff's
1016
+ * `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
1017
+ * been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
868
1018
  */
869
1019
  async waitForResult(id, input = {}) {
870
1020
  requireNonEmpty(id, 'id');
871
- for (;;) {
872
- if (input.signal?.aborted) {
873
- throw input.signal.reason ?? new Error('aborted');
874
- }
875
- const handoff = await this.wait(id, input);
876
- if (isHandoffTerminal(handoff)) {
877
- return handoff;
878
- }
879
- // Yield between polls so timer-driven cancellation can run (see ApprovalsApi).
880
- await sleep(0, input.signal);
881
- }
1021
+ assertOnRetry(input.onRetry);
1022
+ return pollUntilSettled({
1023
+ poll: () => this.wait(id, input),
1024
+ validate: (value) => validateHandoffWait(value, id),
1025
+ settled: isHandoffTerminal,
1026
+ expiresAt: (handoff) => handoff.expires_at,
1027
+ what: `handoff ${id}`,
1028
+ minIntervalMs: MIN_HANDOFF_POLL_INTERVAL_MS,
1029
+ ...(input.signal ? { signal: input.signal } : {}),
1030
+ ...(input.onRetry ? { onRetry: input.onRetry } : {}),
1031
+ });
882
1032
  }
883
1033
  /** List the agent's open handoffs or bounded recent history. */
884
1034
  async list(input = {}) {
@@ -909,7 +1059,7 @@ class HandoffsApi {
909
1059
  });
910
1060
  return this.http.request('POST', '/api/agent/handoffs', {
911
1061
  body,
912
- ...(input.idempotencyKey ? { headers: { 'Idempotency-Key': input.idempotencyKey } } : {}),
1062
+ ...(input.idempotencyKey ? { headers: { 'Idempotency-Key': normalizeIdempotencyKey(input.idempotencyKey) } } : {}),
913
1063
  });
914
1064
  }
915
1065
  }
@@ -963,7 +1113,7 @@ class InboxApi {
963
1113
  }), questionId);
964
1114
  throwIfAborted(signal);
965
1115
  if (question.state === 'pending') {
966
- await sleep(Math.max(0, MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
1116
+ await sleep(Math.max(0, MIN_HANDOFF_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
967
1117
  continue;
968
1118
  }
969
1119
  if (question.state === 'answered' && question.activation_completed === true) {
@@ -1044,11 +1194,11 @@ class LiveApi {
1044
1194
  * Read back a stream this credential started, so a restarted producer can
1045
1195
  * reconcile instead of opening a duplicate.
1046
1196
  *
1047
- * Resolves null for a 404 ONLY — no stream with that correlation id in the
1048
- * last 24 hours. Every other failure throws: in particular a 403
1049
- * `room_not_granted` ({@link RoomScopedErrorCode}), raised when the room is
1050
- * outside the grant the human gave this agent, propagates rather than being
1051
- * reported as "no stream".
1197
+ * Resolves null ONLY for a 404 `stream_not_found` — no stream with that
1198
+ * correlation id in the last 24 hours. Every other failure throws: a 404 for
1199
+ * an unknown or deleted room code, and a 403 `room_not_granted`
1200
+ * ({@link RoomScopedErrorCode}) when the room is outside the grant the human
1201
+ * gave this agent, both propagate rather than being reported as "no stream".
1052
1202
  */
1053
1203
  async get(inviteCode, correlationId) {
1054
1204
  requireNonEmpty(inviteCode, 'inviteCode');
@@ -1057,7 +1207,7 @@ class LiveApi {
1057
1207
  return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
1058
1208
  }
1059
1209
  catch (error) {
1060
- if (error instanceof PingRoomError && error.status === 404)
1210
+ if (error instanceof PingRoomError && error.status === 404 && error.code === 'stream_not_found')
1061
1211
  return null;
1062
1212
  throw error;
1063
1213
  }
@@ -1212,12 +1362,26 @@ export class PingRoom {
1212
1362
  body: { code: normalizeRedeemCode(code) },
1213
1363
  });
1214
1364
  }
1215
- /** Broadcast a ping to every member of a room you own. */
1365
+ /**
1366
+ * Broadcast a ping to every member of a room you own. Pass `idempotencyKey`
1367
+ * and reuse it when retrying after a timeout or network error, so a send
1368
+ * the server already committed is replayed rather than delivered twice.
1369
+ */
1216
1370
  broadcast(inviteCode, ping) {
1217
1371
  assertPing(ping);
1372
+ const { body, headers } = splitIdempotencyKey(ping);
1218
1373
  return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/notifications`, {
1219
- body: dropUndefined({ ...ping }),
1374
+ body: dropUndefined({ ...body }),
1375
+ ...(headers ? { headers } : {}),
1220
1376
  });
1221
1377
  }
1378
+ /** What a logger or JSON.stringify() sees: the API origin, never the credential. */
1379
+ toJSON() {
1380
+ return this.http.toJSON();
1381
+ }
1382
+ [INSPECT]() {
1383
+ const { baseUrl, token } = this.http.toJSON();
1384
+ return `PingRoom { baseUrl: ${JSON.stringify(baseUrl)}, token: ${token === null ? 'null' : token} }`;
1385
+ }
1222
1386
  }
1223
1387
  //# sourceMappingURL=client.js.map