@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,284 @@
1
+ // `pr.auth`: registration, claim, app pairing and the credential lifecycle.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import { sleep, throwIfAborted } from '../internal/async.js';
6
+ import { requireNonEmpty } from '../internal/guards.js';
7
+ import type {
8
+ AgentConnectionInfo,
9
+ AgentDisconnectResult,
10
+ Credential,
11
+ ClaimCompleteParams,
12
+ ClaimStartParams,
13
+ RegisterParams,
14
+ PairingStart,
15
+ PairingStatus,
16
+ PairedCredential,
17
+ StartPairingParams,
18
+ WaitForPairingOptions,
19
+ } from '../types.js';
20
+ import { dropUndefined } from '../internal/request.js';
21
+ import { isRecord } from '../internal/validate.js';
22
+
23
+ /** Drop a malformed or unsafe server-provided URL without rejecting its response. */
24
+ function normalizePublicHttpUrl(candidate: unknown): string | undefined {
25
+ if (typeof candidate !== 'string' || candidate.trim() === '') return undefined;
26
+ const trimmed = candidate.trim();
27
+ // eslint-disable-next-line no-control-regex
28
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return undefined;
29
+ try {
30
+ const url = new URL(trimmed);
31
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
32
+ if (url.username !== '' || url.password !== '') return undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ return trimmed;
37
+ }
38
+
39
+ /** Drop malformed or unsafe server-provided links without rejecting a valid credential. */
40
+ function normalizePairingLinks(value: unknown): { latest_pings: string; install_app?: string } | undefined {
41
+ if (!isRecord(value)) return undefined;
42
+ const latestPings = normalizePublicHttpUrl(value['latest_pings']);
43
+ if (!latestPings) return undefined;
44
+ const installApp = normalizePublicHttpUrl(value['install_app']);
45
+ return {
46
+ latest_pings: latestPings,
47
+ ...(installApp ? { install_app: installApp } : {}),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * The credential a pairing swapped in, and the one to hand back when it ends
53
+ * without an approval. Held by `pair_token`, so a PairingStart that was only
54
+ * copied or re-serialised still finds it.
55
+ */
56
+ interface PendingPairing {
57
+ pairToken: string;
58
+ pending: string;
59
+ previous: string | null;
60
+ }
61
+
62
+ /** Agent credential lifecycle (auth.md flow). */
63
+ export class AuthApi {
64
+ private pairing: PendingPairing | null = null;
65
+
66
+ constructor(private readonly http: HttpClient) {}
67
+
68
+ /** Register an agent. Anonymous or via an identity assertion (ID-JAG / verified email). Public. */
69
+ register(params: RegisterParams): Promise<Credential> {
70
+ requireNonEmpty(params.type, 'type');
71
+ return this.http.request('POST', '/api/agent/auth', { auth: false, body: dropUndefined({ ...params }) });
72
+ }
73
+
74
+ /** Begin claiming a pre-claim credential onto a human account; emails an OTP. */
75
+ claimStart(params: ClaimStartParams): Promise<{ message: string; expires_in: number }> {
76
+ requireNonEmpty(params.email, 'email');
77
+ return this.http.request('POST', '/api/agent/auth/claim/start', { body: { email: params.email } });
78
+ }
79
+
80
+ /** Complete the claim with the emailed OTP; returns an active credential + handle. */
81
+ claimComplete(params: ClaimCompleteParams): Promise<Credential> {
82
+ requireNonEmpty(params.email, 'email');
83
+ requireNonEmpty(params.otp, 'otp');
84
+ return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
85
+ }
86
+
87
+ /**
88
+ * Register a pending agent and create a short-lived link that opens the
89
+ * PingRoom app. The server owns the full grant shown to the approver; this
90
+ * client sends no scope list. The pending credential stays private inside the
91
+ * client and is replaced automatically once waitForPairing() succeeds. If the
92
+ * client already held a credential, it is put back when the pairing expires
93
+ * or the wait is aborted, so a re-pairing that never completes leaves the
94
+ * client on the working credential rather than the dead pending one.
95
+ */
96
+ async startPairing(params: StartPairingParams = {}): Promise<PairingStart> {
97
+ const previousToken = this.http.getToken();
98
+ const pending = await this.register({
99
+ type: 'anonymous',
100
+ ...(params.agent_label ? { agent_label: params.agent_label } : {}),
101
+ });
102
+ this.http.setToken(pending.credential);
103
+
104
+ try {
105
+ const started = await this.http.request<PairingStart>('POST', '/api/agent/auth/pair/start', {
106
+ body: {},
107
+ });
108
+ // A second pairing started while the first is still pending falls back
109
+ // to the credential beneath it, not to the first pairing's pending one.
110
+ this.pairing = {
111
+ pairToken: started.pair_token,
112
+ pending: pending.credential,
113
+ previous: this.pairing !== null && this.pairing.pending === previousToken
114
+ ? this.pairing.previous
115
+ : previousToken,
116
+ };
117
+ return started;
118
+ } catch (error) {
119
+ if (this.http.getToken() === pending.credential) {
120
+ this.http.setToken(previousToken);
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ /** Read the current state of an app pairing using the pending credential. */
127
+ pairingStatus(options: Pick<WaitForPairingOptions, 'signal'> = {}): Promise<PairingStatus> {
128
+ return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
129
+ }
130
+
131
+ /**
132
+ * Wait until the person approves in PingRoom, or the short-lived link
133
+ * expires. Transient network, 429, and 5xx failures are retried inside the
134
+ * server-provided lifetime. On success the client immediately starts using
135
+ * the active credential returned by the server. When the link expires or the
136
+ * wait is aborted, the credential the client held before startPairing() is
137
+ * restored; calling waitForPairing() again after an abort resumes the same
138
+ * pairing.
139
+ */
140
+ async waitForPairing(
141
+ pairing: PairingStart,
142
+ options: WaitForPairingOptions = {},
143
+ ): Promise<PairedCredential> {
144
+ const tracked = this.pairing !== null && this.pairing.pairToken === pairing.pair_token
145
+ ? this.pairing
146
+ : null;
147
+ if (tracked !== null && this.http.getToken() === tracked.previous) {
148
+ // Resuming after an abort put the earlier credential back.
149
+ this.http.setToken(tracked.pending);
150
+ }
151
+ try {
152
+ const paired = await this.pollPairing(pairing, options);
153
+ if (tracked !== null && this.pairing === tracked) this.pairing = null;
154
+ return paired;
155
+ } catch (error) {
156
+ if (tracked !== null && this.pairing === tracked) {
157
+ const expired = error instanceof PingRoomError && error.code === 'pairing_expired';
158
+ if (expired || options.signal?.aborted === true) {
159
+ if (this.http.getToken() === tracked.pending) this.http.setToken(tracked.previous);
160
+ }
161
+ // An expired pairing cannot be resumed; an aborted one can.
162
+ if (expired) this.pairing = null;
163
+ }
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ private async pollPairing(
169
+ pairing: PairingStart,
170
+ options: WaitForPairingOptions,
171
+ ): Promise<PairedCredential> {
172
+ const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
173
+ const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
174
+ const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
175
+ const startedAt = Date.now();
176
+ const deadline = startedAt + lifetimeMs;
177
+ let transientFailures = 0;
178
+
179
+ while (Date.now() < deadline) {
180
+ throwIfAborted(options.signal);
181
+
182
+ try {
183
+ const status = await this.pairingStatus({ signal: options.signal });
184
+ throwIfAborted(options.signal);
185
+ transientFailures = 0;
186
+
187
+ if (status.status === 'active') {
188
+ if (
189
+ typeof status.credential !== 'string'
190
+ || status.credential.trim() === ''
191
+ || status.credential_type !== 'active'
192
+ || (status.expires_in !== null
193
+ && (typeof status.expires_in !== 'number'
194
+ || !Number.isFinite(status.expires_in)
195
+ || status.expires_in < 0))
196
+ // A null/absent `room` is valid when the account has no eligible
197
+ // private delivery room (and on older servers). Only a room that is
198
+ // present but malformed is a broken response.
199
+ || (status.room != null
200
+ && (typeof status.room.invite_code !== 'string'
201
+ || status.room.invite_code.trim() === ''))
202
+ ) {
203
+ throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
204
+ code: 'pairing_invalid_response',
205
+ body: { status: status.status },
206
+ });
207
+ }
208
+ this.http.setToken(status.credential);
209
+ // Pairing is server-authoritative. Some servers still include their
210
+ // internal grant marker for protocol compatibility; it is neither a
211
+ // client input nor part of the paired credential surface.
212
+ const { status: _status, scopes: _serverScopes, links: rawLinks, ...credential } = status;
213
+ const links = normalizePairingLinks(rawLinks);
214
+ return links ? { ...credential, links } : credential;
215
+ }
216
+ if (status.status === 'expired') {
217
+ throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
218
+ code: 'pairing_expired',
219
+ });
220
+ }
221
+ } catch (error) {
222
+ if (
223
+ error instanceof PingRoomError
224
+ && [401, 403, 404].includes(error.status)
225
+ && Date.now() - startedAt >= lifetimeMs / 2
226
+ ) {
227
+ // The pending bearer can expire before the server returns an
228
+ // explicit expired pairing state. Keep early auth failures intact.
229
+ throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
230
+ code: 'pairing_expired',
231
+ });
232
+ }
233
+ if (
234
+ error instanceof PingRoomError
235
+ && (error.code === 'network_error'
236
+ || error.code === 'timeout'
237
+ || error.status === 429
238
+ || error.status >= 500)
239
+ ) {
240
+ transientFailures += 1;
241
+ const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
242
+ await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
243
+ continue;
244
+ }
245
+ throw error;
246
+ }
247
+
248
+ await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
249
+ }
250
+
251
+ throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
252
+ code: 'pairing_expired',
253
+ });
254
+ }
255
+
256
+ /** Rotate to a fresh credential (same scopes/handle). */
257
+ refresh(): Promise<Credential> {
258
+ return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
259
+ }
260
+
261
+ /** Revoke the current credential. */
262
+ revoke(): Promise<void> {
263
+ return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
264
+ }
265
+
266
+ /**
267
+ * What this credential is connected to: the robot, the owner's public id and
268
+ * name, the home room and room memberships, and the room grant. Check
269
+ * `owner.id` and `handle` against the account and robot you approved before
270
+ * sending anything.
271
+ */
272
+ connection(): Promise<AgentConnectionInfo> {
273
+ return this.http.request('GET', '/api/agent/connection');
274
+ }
275
+
276
+ /**
277
+ * Disconnect this agent: revokes the calling credential and its refresh
278
+ * credentials. The client keeps the now-revoked token; clear it with
279
+ * `setToken(null)` or pair again.
280
+ */
281
+ disconnect(): Promise<AgentDisconnectResult> {
282
+ return this.http.request('POST', '/api/agent/disconnect', { body: {} });
283
+ }
284
+ }
@@ -0,0 +1,21 @@
1
+ // `pr.directory`: the public agent directory.
2
+
3
+ import type { HttpClient } from '../http.js';
4
+ import type { DirectoryEntry, DirectoryListInput, DirectoryProfile, Paginated } from '../types.js';
5
+ import { enc, dropUndefined } from '../internal/request.js';
6
+
7
+ /** Public, unauthenticated agent directory. */
8
+ export class DirectoryApi {
9
+ constructor(private readonly http: HttpClient) {}
10
+
11
+ list(input: DirectoryListInput = {}): Promise<Paginated<DirectoryEntry>> {
12
+ return this.http.request('GET', '/api/agents/directory', {
13
+ auth: false,
14
+ query: dropUndefined({ ...input }),
15
+ });
16
+ }
17
+
18
+ get(handle: string): Promise<DirectoryProfile> {
19
+ return this.http.request('GET', `/api/agents/directory/${enc(handle)}`, { auth: false });
20
+ }
21
+ }
@@ -0,0 +1,160 @@
1
+ // `pr.handoffs`: the direct, private agent-to-human loop.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+ import type { HttpClient } from '../http.js';
5
+ import {
6
+ assertStructuredData,
7
+ normalizeIdempotencyKey,
8
+ requireNonEmpty,
9
+ } from '../internal/guards.js';
10
+ import { pollUntilSettled } from '../internal/retry.js';
11
+ import type {
12
+ AskInput,
13
+ Handoff,
14
+ HandoffOptionInput,
15
+ HandoffOptionSpec,
16
+ ListHandoffsInput,
17
+ RequestAckInput,
18
+ WaitHandoffInput,
19
+ WaitForOutcomeOptions,
20
+ } from '../types.js';
21
+ import {
22
+ MIN_HANDOFF_POLL_INTERVAL_MS,
23
+ enc,
24
+ assertOnRetry,
25
+ dropUndefined,
26
+ waitTimeoutMs,
27
+ } from '../internal/request.js';
28
+ import { isRecord, sameId, invalidWaitResponse } from '../internal/validate.js';
29
+
30
+ const HANDOFF_STATES: ReadonlySet<string> = new Set(['open', 'acked', 'pending', 'answered', 'expired', 'cancelled']);
31
+
32
+ function validateHandoffWait(value: unknown, handoffId: string): Handoff {
33
+ const state = isRecord(value) ? value['state'] : undefined;
34
+ if (!isRecord(value) || !sameId(value['id'], handoffId) || typeof state !== 'string' || !HANDOFF_STATES.has(state)) {
35
+ return invalidWaitResponse('handoff', handoffId, value, 'state');
36
+ }
37
+ return value as unknown as Handoff;
38
+ }
39
+
40
+ /** A bare string option is shorthand for `{ value, label: value }`. */
41
+ function normalizeHandoffOptions(options: HandoffOptionSpec[]): HandoffOptionInput[] {
42
+ return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
43
+ }
44
+
45
+ /** Terminal states never change again — the wait loop can stop as soon as one lands. */
46
+ function isHandoffTerminal(handoff: Handoff): boolean {
47
+ const s = handoff.state;
48
+ return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
49
+ }
50
+
51
+ /**
52
+ * The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
53
+ * human) and PRIVATE (no other room member can read it). Backed by the ack and
54
+ * question primitives, so its {@link Handoff.state | state} space is their union
55
+ * — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
56
+ * question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
57
+ *
58
+ * const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
59
+ * const result = await pr.handoffs.waitForResult(task.id);
60
+ *
61
+ * const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
62
+ */
63
+ export class HandoffsApi {
64
+ constructor(private readonly http: HttpClient) {}
65
+
66
+ /** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
67
+ requestAck(input: RequestAckInput): Promise<Handoff> {
68
+ requireNonEmpty(input.prompt, 'prompt');
69
+ assertStructuredData(input.data);
70
+ return this.create('ack', input);
71
+ }
72
+
73
+ /**
74
+ * Ask a human a multi-option question (a `question` handoff). Resolves on the
75
+ * first tapped answer. Bare-string options normalize to `{ value, label }`.
76
+ */
77
+ ask(input: AskInput): Promise<Handoff> {
78
+ requireNonEmpty(input.prompt, 'prompt');
79
+ assertStructuredData(input.data);
80
+ if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
81
+ throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
82
+ code: 'invalid_request',
83
+ });
84
+ }
85
+ return this.create('question', input, normalizeHandoffOptions(input.options));
86
+ }
87
+
88
+ /** Fetch a handoff's current state. */
89
+ get(id: string): Promise<Handoff> {
90
+ requireNonEmpty(id, 'id');
91
+ return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
92
+ }
93
+
94
+ /**
95
+ * Block until the handoff resolves (or expires/cancels), looping the bounded
96
+ * long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
97
+ * `hold`) all surface as normal terminal results — nothing is thrown for them.
98
+ *
99
+ * Polls start at least 2.1 s apart, so a short `timeout` stays under the
100
+ * route's 30-per-minute limit. A response that is not this handoff in a
101
+ * known state rejects with `invalid_response`. Network failures, 429 and 5xx
102
+ * are retried with backoff (honouring `retryAfter`), and reported to
103
+ * `onRetry`, until `signal` aborts or the outage outlasts the handoff's
104
+ * `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
105
+ * been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
106
+ */
107
+ async waitForResult(id: string, input: WaitHandoffInput & WaitForOutcomeOptions = {}): Promise<Handoff> {
108
+ requireNonEmpty(id, 'id');
109
+ assertOnRetry(input.onRetry);
110
+ return pollUntilSettled({
111
+ poll: () => this.wait(id, input),
112
+ validate: (value) => validateHandoffWait(value, id),
113
+ settled: isHandoffTerminal,
114
+ expiresAt: (handoff) => handoff.expires_at,
115
+ what: `handoff ${id}`,
116
+ minIntervalMs: MIN_HANDOFF_POLL_INTERVAL_MS,
117
+ ...(input.signal ? { signal: input.signal } : {}),
118
+ ...(input.onRetry ? { onRetry: input.onRetry } : {}),
119
+ });
120
+ }
121
+
122
+ /** List the agent's open handoffs or bounded recent history. */
123
+ async list(input: ListHandoffsInput = {}): Promise<Handoff[]> {
124
+ const res = await this.http.request<{ handoffs: Handoff[] }>('GET', '/api/agent/handoffs', {
125
+ query: dropUndefined({ state: input.state }),
126
+ });
127
+ return res.handoffs ?? [];
128
+ }
129
+
130
+ /** One bounded long-poll; returns the (possibly still-open) handoff. */
131
+ private wait(id: string, input: WaitHandoffInput): Promise<Handoff> {
132
+ return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
133
+ query: dropUndefined({ timeout: input.timeout }),
134
+ timeoutMs: waitTimeoutMs(input.timeout),
135
+ ...(input.signal ? { signal: input.signal } : {}),
136
+ });
137
+ }
138
+
139
+ private create(
140
+ kind: 'ack' | 'question',
141
+ input: RequestAckInput,
142
+ options?: HandoffOptionInput[],
143
+ ): Promise<Handoff> {
144
+ const body = dropUndefined({
145
+ kind,
146
+ prompt: input.prompt,
147
+ audience: { type: 'direct', user_id: input.target ?? 'me' },
148
+ expires_in: input.expiresIn,
149
+ urgency: input.urgency,
150
+ options,
151
+ correlation_id: input.correlationId,
152
+ reply_to: input.replyTo,
153
+ data: input.data,
154
+ });
155
+ return this.http.request('POST', '/api/agent/handoffs', {
156
+ body,
157
+ ...(input.idempotencyKey ? { headers: { 'Idempotency-Key': normalizeIdempotencyKey(input.idempotencyKey) } } : {}),
158
+ });
159
+ }
160
+ }