@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
@@ -0,0 +1,253 @@
1
+ // `pr.inbox`: setting up and activating the human's Agent Inbox.
2
+
3
+ import { PingRoomActivationIncompleteError, PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import { combineSignals, sleep, throwIfAborted } from '../internal/async.js';
6
+ import { isTransientError, transientDelayMs } from '../internal/retry.js';
7
+ import type {
8
+ AgentInboxEnsureResult,
9
+ AgentInboxActivateInput,
10
+ AgentInboxActivationQuestion,
11
+ AgentInboxActivationResult,
12
+ AgentInboxEnsureInput,
13
+ Handoff,
14
+ } from '../types.js';
15
+ import {
16
+ MIN_HANDOFF_POLL_INTERVAL_MS,
17
+ enc,
18
+ dropUndefined,
19
+ waitTimeoutMs,
20
+ } from '../internal/request.js';
21
+ import { isRecord, isNonEmptyString, isNullableString } from '../internal/validate.js';
22
+
23
+ const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
24
+
25
+ function invalidInboxResponse(message: string, body: Record<string, unknown> = {}): never {
26
+ throw new PingRoomError(message, { code: 'inbox_invalid_response', body });
27
+ }
28
+
29
+ function validateInboxEnsure(value: unknown): AgentInboxEnsureResult {
30
+ if (!isRecord(value) || value['onboarded'] !== true || typeof value['replayed'] !== 'boolean') {
31
+ return invalidInboxResponse('PingRoom returned an invalid Agent Inbox ensure response.');
32
+ }
33
+
34
+ const room = value['room'];
35
+ const question = value['question'];
36
+ if (
37
+ !isRecord(room)
38
+ || !isNonEmptyString(room['id'])
39
+ || typeof room['name'] !== 'string'
40
+ || !isNonEmptyString(room['invite_code'])
41
+ || typeof room['is_agent_inbox'] !== 'boolean'
42
+ || !isRecord(question)
43
+ || !isNonEmptyString(question['id'])
44
+ || question['kind'] !== 'question'
45
+ || !isNonEmptyString(question['prompt'])
46
+ || !Array.isArray(question['options'])
47
+ || question['options'].some((option) => (
48
+ !isRecord(option)
49
+ || !isNonEmptyString(option['value'])
50
+ || !isNonEmptyString(option['label'])
51
+ ))
52
+ || (question['state'] !== 'pending'
53
+ && question['state'] !== 'answered'
54
+ && question['state'] !== 'expired'
55
+ && question['state'] !== 'cancelled')
56
+ || !isNullableString(question['expires_at'])
57
+ || !isNullableString(question['created_at'])
58
+ ) {
59
+ return invalidInboxResponse('PingRoom returned an incomplete Agent Inbox ensure response.');
60
+ }
61
+
62
+ return value as unknown as AgentInboxEnsureResult;
63
+ }
64
+
65
+ function validateInboxWait(value: unknown, expectedId: string): Handoff {
66
+ if (!isRecord(value)) {
67
+ return invalidInboxResponse('PingRoom returned an invalid Agent Inbox wait response.');
68
+ }
69
+
70
+ const state = value['state'];
71
+ if (
72
+ !isNonEmptyString(value['id'])
73
+ || value['id'] !== expectedId
74
+ || value['kind'] !== 'question'
75
+ || (state !== 'pending' && state !== 'answered' && state !== 'expired' && state !== 'cancelled')
76
+ || (value['activation_completed'] !== undefined && typeof value['activation_completed'] !== 'boolean')
77
+ || (state !== 'answered' && value['activation_completed'] === true)
78
+ ) {
79
+ return invalidInboxResponse('PingRoom returned a mismatched Agent Inbox wait response.', {
80
+ expected_id: expectedId,
81
+ received_id: typeof value['id'] === 'string' ? value['id'] : null,
82
+ kind: typeof value['kind'] === 'string' ? value['kind'] : null,
83
+ state: typeof state === 'string' ? state : null,
84
+ });
85
+ }
86
+
87
+ if (state === 'answered') {
88
+ const answer = value['answer'];
89
+ if (
90
+ !isRecord(answer)
91
+ || !isNullableString(answer['value'])
92
+ || !isNullableString(answer['label'])
93
+ || !isNullableString(answer['text'])
94
+ || (!isNonEmptyString(answer['value']) && !isNonEmptyString(answer['text']))
95
+ || !isNullableString(answer['answered_at'])
96
+ || (answer['responder'] !== null && !isRecord(answer['responder']))
97
+ ) {
98
+ return invalidInboxResponse('PingRoom returned an answered activation without a valid answer.', {
99
+ id: expectedId,
100
+ state,
101
+ });
102
+ }
103
+
104
+ const responder = answer['responder'];
105
+ if (
106
+ isRecord(responder)
107
+ && (!isNullableString(responder['id']) || !isNullableString(responder['display_name']))
108
+ ) {
109
+ return invalidInboxResponse('PingRoom returned an invalid activation responder.', {
110
+ id: expectedId,
111
+ state,
112
+ });
113
+ }
114
+ } else if (value['answer'] !== undefined && value['answer'] !== null) {
115
+ return invalidInboxResponse('PingRoom returned an answer for an unresolved activation.', {
116
+ id: expectedId,
117
+ state,
118
+ });
119
+ }
120
+
121
+ return value as unknown as Handoff;
122
+ }
123
+
124
+ /** Setup and activation of the bound human's Agent Inbox delivery room. */
125
+ export class InboxApi {
126
+ constructor(private readonly http: HttpClient) {}
127
+
128
+ /** Replay the viable activation attempt or create its next numbered retry. */
129
+ ensure(input: AgentInboxEnsureInput = {}): Promise<AgentInboxEnsureResult> {
130
+ return this.http.request('POST', '/api/agent/inbox/ensure', {
131
+ body: {},
132
+ ...(input.signal ? { signal: input.signal } : {}),
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Ensure the bound human's Agent Inbox, then block on its onboarding
138
+ * Question until the server confirms activation, it expires/cancels, or the
139
+ * overall deadline elapses. Pairing remains a separate operation so adopting
140
+ * a credential never silently waits for a phone response.
141
+ */
142
+ async activate(input: AgentInboxActivateInput = {}): Promise<AgentInboxActivationResult> {
143
+ const overallTimeoutMs = input.overallTimeoutMs ?? DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS;
144
+ if (!Number.isFinite(overallTimeoutMs) || overallTimeoutMs < 0) {
145
+ throw new PingRoomError('`overallTimeoutMs` must be a non-negative finite number.', {
146
+ code: 'invalid_request',
147
+ });
148
+ }
149
+
150
+ const deadlineError = (): PingRoomActivationIncompleteError => (
151
+ new PingRoomActivationIncompleteError(
152
+ 'deadline_exceeded',
153
+ 'Agent Inbox activation did not complete before the overall deadline.',
154
+ { overall_timeout_ms: overallTimeoutMs },
155
+ )
156
+ );
157
+ const deadlineController = new AbortController();
158
+ const deadlineTimer = overallTimeoutMs === 0
159
+ ? null
160
+ : setTimeout(() => deadlineController.abort(deadlineError()), overallTimeoutMs);
161
+ if (overallTimeoutMs === 0) deadlineController.abort(deadlineError());
162
+
163
+ const signal = input.signal
164
+ ? combineSignals(input.signal, deadlineController.signal)
165
+ : deadlineController.signal;
166
+
167
+ try {
168
+ throwIfAborted(signal);
169
+ const ensured = validateInboxEnsure(await this.ensure({ signal }));
170
+ const questionId = ensured.question.id;
171
+
172
+ let failures = 0;
173
+ for (;;) {
174
+ throwIfAborted(signal);
175
+ const pollStartedAt = Date.now();
176
+ let question: Handoff;
177
+ try {
178
+ question = validateInboxWait(await this.http.request<unknown>(
179
+ 'GET',
180
+ `/api/agent/handoffs/${enc(questionId)}/wait`,
181
+ {
182
+ query: dropUndefined({ timeout: input.timeout }),
183
+ timeoutMs: waitTimeoutMs(input.timeout),
184
+ signal,
185
+ },
186
+ ), questionId);
187
+ } catch (error) {
188
+ // A dropped connection, a request deadline, a 429 (each wait route
189
+ // has its own 30/min bucket per human) or a 5xx is not an outcome:
190
+ // retry it like every other wait, inside the overall deadline.
191
+ // Anything else is permanent and surfaces.
192
+ throwIfAborted(signal);
193
+ if (!isTransientError(error)) {
194
+ throw error;
195
+ }
196
+ failures += 1;
197
+ await sleep(Math.max(
198
+ transientDelayMs(error, failures),
199
+ MIN_HANDOFF_POLL_INTERVAL_MS - (Date.now() - pollStartedAt),
200
+ ), signal);
201
+ continue;
202
+ }
203
+ failures = 0;
204
+ throwIfAborted(signal);
205
+
206
+ if (question.state === 'pending') {
207
+ await sleep(
208
+ Math.max(0, MIN_HANDOFF_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)),
209
+ signal,
210
+ );
211
+ continue;
212
+ }
213
+
214
+ if (question.state === 'answered' && question.activation_completed === true) {
215
+ return {
216
+ ...ensured,
217
+ activation_completed: true,
218
+ question: question as AgentInboxActivationQuestion,
219
+ };
220
+ }
221
+
222
+ if (question.state === 'answered') {
223
+ throw new PingRoomActivationIncompleteError(
224
+ 'answered_without_completion',
225
+ 'The onboarding Question was answered without verified native phone receipt before the answer.',
226
+ { question_id: questionId, activation_completed: question.activation_completed ?? null },
227
+ );
228
+ }
229
+
230
+ if (question.state === 'expired' || question.state === 'cancelled') {
231
+ throw new PingRoomActivationIncompleteError(
232
+ question.state,
233
+ `The onboarding Question ${question.state} before Agent Inbox activation completed.`,
234
+ { question_id: questionId, activation_completed: question.activation_completed ?? null },
235
+ );
236
+ }
237
+
238
+ return invalidInboxResponse('PingRoom returned an invalid terminal activation state.', {
239
+ id: questionId,
240
+ state: question.state,
241
+ });
242
+ }
243
+ } catch (error) {
244
+ // A custom fetch implementation may reject with a generic AbortError
245
+ // instead of the signal's richer reason. Preserve caller cancellation
246
+ // and the activation-specific deadline error at this public boundary.
247
+ throwIfAborted(signal);
248
+ throw error;
249
+ } finally {
250
+ if (deadlineTimer !== null) clearTimeout(deadlineTimer);
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,134 @@
1
+ // `pr.live`: live-status streams.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import { assertStructuredData, requireNonEmpty } from '../internal/guards.js';
6
+ import type {
7
+ LiveClaimAction,
8
+ LiveClaimResult,
9
+ LiveClaimStatus,
10
+ LiveClaimStreamState,
11
+ LiveStatusPing,
12
+ LiveStatusResult,
13
+ LiveStatusSnapshot,
14
+ } from '../liveStatus.js';
15
+ import { enc } from '../internal/request.js';
16
+ import { isNonEmptyString, isRecord, sameId } from '../internal/validate.js';
17
+
18
+ /**
19
+ * What each action's 200 receipt may say, as `[claim.status, stream state]`
20
+ * pairs. `release` has two: letting go reopens a request that can still be
21
+ * taken, but a request past its deadline (or four hours old with none) ends as
22
+ * `expired` with stream state `failed`, exactly as time ends an open one. That
23
+ * is still a 200, and a retry replays it. Only `release` can end that way.
24
+ */
25
+ const CLAIM_OUTCOMES = {
26
+ claim: [['claimed', 'running']],
27
+ release: [['open', 'running'], ['expired', 'failed']],
28
+ done: [['done', 'done']],
29
+ cancel: [['cancelled', 'failed']],
30
+ } as const satisfies Record<LiveClaimAction, ReadonlyArray<readonly [LiveClaimStatus, LiveClaimStreamState]>>;
31
+
32
+ function validateClaimResult(value: unknown, notificationId: string, action: LiveClaimAction): LiveClaimResult {
33
+ const expected = CLAIM_OUTCOMES[action];
34
+ const claim = isRecord(value) ? value['claim'] : undefined;
35
+ const party = isRecord(claim) ? claim['by'] : undefined;
36
+ if (!isRecord(value) || !sameId(value['notification_id'], notificationId)
37
+ || !isNonEmptyString(value['correlation_id']) || typeof value['idempotent'] !== 'boolean'
38
+ || !isRecord(claim) || !expected
39
+ || !expected.some(([status, state]) => claim['status'] === status && value['state'] === state)
40
+ || typeof claim['late'] !== 'boolean'
41
+ || !(party === null || isRecord(party) && isNonEmptyString(party['id']) && typeof party['name'] === 'string')
42
+ || !(claim['at'] === null || typeof claim['at'] === 'number' && Number.isFinite(claim['at']))) {
43
+ throw new PingRoomError('PingRoom returned an invalid claim transition receipt.', {
44
+ code: 'invalid_response',
45
+ body: { expected_notification_id: notificationId, action },
46
+ });
47
+ }
48
+ return value as unknown as LiveClaimResult;
49
+ }
50
+
51
+ /**
52
+ * Live-status streams — a self-updating card on the room members' lock screen.
53
+ *
54
+ * Requires the `pingroom:live:write` scope. Free accounts get a small daily
55
+ * budget of NEW streams; updates and the terminal ping of an open stream are
56
+ * never charged, so a card can never be quota-blocked into hanging open. A
57
+ * first ping that is already `done`/`failed` is a new stream and is charged.
58
+ */
59
+ export class LiveApi {
60
+ constructor(private readonly http: HttpClient) {}
61
+
62
+ /**
63
+ * Send one stream ping — start, update, or end, chosen by `live_status.state`
64
+ * and whether the correlation already exists.
65
+ */
66
+ push(inviteCode: string, ping: LiveStatusPing): Promise<LiveStatusResult> {
67
+ requireNonEmpty(inviteCode, 'inviteCode');
68
+ requireNonEmpty(ping.correlation_id, 'correlation_id');
69
+ assertStructuredData(ping.data);
70
+ return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/live`, { body: ping });
71
+ }
72
+
73
+ /**
74
+ * Read back a stream this credential started, so a restarted producer can
75
+ * reconcile instead of opening a duplicate.
76
+ *
77
+ * Resolves null ONLY for a 404 `stream_not_found` — no stream with that
78
+ * correlation id in the last 24 hours. Every other failure throws: a 404 for
79
+ * an unknown or deleted room code, and a 403 `room_not_granted`
80
+ * ({@link RoomScopedErrorCode}) when the room is outside the grant the human
81
+ * gave this agent, both propagate rather than being reported as "no stream".
82
+ */
83
+ async get(inviteCode: string, correlationId: string): Promise<LiveStatusSnapshot | null> {
84
+ requireNonEmpty(inviteCode, 'inviteCode');
85
+ requireNonEmpty(correlationId, 'correlationId');
86
+ try {
87
+ return await this.http.request<LiveStatusSnapshot>(
88
+ 'GET',
89
+ `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`,
90
+ );
91
+ } catch (error) {
92
+ if (error instanceof PingRoomError && error.status === 404 && error.code === 'stream_not_found') return null;
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Act on a "Who's on it?" request as the human this credential acts for:
99
+ * `claim` takes an open request (anyone but its asker), `release` lets go of
100
+ * one you hold, `done` finishes it (its claimer, or its asker at any time) and
101
+ * `cancel` withdraws it (its asker, or a room owner or admin).
102
+ *
103
+ * `release` returns the request to `open` only while it can still be taken.
104
+ * Past its deadline (or four hours after it was made, when it has none) it
105
+ * ends the request as `expired` instead: a 200 whose `claim.status` is
106
+ * `expired` and whose `state` is `failed`, with `claim.by` and `claim.at`
107
+ * `null`. It is not an error, and the asker hears "No one took it". No other
108
+ * action resolves that way.
109
+ *
110
+ * `notificationId` is the request's `notification_id`, from the `push()`
111
+ * result or `notifications.list()`. It needs only room membership, not room
112
+ * ownership, and the `pingroom:live:write` scope.
113
+ *
114
+ * Repeating your own last action resolves with `idempotent: true`. A refusal
115
+ * throws `PingRoomError`: `claim_conflict` (409), `claim_own_request` (422),
116
+ * `claim_private_rooms_only` (422), `claim_not_found` (404) or `forbidden`
117
+ * (403). On 409 and 422 `error.body` is a `LiveClaimRefusal` carrying the
118
+ * request as it stands, so you can say who got it first.
119
+ * A malformed or mismatched success throws `invalid_response`; the SDK
120
+ * never repeats the transition POST automatically.
121
+ */
122
+ claim(inviteCode: string, notificationId: string, action: LiveClaimAction): Promise<LiveClaimResult> {
123
+ requireNonEmpty(inviteCode, 'inviteCode');
124
+ requireNonEmpty(notificationId, 'notificationId');
125
+ if (typeof action !== 'string' || !Object.hasOwn(CLAIM_OUTCOMES, action)) {
126
+ throw new PingRoomError('`action` must be claim, release, done or cancel.', { code: 'invalid_request' });
127
+ }
128
+ return this.http.request<unknown>(
129
+ 'POST',
130
+ `/api/agent/rooms/${enc(inviteCode)}/live/${enc(notificationId)}/claim`,
131
+ { body: { action } },
132
+ ).then((value) => validateClaimResult(value, notificationId, action));
133
+ }
134
+ }
@@ -0,0 +1,152 @@
1
+ // `pr.notifications`: history, one-shot waits and the listen() iterator.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import { sleep } from '../internal/async.js';
6
+ import { requireNonEmpty } from '../internal/guards.js';
7
+ import { isTransientError, transientDelayMs } from '../internal/retry.js';
8
+ import { isRecord } from '../internal/validate.js';
9
+ import type {
10
+ AcknowledgementWaitResult,
11
+ AgentNotification,
12
+ ListNotificationsInput,
13
+ ListenInput,
14
+ NotificationDetail,
15
+ NotificationHistoryEntry,
16
+ Paginated,
17
+ WaitForAcknowledgementInput,
18
+ WaitInput,
19
+ WaitResult,
20
+ } from '../types.js';
21
+ import { enc, dropUndefined, MIN_LISTEN_POLL_INTERVAL_MS, waitTimeoutMs } from '../internal/request.js';
22
+
23
+ /**
24
+ * A 2xx that is not a wait answer (a captive portal's HTML, a proxy's error
25
+ * page served as 200) must not end `listen()` with a bare TypeError, nor
26
+ * advance its cursor.
27
+ */
28
+ function validateListenResult(value: unknown, requireCursor: boolean): WaitResult {
29
+ if (
30
+ !isRecord(value)
31
+ || !Array.isArray(value['notifications'])
32
+ || value['notifications'].some((notification) => !isRecord(notification))
33
+ || (value['cursor'] === undefined ? requireCursor : typeof value['cursor'] !== 'string')
34
+ ) {
35
+ throw new PingRoomError('PingRoom returned an invalid notifications wait response.', {
36
+ code: 'invalid_response',
37
+ });
38
+ }
39
+ return value as unknown as WaitResult;
40
+ }
41
+
42
+ export class NotificationsApi {
43
+ constructor(private readonly http: HttpClient) {}
44
+
45
+ /** Paginated list (polling fallback). Prefer wait()/listen() for real-time. */
46
+ async list(input: ListNotificationsInput = {}): Promise<Paginated<NotificationHistoryEntry>> {
47
+ const { notifications_per_page, ...query } = input;
48
+ const page = await this.http.request<Paginated<NotificationDetail>>('GET', '/api/agent/notifications', {
49
+ query: dropUndefined({ ...query, limit: input.limit ?? notifications_per_page }),
50
+ });
51
+ return {
52
+ ...page,
53
+ data: page.data.map(({ room, ...notification }) => ({
54
+ ...notification,
55
+ ...(room ? { room: { ...room, code: room.invite_code } } : {}),
56
+ })),
57
+ };
58
+ }
59
+
60
+ /** Fetch one visible notification, including its current acknowledgement state. */
61
+ getNotification(notificationId: string): Promise<NotificationDetail> {
62
+ requireNonEmpty(notificationId, 'notificationId');
63
+ return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}`);
64
+ }
65
+
66
+ /**
67
+ * One bounded long-poll for an ack-required ping. The returned state may
68
+ * still be `open` when the hold timeout elapses; call again to keep waiting.
69
+ */
70
+ waitForAcknowledgement(
71
+ notificationId: string,
72
+ input: WaitForAcknowledgementInput = {},
73
+ ): Promise<AcknowledgementWaitResult> {
74
+ requireNonEmpty(notificationId, 'notificationId');
75
+ return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}/ack/wait`, {
76
+ query: dropUndefined({ timeout: input.timeout }),
77
+ timeoutMs: waitTimeoutMs(input.timeout),
78
+ ...(input.signal ? { signal: input.signal } : {}),
79
+ });
80
+ }
81
+
82
+ /** One long-poll: resolves as soon as new pings land, or with an empty list at timeout. */
83
+ wait(input: WaitInput = {}): Promise<WaitResult> {
84
+ return this.http.request('GET', '/api/agent/notifications/wait', {
85
+ query: dropUndefined({ after: input.after, timeout: input.timeout, limit: input.limit }),
86
+ timeoutMs: waitTimeoutMs(input.timeout),
87
+ ...(input.signal ? { signal: input.signal } : {}),
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Continuous real-time inbound: an async iterator that long-polls in a loop,
93
+ * advancing the cursor, yielding each new ping. Stops when `signal` aborts.
94
+ *
95
+ * for await (const ping of pr.notifications.listen({ signal })) { ... }
96
+ */
97
+ async *listen(input: ListenInput = {}): AsyncGenerator<AgentNotification, void, void> {
98
+ let cursor = input.after;
99
+ let headRead = cursor !== undefined;
100
+ const backoffMs = input.backoffMs ?? 1000;
101
+ let failures = 0;
102
+ while (!input.signal?.aborted) {
103
+ let result: WaitResult;
104
+ // The head read is an immediate answer by design; only polls ahead of
105
+ // the cursor are spaced.
106
+ const polledForward = headRead;
107
+ const pollStartedAt = Date.now();
108
+ try {
109
+ // The head read exists to obtain a cursor, so it must carry one.
110
+ result = validateListenResult(headRead
111
+ ? await this.wait({
112
+ after: cursor,
113
+ ...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
114
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
115
+ ...(input.signal ? { signal: input.signal } : {}),
116
+ })
117
+ // No cursor yet: read the current head (an immediate, empty answer)
118
+ // so only pings that land after this call are delivered.
119
+ : await this.wait({ timeout: 0, ...(input.signal ? { signal: input.signal } : {}) }), !headRead);
120
+ } catch (err) {
121
+ if (input.signal?.aborted) {
122
+ return;
123
+ }
124
+ // A revoked credential or a bad request never heals; only network
125
+ // failures, deadlines, 429 and 5xx are retried.
126
+ if (!isTransientError(err)) {
127
+ throw err;
128
+ }
129
+ failures += 1;
130
+ input.onError?.(err);
131
+ await sleep(Math.max(
132
+ transientDelayMs(err, failures, backoffMs),
133
+ MIN_LISTEN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt),
134
+ ), input.signal);
135
+ continue;
136
+ }
137
+ failures = 0;
138
+ headRead = true;
139
+ cursor = result.cursor ?? cursor;
140
+ for (const n of result.notifications) {
141
+ yield n;
142
+ }
143
+ // Yield between polls so timer-driven cancellation can run (see
144
+ // ApprovalsApi). After an empty answer also keep polls a floor apart, so
145
+ // `timeout: 0` or an early-answering proxy cannot spin the loop.
146
+ const spacingMs = polledForward && result.notifications.length === 0
147
+ ? Math.max(0, MIN_LISTEN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt))
148
+ : 0;
149
+ await sleep(spacingMs, input.signal);
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,24 @@
1
+ // `pr.profile`: this agent's own avatar and handle.
2
+
3
+ import type { HttpClient } from '../http.js';
4
+ import { requireNonEmpty } from '../internal/guards.js';
5
+ import type { RotateHandleResult } from '../types.js';
6
+
7
+ export class ProfileApi {
8
+ constructor(private readonly http: HttpClient) {}
9
+
10
+ /**
11
+ * Set this agent's own avatar (from the bot avatar set) — the robot face
12
+ * its pings and Questions wear. Never touches the bound human's profile
13
+ * picture. Returns `{ ok, avatar_id, avatar_url }`.
14
+ */
15
+ setAvatar(avatarId: string): Promise<{ ok: boolean; avatar_id: string; avatar_url: string }> {
16
+ requireNonEmpty(avatarId, 'avatarId');
17
+ return this.http.request('POST', '/api/agent/profile/avatar', { body: { avatar_id: avatarId } });
18
+ }
19
+
20
+ /** Rotate the agent's handle — the kill-switch for a leaked handle. */
21
+ rotateHandle(): Promise<RotateHandleResult> {
22
+ return this.http.request('POST', '/api/agent/profile/handle/rotate', { body: {} });
23
+ }
24
+ }