@pingroom/sdk 0.7.1 → 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.
Files changed (191) hide show
  1. package/README.md +288 -44
  2. package/dist/client.d.ts +27 -312
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +33 -1154
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +40 -6
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +56 -4
  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 +47 -18
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +3 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal/async.d.ts +2 -0
  19. package/dist/internal/async.d.ts.map +1 -1
  20. package/dist/internal/async.js +6 -0
  21. package/dist/internal/async.js.map +1 -1
  22. package/dist/internal/guards.d.ts +16 -0
  23. package/dist/internal/guards.d.ts.map +1 -1
  24. package/dist/internal/guards.js +22 -1
  25. package/dist/internal/guards.js.map +1 -1
  26. package/dist/internal/request.d.ts +41 -0
  27. package/dist/internal/request.d.ts.map +1 -0
  28. package/dist/internal/request.js +78 -0
  29. package/dist/internal/request.js.map +1 -0
  30. package/dist/internal/retry.d.ts +65 -0
  31. package/dist/internal/retry.d.ts.map +1 -0
  32. package/dist/internal/retry.js +126 -0
  33. package/dist/internal/retry.js.map +1 -0
  34. package/dist/internal/url.d.ts +9 -0
  35. package/dist/internal/url.d.ts.map +1 -1
  36. package/dist/internal/url.js +25 -0
  37. package/dist/internal/url.js.map +1 -1
  38. package/dist/internal/validate.d.ts +13 -0
  39. package/dist/internal/validate.d.ts.map +1 -0
  40. package/dist/internal/validate.js +36 -0
  41. package/dist/internal/validate.js.map +1 -0
  42. package/dist/linkPing.d.ts.map +1 -1
  43. package/dist/linkPing.js +6 -2
  44. package/dist/linkPing.js.map +1 -1
  45. package/dist/liveStatus.d.ts +106 -11
  46. package/dist/liveStatus.d.ts.map +1 -1
  47. package/dist/liveStatus.js +20 -3
  48. package/dist/liveStatus.js.map +1 -1
  49. package/dist/mcp.d.ts.map +1 -1
  50. package/dist/mcp.js +40 -10
  51. package/dist/mcp.js.map +1 -1
  52. package/dist/namespaces/actions.d.ts +50 -0
  53. package/dist/namespaces/actions.d.ts.map +1 -0
  54. package/dist/namespaces/actions.js +219 -0
  55. package/dist/namespaces/actions.js.map +1 -0
  56. package/dist/namespaces/approvals.d.ts +23 -0
  57. package/dist/namespaces/approvals.d.ts.map +1 -0
  58. package/dist/namespaces/approvals.js +78 -0
  59. package/dist/namespaces/approvals.js.map +1 -0
  60. package/dist/namespaces/attachments.d.ts +37 -0
  61. package/dist/namespaces/attachments.d.ts.map +1 -0
  62. package/dist/namespaces/attachments.js +91 -0
  63. package/dist/namespaces/attachments.js.map +1 -0
  64. package/dist/namespaces/auth.d.ts +58 -0
  65. package/dist/namespaces/auth.d.ts.map +1 -0
  66. package/dist/namespaces/auth.js +242 -0
  67. package/dist/namespaces/auth.js.map +1 -0
  68. package/dist/namespaces/directory.d.ts +10 -0
  69. package/dist/namespaces/directory.d.ts.map +1 -0
  70. package/dist/namespaces/directory.js +19 -0
  71. package/dist/namespaces/directory.js.map +1 -0
  72. package/dist/namespaces/handoffs.d.ts +47 -0
  73. package/dist/namespaces/handoffs.d.ts.map +1 -0
  74. package/dist/namespaces/handoffs.js +126 -0
  75. package/dist/namespaces/handoffs.js.map +1 -0
  76. package/dist/namespaces/inbox.d.ts +17 -0
  77. package/dist/namespaces/inbox.d.ts.map +1 -0
  78. package/dist/namespaces/inbox.js +192 -0
  79. package/dist/namespaces/inbox.js.map +1 -0
  80. package/dist/namespaces/live.d.ts +57 -0
  81. package/dist/namespaces/live.d.ts.map +1 -0
  82. package/dist/namespaces/live.js +116 -0
  83. package/dist/namespaces/live.js.map +1 -0
  84. package/dist/namespaces/notifications.d.ts +25 -0
  85. package/dist/namespaces/notifications.d.ts.map +1 -0
  86. package/dist/namespaces/notifications.js +128 -0
  87. package/dist/namespaces/notifications.js.map +1 -0
  88. package/dist/namespaces/profile.d.ts +19 -0
  89. package/dist/namespaces/profile.d.ts.map +1 -0
  90. package/dist/namespaces/profile.js +22 -0
  91. package/dist/namespaces/profile.js.map +1 -0
  92. package/dist/namespaces/questions.d.ts +41 -0
  93. package/dist/namespaces/questions.d.ts.map +1 -0
  94. package/dist/namespaces/questions.js +107 -0
  95. package/dist/namespaces/questions.js.map +1 -0
  96. package/dist/namespaces/rooms.d.ts +13 -0
  97. package/dist/namespaces/rooms.d.ts.map +1 -0
  98. package/dist/namespaces/rooms.js +41 -0
  99. package/dist/namespaces/rooms.js.map +1 -0
  100. package/dist/namespaces/webhooks.d.ts +11 -0
  101. package/dist/namespaces/webhooks.d.ts.map +1 -0
  102. package/dist/namespaces/webhooks.js +32 -0
  103. package/dist/namespaces/webhooks.js.map +1 -0
  104. package/dist/types/approvals.d.ts +62 -0
  105. package/dist/types/approvals.d.ts.map +1 -0
  106. package/dist/types/approvals.js +2 -0
  107. package/dist/types/approvals.js.map +1 -0
  108. package/dist/types/auth.d.ts +185 -0
  109. package/dist/types/auth.d.ts.map +1 -0
  110. package/dist/types/auth.js +2 -0
  111. package/dist/types/auth.js.map +1 -0
  112. package/dist/types/common.d.ts +43 -0
  113. package/dist/types/common.d.ts.map +1 -0
  114. package/dist/types/common.js +2 -0
  115. package/dist/types/common.js.map +1 -0
  116. package/dist/types/handoffs.d.ts +121 -0
  117. package/dist/types/handoffs.d.ts.map +1 -0
  118. package/dist/types/handoffs.js +2 -0
  119. package/dist/types/handoffs.js.map +1 -0
  120. package/dist/types/inbox.d.ts +51 -0
  121. package/dist/types/inbox.d.ts.map +1 -0
  122. package/dist/types/inbox.js +2 -0
  123. package/dist/types/inbox.js.map +1 -0
  124. package/dist/types/notifications.d.ts +376 -0
  125. package/dist/types/notifications.d.ts.map +1 -0
  126. package/dist/types/notifications.js +2 -0
  127. package/dist/types/notifications.js.map +1 -0
  128. package/dist/types/profile.d.ts +30 -0
  129. package/dist/types/profile.d.ts.map +1 -0
  130. package/dist/types/profile.js +3 -0
  131. package/dist/types/profile.js.map +1 -0
  132. package/dist/types/questions.d.ts +210 -0
  133. package/dist/types/questions.d.ts.map +1 -0
  134. package/dist/types/questions.js +2 -0
  135. package/dist/types/questions.js.map +1 -0
  136. package/dist/types/rooms.d.ts +163 -0
  137. package/dist/types/rooms.d.ts.map +1 -0
  138. package/dist/types/rooms.js +3 -0
  139. package/dist/types/rooms.js.map +1 -0
  140. package/dist/types.d.ts +9 -1035
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/dist/webhooks.d.ts +32 -5
  145. package/dist/webhooks.d.ts.map +1 -1
  146. package/dist/webhooks.js +44 -14
  147. package/dist/webhooks.js.map +1 -1
  148. package/package.json +4 -2
  149. package/src/client.ts +115 -0
  150. package/src/errors.ts +291 -0
  151. package/src/http.ts +225 -0
  152. package/src/index.ts +97 -0
  153. package/src/internal/async.ts +54 -0
  154. package/src/internal/guards.ts +129 -0
  155. package/src/internal/request.ts +97 -0
  156. package/src/internal/retry.ts +157 -0
  157. package/src/internal/url.ts +78 -0
  158. package/src/internal/validate.ts +42 -0
  159. package/src/linkPing.ts +56 -0
  160. package/src/liveStatus.ts +337 -0
  161. package/src/locationPing.ts +101 -0
  162. package/src/mcp.ts +161 -0
  163. package/src/namespaces/actions.ts +234 -0
  164. package/src/namespaces/approvals.ts +104 -0
  165. package/src/namespaces/attachments.ts +106 -0
  166. package/src/namespaces/auth.ts +284 -0
  167. package/src/namespaces/directory.ts +21 -0
  168. package/src/namespaces/handoffs.ts +160 -0
  169. package/src/namespaces/inbox.ts +253 -0
  170. package/src/namespaces/live.ts +134 -0
  171. package/src/namespaces/notifications.ts +152 -0
  172. package/src/namespaces/profile.ts +24 -0
  173. package/src/namespaces/questions.ts +133 -0
  174. package/src/namespaces/rooms.ts +53 -0
  175. package/src/namespaces/webhooks.ts +38 -0
  176. package/src/types/approvals.ts +67 -0
  177. package/src/types/auth.ts +202 -0
  178. package/src/types/common.ts +74 -0
  179. package/src/types/handoffs.ts +148 -0
  180. package/src/types/inbox.ts +64 -0
  181. package/src/types/notifications.ts +393 -0
  182. package/src/types/profile.ts +37 -0
  183. package/src/types/questions.ts +226 -0
  184. package/src/types/rooms.ts +169 -0
  185. package/src/types.ts +17 -0
  186. package/src/version.ts +2 -0
  187. package/src/webhooks.ts +373 -0
  188. package/dist/liveActivity.d.ts +0 -101
  189. package/dist/liveActivity.d.ts.map +0 -1
  190. package/dist/liveActivity.js +0 -83
  191. package/dist/liveActivity.js.map +0 -1
package/dist/client.js CHANGED
@@ -1,1160 +1,27 @@
1
- import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
2
1
  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';
5
- import { linkPing } from './linkPing.js';
6
- import { locationPing } from './locationPing.js';
2
+ import { normalizeRedeemCode } from './internal/guards.js';
7
3
  import { McpClient } from './mcp.js';
4
+ import { enc, splitIdempotencyKey, dropUndefined, assertPing } from './internal/request.js';
5
+ import { AuthApi } from './namespaces/auth.js';
6
+ import { RoomsApi } from './namespaces/rooms.js';
7
+ import { ActionsApi } from './namespaces/actions.js';
8
+ import { WebhooksApi } from './namespaces/webhooks.js';
9
+ import { NotificationsApi } from './namespaces/notifications.js';
10
+ import { AttachmentsApi } from './namespaces/attachments.js';
11
+ import { ApprovalsApi } from './namespaces/approvals.js';
12
+ import { QuestionsApi } from './namespaces/questions.js';
13
+ import { HandoffsApi } from './namespaces/handoffs.js';
14
+ import { InboxApi } from './namespaces/inbox.js';
15
+ import { ProfileApi } from './namespaces/profile.js';
16
+ import { LiveApi } from './namespaces/live.js';
17
+ import { DirectoryApi } from './namespaces/directory.js';
8
18
  const DEFAULT_BASE_URL = 'https://api.pingroom.io';
9
- /** Server cap per uploaded file; a larger body is a 413 the server answers before reading it. */
10
- const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
11
- const MAX_TRIGGER_ATTACHMENTS = 4;
12
- const QUICK_ACTION_PAGE_SIZE = 4;
13
- const MAX_QUICK_ACTION_PAGES = 4;
14
- /** Extra wall-clock allowance over the server's hold window for long-poll calls. */
15
- const LONG_POLL_BUFFER_MS = 10_000;
16
- const DEFAULT_WAIT_SECONDS = 20;
17
- const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
18
- const MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS = 2100;
19
+ /** Node's util.inspect hook, named through the global registry so no import is needed. */
20
+ const INSPECT = Symbol.for('nodejs.util.inspect.custom');
19
21
  function envBaseUrl() {
20
22
  const proc = globalThis.process;
21
23
  return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
22
24
  }
23
- function enc(segment) {
24
- return encodeURIComponent(segment);
25
- }
26
- function dropUndefined(obj) {
27
- const out = {};
28
- for (const [k, v] of Object.entries(obj)) {
29
- if (v !== undefined) {
30
- out[k] = v;
31
- }
32
- }
33
- return out;
34
- }
35
- /**
36
- * Mirrors the server's `icon` rule (`present`, `required_with:label`): the emoji
37
- * is the half that must be there unless BOTH are empty, which reserves the
38
- * slot as disabled.
39
- */
40
- function assertQuickActionIcon(action) {
41
- requirePresentString(action.label, 'label');
42
- requirePresentString(action.icon, 'icon');
43
- if (action.icon.trim() === '' && action.label.trim() !== '') {
44
- throw new PingRoomError('`icon` is required when `label` is set; send both empty to disable the slot.', {
45
- code: 'invalid_request',
46
- });
47
- }
48
- }
49
- function assertTriggerInput(input) {
50
- const data = input.data;
51
- if (data !== undefined) {
52
- if (data === null || typeof data !== 'object' || Array.isArray(data)) {
53
- throw new PingRoomError('`data` must be an object with `location` and/or `url`.', { code: 'invalid_request' });
54
- }
55
- for (const key of Object.keys(data)) {
56
- if (key !== 'location' && key !== 'url') {
57
- throw new PingRoomError(`\`data.${key}\` is not accepted on a quick-action press; only location and url are.`, {
58
- code: 'invalid_request',
59
- });
60
- }
61
- }
62
- try {
63
- if (data.location !== undefined)
64
- locationPing(data.location);
65
- if (data.url !== undefined)
66
- linkPing({ url: data.url });
67
- }
68
- catch (error) {
69
- throw new PingRoomError(error.message, { code: 'invalid_request' });
70
- }
71
- }
72
- if (input.attachment_ids !== undefined) {
73
- if (!Array.isArray(input.attachment_ids) || input.attachment_ids.length > MAX_TRIGGER_ATTACHMENTS) {
74
- throw new PingRoomError(`\`attachment_ids\` accepts at most ${MAX_TRIGGER_ATTACHMENTS} ids.`, { code: 'invalid_request' });
75
- }
76
- for (const id of input.attachment_ids)
77
- requireNonEmpty(id, 'attachment_ids[]');
78
- }
79
- }
80
- function attachmentByteLength(content) {
81
- if (content instanceof Blob)
82
- return content.size;
83
- if (typeof content === 'string')
84
- return new TextEncoder().encode(content).byteLength;
85
- return content.byteLength;
86
- }
87
- function assertPing(ping) {
88
- requireNonEmpty(ping.message, 'message');
89
- // The invite code does not expose room visibility. Permit the public-room
90
- // ceiling locally; Laravel applies 120 for private and 160 for public rooms.
91
- assertMaxLength(ping.message, MAX_PUBLIC_PING_MESSAGE_LENGTH, 'message');
92
- assertMaxLength(ping.title, MAX_PING_TITLE_LENGTH, 'title');
93
- assertActionNumber(ping.action_number);
94
- assertStructuredData(ping.data);
95
- }
96
- function waitTimeoutMs(seconds) {
97
- return (seconds ?? DEFAULT_WAIT_SECONDS) * 1000 + LONG_POLL_BUFFER_MS;
98
- }
99
- function isRecord(value) {
100
- return value !== null && typeof value === 'object' && !Array.isArray(value);
101
- }
102
- function isNonEmptyString(value) {
103
- return typeof value === 'string' && value.trim() !== '';
104
- }
105
- function isNullableString(value) {
106
- return value === null || typeof value === 'string';
107
- }
108
- /** Drop a malformed or unsafe server-provided URL without rejecting its response. */
109
- function normalizePublicHttpUrl(candidate) {
110
- if (typeof candidate !== 'string' || candidate.trim() === '')
111
- return undefined;
112
- const trimmed = candidate.trim();
113
- // eslint-disable-next-line no-control-regex
114
- if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed))
115
- return undefined;
116
- try {
117
- const url = new URL(trimmed);
118
- if (url.protocol !== 'https:' && url.protocol !== 'http:')
119
- return undefined;
120
- if (url.username !== '' || url.password !== '')
121
- return undefined;
122
- }
123
- catch {
124
- return undefined;
125
- }
126
- return trimmed;
127
- }
128
- /** Drop malformed or unsafe server-provided links without rejecting a valid credential. */
129
- function normalizePairingLinks(value) {
130
- if (!isRecord(value))
131
- return undefined;
132
- const latestPings = normalizePublicHttpUrl(value['latest_pings']);
133
- if (!latestPings)
134
- return undefined;
135
- const installApp = normalizePublicHttpUrl(value['install_app']);
136
- return {
137
- latest_pings: latestPings,
138
- ...(installApp ? { install_app: installApp } : {}),
139
- };
140
- }
141
- function invalidInboxResponse(message, body = {}) {
142
- throw new PingRoomError(message, { code: 'inbox_invalid_response', body });
143
- }
144
- function validateInboxEnsure(value) {
145
- if (!isRecord(value) || value['onboarded'] !== true || typeof value['replayed'] !== 'boolean') {
146
- return invalidInboxResponse('PingRoom returned an invalid Agent Inbox ensure response.');
147
- }
148
- const room = value['room'];
149
- const question = value['question'];
150
- if (!isRecord(room)
151
- || !isNonEmptyString(room['id'])
152
- || typeof room['name'] !== 'string'
153
- || !isNonEmptyString(room['invite_code'])
154
- || typeof room['is_agent_inbox'] !== 'boolean'
155
- || !isRecord(question)
156
- || !isNonEmptyString(question['id'])
157
- || question['kind'] !== 'question'
158
- || !isNonEmptyString(question['prompt'])
159
- || !Array.isArray(question['options'])
160
- || question['options'].some((option) => (!isRecord(option)
161
- || !isNonEmptyString(option['value'])
162
- || !isNonEmptyString(option['label'])))
163
- || (question['state'] !== 'pending'
164
- && question['state'] !== 'answered'
165
- && question['state'] !== 'expired'
166
- && question['state'] !== 'cancelled')
167
- || !isNullableString(question['expires_at'])
168
- || !isNullableString(question['created_at'])) {
169
- return invalidInboxResponse('PingRoom returned an incomplete Agent Inbox ensure response.');
170
- }
171
- return value;
172
- }
173
- function validateInboxWait(value, expectedId) {
174
- if (!isRecord(value)) {
175
- return invalidInboxResponse('PingRoom returned an invalid Agent Inbox wait response.');
176
- }
177
- const state = value['state'];
178
- if (!isNonEmptyString(value['id'])
179
- || value['id'] !== expectedId
180
- || value['kind'] !== 'question'
181
- || (state !== 'pending' && state !== 'answered' && state !== 'expired' && state !== 'cancelled')
182
- || (value['activation_completed'] !== undefined && typeof value['activation_completed'] !== 'boolean')
183
- || (state !== 'answered' && value['activation_completed'] === true)) {
184
- return invalidInboxResponse('PingRoom returned a mismatched Agent Inbox wait response.', {
185
- expected_id: expectedId,
186
- received_id: typeof value['id'] === 'string' ? value['id'] : null,
187
- kind: typeof value['kind'] === 'string' ? value['kind'] : null,
188
- state: typeof state === 'string' ? state : null,
189
- });
190
- }
191
- if (state === 'answered') {
192
- const answer = value['answer'];
193
- if (!isRecord(answer)
194
- || !isNullableString(answer['value'])
195
- || !isNullableString(answer['label'])
196
- || !isNullableString(answer['text'])
197
- || (!isNonEmptyString(answer['value']) && !isNonEmptyString(answer['text']))
198
- || !isNullableString(answer['answered_at'])
199
- || (answer['responder'] !== null && !isRecord(answer['responder']))) {
200
- return invalidInboxResponse('PingRoom returned an answered activation without a valid answer.', {
201
- id: expectedId,
202
- state,
203
- });
204
- }
205
- const responder = answer['responder'];
206
- if (isRecord(responder)
207
- && (!isNullableString(responder['id']) || !isNullableString(responder['display_name']))) {
208
- return invalidInboxResponse('PingRoom returned an invalid activation responder.', {
209
- id: expectedId,
210
- state,
211
- });
212
- }
213
- }
214
- else if (value['answer'] !== undefined && value['answer'] !== null) {
215
- return invalidInboxResponse('PingRoom returned an answer for an unresolved activation.', {
216
- id: expectedId,
217
- state,
218
- });
219
- }
220
- return value;
221
- }
222
- function throwIfAborted(signal) {
223
- if (signal.aborted) {
224
- throw signal.reason ?? new Error('aborted');
225
- }
226
- }
227
- // --- namespaces -----------------------------------------------------------
228
- /** Agent credential lifecycle (auth.md flow). */
229
- class AuthApi {
230
- http;
231
- constructor(http) {
232
- this.http = http;
233
- }
234
- /** Register an agent. Anonymous or via an identity assertion (ID-JAG / verified email). Public. */
235
- register(params) {
236
- requireNonEmpty(params.type, 'type');
237
- return this.http.request('POST', '/api/agent/auth', { auth: false, body: dropUndefined({ ...params }) });
238
- }
239
- /** Begin claiming a pre-claim credential onto a human account; emails an OTP. */
240
- claimStart(params) {
241
- requireNonEmpty(params.email, 'email');
242
- return this.http.request('POST', '/api/agent/auth/claim/start', { body: { email: params.email } });
243
- }
244
- /** Complete the claim with the emailed OTP; returns an active credential + handle. */
245
- claimComplete(params) {
246
- requireNonEmpty(params.email, 'email');
247
- requireNonEmpty(params.otp, 'otp');
248
- return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
249
- }
250
- /**
251
- * Register a pending agent and create a short-lived link that opens the
252
- * PingRoom app. The server owns the full grant shown to the approver; this
253
- * client sends no scope list. The pending credential stays private inside the
254
- * client and is replaced automatically once waitForPairing() succeeds.
255
- */
256
- async startPairing(params = {}) {
257
- const previousToken = this.http.getToken();
258
- const pending = await this.register({
259
- type: 'anonymous',
260
- ...(params.agent_label ? { agent_label: params.agent_label } : {}),
261
- });
262
- this.http.setToken(pending.credential);
263
- try {
264
- return await this.http.request('POST', '/api/agent/auth/pair/start', {
265
- body: {},
266
- });
267
- }
268
- catch (error) {
269
- if (this.http.getToken() === pending.credential) {
270
- this.http.setToken(previousToken);
271
- }
272
- throw error;
273
- }
274
- }
275
- /** Read the current state of an app pairing using the pending credential. */
276
- pairingStatus(options = {}) {
277
- return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
278
- }
279
- /**
280
- * Wait until the person approves in PingRoom, or the short-lived link
281
- * expires. Transient network, 429, and 5xx failures are retried inside the
282
- * server-provided lifetime. On success the client immediately starts using
283
- * the active credential returned by the server.
284
- */
285
- async waitForPairing(pairing, options = {}) {
286
- const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
287
- const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
288
- const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
289
- const startedAt = Date.now();
290
- const deadline = startedAt + lifetimeMs;
291
- let transientFailures = 0;
292
- while (Date.now() < deadline) {
293
- this.throwIfPairingAborted(options.signal);
294
- try {
295
- const status = await this.pairingStatus({ signal: options.signal });
296
- this.throwIfPairingAborted(options.signal);
297
- transientFailures = 0;
298
- if (status.status === 'active') {
299
- if (typeof status.credential !== 'string'
300
- || status.credential.trim() === ''
301
- || status.credential_type !== 'active'
302
- || (status.expires_in !== null
303
- && (typeof status.expires_in !== 'number'
304
- || !Number.isFinite(status.expires_in)
305
- || status.expires_in < 0))
306
- // A null/absent `room` is valid when the account has no eligible
307
- // private delivery room (and on older servers). Only a room that is
308
- // present but malformed is a broken response.
309
- || (status.room != null
310
- && (typeof status.room.invite_code !== 'string'
311
- || status.room.invite_code.trim() === ''))) {
312
- throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
313
- code: 'pairing_invalid_response',
314
- body: { status: status.status },
315
- });
316
- }
317
- this.http.setToken(status.credential);
318
- // Pairing is server-authoritative. Some servers still include their
319
- // internal grant marker for protocol compatibility; it is neither a
320
- // client input nor part of the paired credential surface.
321
- const { status: _status, scopes: _serverScopes, links: rawLinks, ...credential } = status;
322
- const links = normalizePairingLinks(rawLinks);
323
- return links ? { ...credential, links } : credential;
324
- }
325
- if (status.status === 'expired') {
326
- throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
327
- code: 'pairing_expired',
328
- });
329
- }
330
- }
331
- catch (error) {
332
- if (error instanceof PingRoomError
333
- && [401, 403, 404].includes(error.status)
334
- && Date.now() - startedAt >= lifetimeMs / 2) {
335
- // The pending bearer can expire before the server returns an
336
- // explicit expired pairing state. Keep early auth failures intact.
337
- throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
338
- code: 'pairing_expired',
339
- });
340
- }
341
- if (error instanceof PingRoomError
342
- && (error.code === 'network_error'
343
- || error.code === 'timeout'
344
- || error.status === 429
345
- || error.status >= 500)) {
346
- transientFailures += 1;
347
- const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
348
- await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
349
- continue;
350
- }
351
- throw error;
352
- }
353
- await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
354
- }
355
- throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
356
- code: 'pairing_expired',
357
- });
358
- }
359
- throwIfPairingAborted(signal) {
360
- if (signal?.aborted) {
361
- throw signal.reason ?? new Error('aborted');
362
- }
363
- }
364
- /** Rotate to a fresh credential (same scopes/handle). */
365
- refresh() {
366
- return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
367
- }
368
- /** Revoke the current credential. */
369
- revoke() {
370
- return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
371
- }
372
- }
373
- class RoomsApi {
374
- http;
375
- constructor(http) {
376
- this.http = http;
377
- }
378
- icons() {
379
- return this.http.request('GET', '/api/agent/room-icons');
380
- }
381
- list() {
382
- return this.http.request('GET', '/api/agent/rooms');
383
- }
384
- get(inviteCode) {
385
- return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}`);
386
- }
387
- create(input) {
388
- requireNonEmpty(input.name, 'name');
389
- return this.http.request('POST', '/api/agent/rooms', { body: dropUndefined({ ...input }) });
390
- }
391
- createPublic(input) {
392
- requireNonEmpty(input.name, 'name');
393
- requireNonEmpty(input.handle, 'handle');
394
- // The server takes the location trio all-or-nothing; a partial set is a
395
- // 422 on a field the caller may not have meant to send at all.
396
- const present = [input.location_name, input.location_latitude, input.location_longitude].filter((v) => v !== undefined).length;
397
- if (present !== 0 && present !== 3) {
398
- throw new PingRoomError('Send `location_name`, `location_latitude` and `location_longitude` together, or none of them.', {
399
- code: 'invalid_request',
400
- });
401
- }
402
- return this.http.request('POST', '/api/agent/rooms/public', { body: dropUndefined({ ...input }) });
403
- }
404
- join(input) {
405
- requireNonEmpty(input.invite_code, 'invite_code');
406
- return this.http.request('POST', '/api/agent/rooms/join', { body: dropUndefined({ ...input }) });
407
- }
408
- }
409
- class ActionsApi {
410
- http;
411
- constructor(http) {
412
- this.http = http;
413
- }
414
- list(inviteCode) {
415
- return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/actions`);
416
- }
417
- update(inviteCode, actionNumber, input) {
418
- assertActionNumber(actionNumber);
419
- // A Ping's title is optional — its emoji can be the whole name — so an
420
- // empty `label` is a valid update. `icon` is the half that must be there,
421
- // unless both are empty: that reserves the slot as disabled.
422
- assertQuickActionIcon(input);
423
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}`, {
424
- body: dropUndefined({ ...input }),
425
- });
426
- }
427
- /**
428
- * Write up to 16 action slots in one request. The server enforces the room owner's plan.
429
- * Add new pages in complete groups of four; existing slots may be edited individually.
430
- *
431
- * Prefer this over looping `update()`. Each single-slot write enqueues its
432
- * own silent rooms-refresh push, so configuring a room's four Pings one at a
433
- * time wakes room members' devices four times inside a few seconds — four of a
434
- * finite daily background-push budget spent on one logical operation.
435
- *
436
- * Partial and additive: slots absent from `actions` keep their current
437
- * configuration, so this is also the right call for editing a single Ping.
438
- * Nothing here deletes an action, and an empty array is rejected locally
439
- * rather than being sent as a clear. Resolves to the room's full ordered
440
- * action set, not just the slots written.
441
- */
442
- updateMany(inviteCode, actions) {
443
- if (!Array.isArray(actions) || actions.length === 0) {
444
- throw new PingRoomError('`actions` must contain at least one action.', { code: 'invalid_request' });
445
- }
446
- if (actions.length > 16) {
447
- throw new PingRoomError('A batch may contain at most 16 action slots.', { code: 'invalid_request' });
448
- }
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
- }
463
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions`, {
464
- body: { actions: actions.map((action) => dropUndefined({ ...action })) },
465
- });
466
- }
467
- /**
468
- * Press a slot. When the action has an `input_type`, pass the matching
469
- * detail (`data.location`, `data.url`, or `attachment_ids`) or the server
470
- * refuses the press with `422 quick_action_input_required` and sends nothing.
471
- * Read `input_type` from `list()` first; pass the action's `id` as
472
- * `quick_action_id` to be refused rather than fire the wrong Ping if the
473
- * owner moved pages meanwhile.
474
- */
475
- trigger(inviteCode, actionNumber, input = {}) {
476
- assertActionNumber(actionNumber);
477
- assertTriggerInput(input);
478
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}/trigger`, {
479
- body: dropUndefined({ ...input }),
480
- });
481
- }
482
- /**
483
- * Replace the room's page layout atomically (room owner on Pro only). See
484
- * `UpdateQuickActionLayoutInput` for the snapshot contract; a stale
485
- * `base_action_ids` is `409 quick_action_layout_changed`, a page still used
486
- * by a time trigger, webhook, agent binding or running live update is
487
- * `409 quick_action_page_in_use`. Prefer `deletePage()`, which builds the
488
- * body from a fresh read.
489
- */
490
- updateLayout(inviteCode, input) {
491
- if (!Array.isArray(input.base_action_ids)) {
492
- throw new PingRoomError('`base_action_ids` must be an array of action ids (may be empty).', { code: 'invalid_request' });
493
- }
494
- if (!Array.isArray(input.page_order) || input.page_order.length < 1 || input.page_order.length > MAX_QUICK_ACTION_PAGES) {
495
- throw new PingRoomError(`\`page_order\` must list 1–${MAX_QUICK_ACTION_PAGES} pages.`, { code: 'invalid_request' });
496
- }
497
- for (const page of input.page_order) {
498
- if (page !== null && (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES)) {
499
- throw new PingRoomError('`page_order` entries must be an original page number 1–4 or null for a blank page.', {
500
- code: 'invalid_request',
501
- });
502
- }
503
- }
504
- const expected = input.page_order.length * QUICK_ACTION_PAGE_SIZE;
505
- if (!Array.isArray(input.actions) || input.actions.length !== expected) {
506
- throw new PingRoomError(`\`actions\` must hold exactly ${expected} slots for ${input.page_order.length} page(s).`, {
507
- code: 'invalid_request',
508
- });
509
- }
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
- }
519
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/layout`, {
520
- body: {
521
- base_action_ids: input.base_action_ids,
522
- page_order: input.page_order,
523
- actions: input.actions.map((action) => dropUndefined({ ...action })),
524
- },
525
- });
526
- }
527
- /**
528
- * Delete one page (1–4) and shift the later pages left, keeping every other
529
- * slot's configuration. Reads the current action set first so the layout
530
- * snapshot is real, then calls `updateLayout()`. Page 1 can be deleted only
531
- * when another page remains.
532
- */
533
- async deletePage(inviteCode, page) {
534
- if (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES) {
535
- throw new PingRoomError('`page` must be an integer 1–4.', { code: 'invalid_request' });
536
- }
537
- const current = await this.list(inviteCode);
538
- const stored = [...current].sort((a, b) => a.action_number - b.action_number);
539
- const pageCount = Math.max(1, Math.ceil((stored.at(-1)?.action_number ?? 0) / QUICK_ACTION_PAGE_SIZE));
540
- if (page > pageCount) {
541
- throw new PingRoomError(`The room has ${pageCount} page(s); page ${page} does not exist.`, { code: 'invalid_request' });
542
- }
543
- if (pageCount === 1) {
544
- throw new PingRoomError('A room keeps at least one page; configure its slots instead of deleting it.', {
545
- code: 'invalid_request',
546
- });
547
- }
548
- const keep = Array.from({ length: pageCount }, (_, i) => i + 1).filter((p) => p !== page);
549
- const byNumber = new Map(stored.map((a) => [a.action_number, a]));
550
- const actions = [];
551
- keep.forEach((originalPage, index) => {
552
- for (let offset = 1; offset <= QUICK_ACTION_PAGE_SIZE; offset++) {
553
- const source = byNumber.get((originalPage - 1) * QUICK_ACTION_PAGE_SIZE + offset);
554
- actions.push({
555
- action_number: index * QUICK_ACTION_PAGE_SIZE + offset,
556
- label: source?.label ?? '',
557
- icon: source?.icon ?? '',
558
- ...(source?.sound !== undefined ? { sound: source.sound } : {}),
559
- ...(source?.haptic_style !== undefined ? { haptic_style: source.haptic_style } : {}),
560
- ...(source?.requires_ack !== undefined ? { requires_ack: source.requires_ack } : {}),
561
- ...(source?.input_type !== undefined ? { input_type: source.input_type } : {}),
562
- });
563
- }
564
- });
565
- return this.updateLayout(inviteCode, {
566
- base_action_ids: stored.map((a) => a.id).filter((id) => typeof id === 'string'),
567
- page_order: keep,
568
- actions,
569
- });
570
- }
571
- }
572
- class WebhooksApi {
573
- http;
574
- constructor(http) {
575
- this.http = http;
576
- }
577
- async list(inviteCode) {
578
- const result = await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/webhooks`);
579
- return result.webhooks;
580
- }
581
- create(inviteCode, input) {
582
- requireNonEmpty(input.name, 'name');
583
- assertActionNumber(input.action_number);
584
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/webhooks`, {
585
- body: dropUndefined({ ...input }),
586
- });
587
- }
588
- update(inviteCode, webhookId, input) {
589
- requireNonEmpty(webhookId, 'webhookId');
590
- assertActionNumber(input.action_number);
591
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/webhooks/${enc(webhookId)}`, {
592
- body: dropUndefined({ ...input }),
593
- });
594
- }
595
- async delete(inviteCode, webhookId) {
596
- requireNonEmpty(webhookId, 'webhookId');
597
- await this.http.request('DELETE', `/api/agent/rooms/${enc(inviteCode)}/webhooks/${enc(webhookId)}`);
598
- }
599
- }
600
- class NotificationsApi {
601
- http;
602
- constructor(http) {
603
- this.http = http;
604
- }
605
- /** Paginated list (polling fallback). Prefer wait()/listen() for real-time. */
606
- async list(input = {}) {
607
- const { notifications_per_page, ...query } = input;
608
- const page = await this.http.request('GET', '/api/agent/notifications', {
609
- query: dropUndefined({ ...query, limit: input.limit ?? notifications_per_page }),
610
- });
611
- return {
612
- ...page,
613
- data: page.data.map(({ room, ...notification }) => ({
614
- ...notification,
615
- ...(room ? { room: { ...room, code: room.invite_code } } : {}),
616
- })),
617
- };
618
- }
619
- /** Fetch one visible notification, including its current acknowledgement state. */
620
- getNotification(notificationId) {
621
- requireNonEmpty(notificationId, 'notificationId');
622
- return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}`);
623
- }
624
- /**
625
- * One bounded long-poll for an ack-required ping. The returned state may
626
- * still be `open` when the hold timeout elapses; call again to keep waiting.
627
- */
628
- waitForAcknowledgement(notificationId, input = {}) {
629
- requireNonEmpty(notificationId, 'notificationId');
630
- return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}/ack/wait`, {
631
- query: dropUndefined({ timeout: input.timeout }),
632
- timeoutMs: waitTimeoutMs(input.timeout),
633
- ...(input.signal ? { signal: input.signal } : {}),
634
- });
635
- }
636
- /** One long-poll: resolves as soon as new pings land, or with an empty list at timeout. */
637
- wait(input = {}) {
638
- return this.http.request('GET', '/api/agent/notifications/wait', {
639
- query: dropUndefined({ after: input.after, timeout: input.timeout, limit: input.limit }),
640
- timeoutMs: waitTimeoutMs(input.timeout),
641
- ...(input.signal ? { signal: input.signal } : {}),
642
- });
643
- }
644
- /**
645
- * Continuous real-time inbound: an async iterator that long-polls in a loop,
646
- * advancing the cursor, yielding each new ping. Stops when `signal` aborts.
647
- *
648
- * for await (const ping of pr.notifications.listen({ signal })) { ... }
649
- */
650
- async *listen(input = {}) {
651
- 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;
660
- while (!input.signal?.aborted) {
661
- let result;
662
- 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
- });
669
- }
670
- catch (err) {
671
- if (input.signal?.aborted) {
672
- return;
673
- }
674
- if (input.onError) {
675
- input.onError(err);
676
- await sleep(backoff, input.signal);
677
- continue;
678
- }
679
- throw err;
680
- }
681
- cursor = result.cursor ?? cursor;
682
- for (const n of result.notifications) {
683
- yield n;
684
- }
685
- // Yield between polls so timer-driven cancellation can run (see ApprovalsApi).
686
- await sleep(0, input.signal);
687
- }
688
- }
689
- }
690
- class AgentsApi {
691
- http;
692
- constructor(http) {
693
- this.http = http;
694
- }
695
- /**
696
- * @deprecated Retired server-side — always fails with 410
697
- * `cross_account_ping_retired`. An agent can only reach the account that
698
- * connected it; to reach anyone else, post into a room you both belong to.
699
- */
700
- ping(handle, ping) {
701
- requireNonEmpty(handle, 'handle');
702
- assertPing(ping);
703
- return this.http.request('POST', `/api/agent/agents/${enc(handle)}/ping`, { body: dropUndefined({ ...ping }) });
704
- }
705
- }
706
- class ApprovalsApi {
707
- http;
708
- constructor(http) {
709
- this.http = http;
710
- }
711
- /** Ask the human you act for to decide; returns the pending approval. */
712
- request(inviteCode, input) {
713
- requireNonEmpty(input.question, 'question');
714
- assertStructuredData(input.data);
715
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/approvals`, {
716
- body: dropUndefined({ ...input }),
717
- });
718
- }
719
- get(approvalId) {
720
- return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}`);
721
- }
722
- /** One long-poll for the decision; returns the (possibly still-pending) approval. */
723
- wait(approvalId, input = {}) {
724
- return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}/wait`, {
725
- query: dropUndefined({ timeout: input.timeout }),
726
- timeoutMs: waitTimeoutMs(input.timeout),
727
- ...(input.signal ? { signal: input.signal } : {}),
728
- });
729
- }
730
- /** Block until the human decides (or the request expires), looping wait() under the hood. */
731
- 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
- }
744
- }
745
- }
746
- /**
747
- * The general human-in-the-loop primitive: ask a human a question with 2–4
748
- * options (or a short typed answer) and block until they tap an answer. An
749
- * {@link ApprovalsApi | approval} is the two-option case; reach for questions
750
- * when you need more choices, a typed reply, or room-scope answering.
751
- */
752
- class QuestionsApi {
753
- http;
754
- constructor(http) {
755
- this.http = http;
756
- }
757
- /** Ask a Question in a room; returns the pending Question (id + computed expiry). */
758
- ask(inviteCode, input) {
759
- requireNonEmpty(input.prompt, 'prompt');
760
- assertStructuredData(input.data);
761
- const { idempotencyKey, ...body } = input;
762
- if (idempotencyKey !== undefined) {
763
- requireNonEmpty(idempotencyKey, 'idempotencyKey');
764
- assertMaxLength(idempotencyKey, 255, 'idempotencyKey');
765
- }
766
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/questions`, {
767
- body: dropUndefined(body),
768
- ...(idempotencyKey !== undefined ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
769
- });
770
- }
771
- /** Fetch a Question's current state. */
772
- get(questionId) {
773
- return this.http.request('GET', `/api/agent/questions/${enc(questionId)}`);
774
- }
775
- /** One long-poll for the answer; returns the (possibly still-pending) Question. */
776
- wait(questionId, input = {}) {
777
- return this.http.request('GET', `/api/agent/questions/${enc(questionId)}/wait`, {
778
- query: dropUndefined({ timeout: input.timeout }),
779
- timeoutMs: waitTimeoutMs(input.timeout),
780
- ...(input.signal ? { signal: input.signal } : {}),
781
- });
782
- }
783
- /**
784
- * Block until the human answers (or the question expires/cancels), looping
785
- * wait() under the hood. Returns the resolved Question — inspect `state` and
786
- * `answer.value` to branch.
787
- */
788
- 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
- }
800
- }
801
- /** List the asker's Questions, optionally filtered by state. */
802
- async list(input = {}) {
803
- const res = await this.http.request('GET', '/api/agent/questions', {
804
- query: dropUndefined({ state: input.state }),
805
- });
806
- return res.questions ?? [];
807
- }
808
- /** Withdraw a still-pending Question (409 if it already resolved). */
809
- cancel(questionId) {
810
- return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
811
- }
812
- }
813
- /** A bare string option is shorthand for `{ value, label: value }`. */
814
- function normalizeHandoffOptions(options) {
815
- return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
816
- }
817
- /** Terminal states never change again — the wait loop can stop as soon as one lands. */
818
- function isHandoffTerminal(handoff) {
819
- const s = handoff.state;
820
- return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
821
- }
822
- /**
823
- * The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
824
- * human) and PRIVATE (no other room member can read it). Backed by the ack and
825
- * question primitives, so its {@link Handoff.state | state} space is their union
826
- * — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
827
- * question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
828
- *
829
- * const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
830
- * const result = await pr.handoffs.waitForResult(task.id);
831
- *
832
- * const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
833
- */
834
- class HandoffsApi {
835
- http;
836
- constructor(http) {
837
- this.http = http;
838
- }
839
- /** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
840
- requestAck(input) {
841
- requireNonEmpty(input.prompt, 'prompt');
842
- assertStructuredData(input.data);
843
- return this.create('ack', input);
844
- }
845
- /**
846
- * Ask a human a multi-option question (a `question` handoff). Resolves on the
847
- * first tapped answer. Bare-string options normalize to `{ value, label }`.
848
- */
849
- ask(input) {
850
- requireNonEmpty(input.prompt, 'prompt');
851
- assertStructuredData(input.data);
852
- if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
853
- throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
854
- code: 'invalid_request',
855
- });
856
- }
857
- return this.create('question', input, normalizeHandoffOptions(input.options));
858
- }
859
- /** Fetch a handoff's current state. */
860
- get(id) {
861
- requireNonEmpty(id, 'id');
862
- return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
863
- }
864
- /**
865
- * Block until the handoff resolves (or expires/cancels), looping the bounded
866
- * long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
867
- * `hold`) all surface as normal terminal results — nothing is thrown for them.
868
- */
869
- async waitForResult(id, input = {}) {
870
- 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
- }
882
- }
883
- /** List the agent's open handoffs or bounded recent history. */
884
- async list(input = {}) {
885
- const res = await this.http.request('GET', '/api/agent/handoffs', {
886
- query: dropUndefined({ state: input.state }),
887
- });
888
- return res.handoffs ?? [];
889
- }
890
- /** One bounded long-poll; returns the (possibly still-open) handoff. */
891
- wait(id, input) {
892
- return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
893
- query: dropUndefined({ timeout: input.timeout }),
894
- timeoutMs: waitTimeoutMs(input.timeout),
895
- ...(input.signal ? { signal: input.signal } : {}),
896
- });
897
- }
898
- create(kind, input, options) {
899
- const body = dropUndefined({
900
- kind,
901
- prompt: input.prompt,
902
- audience: { type: 'direct', user_id: input.target ?? 'me' },
903
- expires_in: input.expiresIn,
904
- urgency: input.urgency,
905
- options,
906
- correlation_id: input.correlationId,
907
- reply_to: input.replyTo,
908
- data: input.data,
909
- });
910
- return this.http.request('POST', '/api/agent/handoffs', {
911
- body,
912
- ...(input.idempotencyKey ? { headers: { 'Idempotency-Key': input.idempotencyKey } } : {}),
913
- });
914
- }
915
- }
916
- /** Setup and activation of the bound human's Agent Inbox delivery room. */
917
- class InboxApi {
918
- http;
919
- constructor(http) {
920
- this.http = http;
921
- }
922
- /** Replay the viable activation attempt or create its next numbered retry. */
923
- ensure(input = {}) {
924
- return this.http.request('POST', '/api/agent/inbox/ensure', {
925
- body: {},
926
- ...(input.signal ? { signal: input.signal } : {}),
927
- });
928
- }
929
- /**
930
- * Ensure the bound human's Agent Inbox, then block on its onboarding
931
- * Question until the server confirms activation, it expires/cancels, or the
932
- * overall deadline elapses. Pairing remains a separate operation so adopting
933
- * a credential never silently waits for a phone response.
934
- */
935
- async activate(input = {}) {
936
- const overallTimeoutMs = input.overallTimeoutMs ?? DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS;
937
- if (!Number.isFinite(overallTimeoutMs) || overallTimeoutMs < 0) {
938
- throw new PingRoomError('`overallTimeoutMs` must be a non-negative finite number.', {
939
- code: 'invalid_request',
940
- });
941
- }
942
- const deadlineError = () => (new PingRoomActivationIncompleteError('deadline_exceeded', 'Agent Inbox activation did not complete before the overall deadline.', { overall_timeout_ms: overallTimeoutMs }));
943
- const deadlineController = new AbortController();
944
- const deadlineTimer = overallTimeoutMs === 0
945
- ? null
946
- : setTimeout(() => deadlineController.abort(deadlineError()), overallTimeoutMs);
947
- if (overallTimeoutMs === 0)
948
- deadlineController.abort(deadlineError());
949
- const signal = input.signal
950
- ? combineSignals(input.signal, deadlineController.signal)
951
- : deadlineController.signal;
952
- try {
953
- throwIfAborted(signal);
954
- const ensured = validateInboxEnsure(await this.ensure({ signal }));
955
- const questionId = ensured.question.id;
956
- for (;;) {
957
- throwIfAborted(signal);
958
- const pollStartedAt = Date.now();
959
- const question = validateInboxWait(await this.http.request('GET', `/api/agent/handoffs/${enc(questionId)}/wait`, {
960
- query: dropUndefined({ timeout: input.timeout }),
961
- timeoutMs: waitTimeoutMs(input.timeout),
962
- signal,
963
- }), questionId);
964
- throwIfAborted(signal);
965
- if (question.state === 'pending') {
966
- await sleep(Math.max(0, MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
967
- continue;
968
- }
969
- if (question.state === 'answered' && question.activation_completed === true) {
970
- return {
971
- ...ensured,
972
- activation_completed: true,
973
- question: question,
974
- };
975
- }
976
- if (question.state === 'answered') {
977
- throw new PingRoomActivationIncompleteError('answered_without_completion', 'The onboarding Question was answered without verified native phone receipt before the answer.', { question_id: questionId, activation_completed: question.activation_completed ?? null });
978
- }
979
- if (question.state === 'expired' || question.state === 'cancelled') {
980
- throw new PingRoomActivationIncompleteError(question.state, `The onboarding Question ${question.state} before Agent Inbox activation completed.`, { question_id: questionId, activation_completed: question.activation_completed ?? null });
981
- }
982
- return invalidInboxResponse('PingRoom returned an invalid terminal activation state.', {
983
- id: questionId,
984
- state: question.state,
985
- });
986
- }
987
- }
988
- catch (error) {
989
- // A custom fetch implementation may reject with a generic AbortError
990
- // instead of the signal's richer reason. Preserve caller cancellation
991
- // and the activation-specific deadline error at this public boundary.
992
- throwIfAborted(signal);
993
- throw error;
994
- }
995
- finally {
996
- if (deadlineTimer !== null)
997
- clearTimeout(deadlineTimer);
998
- }
999
- }
1000
- }
1001
- class ProfileApi {
1002
- http;
1003
- constructor(http) {
1004
- this.http = http;
1005
- }
1006
- /**
1007
- * Set this agent's own avatar (from the bot avatar set) — the robot face
1008
- * its pings and Questions wear. Never touches the bound human's profile
1009
- * picture. Returns `{ ok, avatar_id, avatar_url }`.
1010
- */
1011
- setAvatar(avatarId) {
1012
- requireNonEmpty(avatarId, 'avatarId');
1013
- return this.http.request('POST', '/api/agent/profile/avatar', { body: { avatar_id: avatarId } });
1014
- }
1015
- /** Rotate the agent's handle — the kill-switch for a leaked handle. */
1016
- rotateHandle() {
1017
- return this.http.request('POST', '/api/agent/profile/handle/rotate', { body: {} });
1018
- }
1019
- }
1020
- /**
1021
- * Live-status streams — a self-updating card on the room members' lock screen.
1022
- *
1023
- * Requires the `pingroom:live:write` scope. Free accounts get a small daily
1024
- * budget of NEW streams; updates and the terminal ping of an open stream are
1025
- * never charged, so a card can never be quota-blocked into hanging open. A
1026
- * first ping that is already `done`/`failed` is a new stream and is charged.
1027
- */
1028
- class LiveApi {
1029
- http;
1030
- constructor(http) {
1031
- this.http = http;
1032
- }
1033
- /**
1034
- * Send one stream ping — start, update, or end, chosen by `live_status.state`
1035
- * and whether the correlation already exists.
1036
- */
1037
- push(inviteCode, ping) {
1038
- requireNonEmpty(inviteCode, 'inviteCode');
1039
- requireNonEmpty(ping.correlation_id, 'correlation_id');
1040
- assertStructuredData(ping.data);
1041
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/live`, { body: ping });
1042
- }
1043
- /**
1044
- * Read back a stream this credential started, so a restarted producer can
1045
- * reconcile instead of opening a duplicate.
1046
- *
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".
1052
- */
1053
- async get(inviteCode, correlationId) {
1054
- requireNonEmpty(inviteCode, 'inviteCode');
1055
- requireNonEmpty(correlationId, 'correlationId');
1056
- try {
1057
- return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
1058
- }
1059
- catch (error) {
1060
- if (error instanceof PingRoomError && error.status === 404)
1061
- return null;
1062
- throw error;
1063
- }
1064
- }
1065
- }
1066
- /**
1067
- * Private files that a Ping or Question can carry.
1068
- *
1069
- * Upload first, then pass the returned ids to `broadcast()` / `questions.ask()`
1070
- * as `attachment_ids`. Bytes never travel over MCP or a JSON ping body. An
1071
- * upload that is never attached expires on its own after 24 hours.
1072
- * Each file must be 1 byte–5 MiB with an md/pdf/html/txt/jpg/jpeg/png/zip filename;
1073
- * a Ping or Question accepts at most four ids.
1074
- *
1075
- * Uploading requires the bound account to hold Pro (the API answers 402
1076
- * `pro_required` otherwise). Reading and deleting are not gated.
1077
- */
1078
- class AttachmentsApi {
1079
- http;
1080
- constructor(http) {
1081
- this.http = http;
1082
- }
1083
- /** Upload one file and get back the id a send can claim. */
1084
- upload(input) {
1085
- requireNonEmpty(input.filename, 'filename');
1086
- // The server answers an oversized body with 413 before reading it; refuse
1087
- // locally so no bytes travel for a file that can never be accepted.
1088
- if (attachmentByteLength(input.content) > ATTACHMENT_MAX_BYTES) {
1089
- return Promise.reject(new PingRoomError('Attachments are at most 5 MiB each.', { code: 'attachment_too_large', status: 413 }));
1090
- }
1091
- const body = new FormData();
1092
- body.append('file', toAttachmentBlob(input), input.filename);
1093
- return this.http
1094
- .request('POST', '/api/agent/attachments', {
1095
- body,
1096
- ...(input.signal ? { signal: input.signal } : {}),
1097
- })
1098
- .then((res) => res.attachment);
1099
- }
1100
- /**
1101
- * Raw bytes for an attachment this credential is allowed to see. Returns the
1102
- * undecoded Response so a caller can stream it, or read text/arrayBuffer.
1103
- */
1104
- content(attachmentId, signal) {
1105
- requireNonEmpty(attachmentId, 'attachmentId');
1106
- return this.http.raw('GET', `/api/agent/attachments/${enc(attachmentId)}/content`, {
1107
- ...(signal ? { signal } : {}),
1108
- });
1109
- }
1110
- /**
1111
- * The listing of a `.zip`'s contents, recorded when it was uploaded.
1112
- *
1113
- * Resolves null for a non-archive, and for an archive whose central
1114
- * directory did not parse. Nothing decompresses the file to produce this, so
1115
- * the declared sizes are what the archive claims, not verified output.
1116
- */
1117
- manifest(attachmentId, signal) {
1118
- requireNonEmpty(attachmentId, 'attachmentId');
1119
- return this.http
1120
- .request('GET', `/api/agent/attachments/${enc(attachmentId)}/manifest`, { ...(signal ? { signal } : {}) })
1121
- .then((res) => res.manifest ?? null);
1122
- }
1123
- /** Discard an upload that has not been attached to a ping yet. */
1124
- delete(attachmentId) {
1125
- requireNonEmpty(attachmentId, 'attachmentId');
1126
- return this.http.request('DELETE', `/api/agent/attachments/${enc(attachmentId)}`);
1127
- }
1128
- }
1129
- /** Normalise the three accepted content shapes into one multipart part. */
1130
- function toAttachmentBlob(input) {
1131
- const type = input.contentType ?? '';
1132
- if (input.content instanceof Blob) {
1133
- return type ? new Blob([input.content], { type }) : input.content;
1134
- }
1135
- // Copy into a plain ArrayBuffer: a Uint8Array view may be a window onto a
1136
- // larger (or SharedArrayBuffer-backed) buffer, which Blob would not accept.
1137
- const parts = typeof input.content === 'string'
1138
- ? [input.content]
1139
- : [new Uint8Array(input.content).slice().buffer];
1140
- return new Blob(parts, type ? { type } : undefined);
1141
- }
1142
- /** Public, unauthenticated agent directory. */
1143
- class DirectoryApi {
1144
- http;
1145
- constructor(http) {
1146
- this.http = http;
1147
- }
1148
- list(input = {}) {
1149
- return this.http.request('GET', '/api/agents/directory', {
1150
- auth: false,
1151
- query: dropUndefined({ ...input }),
1152
- });
1153
- }
1154
- get(handle) {
1155
- return this.http.request('GET', `/api/agents/directory/${enc(handle)}`, { auth: false });
1156
- }
1157
- }
1158
25
  // --- main client ----------------------------------------------------------
1159
26
  export class PingRoom {
1160
27
  auth;
@@ -1163,7 +30,6 @@ export class PingRoom {
1163
30
  actions;
1164
31
  notifications;
1165
32
  attachments;
1166
- agents;
1167
33
  approvals;
1168
34
  questions;
1169
35
  handoffs;
@@ -1189,7 +55,6 @@ export class PingRoom {
1189
55
  this.actions = new ActionsApi(this.http);
1190
56
  this.notifications = new NotificationsApi(this.http);
1191
57
  this.attachments = new AttachmentsApi(this.http);
1192
- this.agents = new AgentsApi(this.http);
1193
58
  this.approvals = new ApprovalsApi(this.http);
1194
59
  this.questions = new QuestionsApi(this.http);
1195
60
  this.handoffs = new HandoffsApi(this.http);
@@ -1212,12 +77,26 @@ export class PingRoom {
1212
77
  body: { code: normalizeRedeemCode(code) },
1213
78
  });
1214
79
  }
1215
- /** Broadcast a ping to every member of a room you own. */
80
+ /**
81
+ * Broadcast a ping to every member of a room you own. Pass `idempotencyKey`
82
+ * and reuse it when retrying after a timeout or network error, so a send
83
+ * the server already committed is replayed rather than delivered twice.
84
+ */
1216
85
  broadcast(inviteCode, ping) {
1217
86
  assertPing(ping);
87
+ const { body, headers } = splitIdempotencyKey(ping);
1218
88
  return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/notifications`, {
1219
- body: dropUndefined({ ...ping }),
89
+ body: dropUndefined({ ...body }),
90
+ ...(headers ? { headers } : {}),
1220
91
  });
1221
92
  }
93
+ /** What a logger or JSON.stringify() sees: the API origin, never the credential. */
94
+ toJSON() {
95
+ return this.http.toJSON();
96
+ }
97
+ [INSPECT]() {
98
+ const { baseUrl, token } = this.http.toJSON();
99
+ return `PingRoom { baseUrl: ${JSON.stringify(baseUrl)}, token: ${token === null ? 'null' : token} }`;
100
+ }
1222
101
  }
1223
102
  //# sourceMappingURL=client.js.map