@pingroom/sdk 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +339 -12
- package/dist/client.d.ts +130 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +517 -2
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts +7 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +29 -5
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/linkPing.d.ts +27 -0
- package/dist/linkPing.d.ts.map +1 -0
- package/dist/linkPing.js +39 -0
- package/dist/linkPing.js.map +1 -0
- package/dist/liveActivity.d.ts +101 -0
- package/dist/liveActivity.d.ts.map +1 -0
- package/dist/liveActivity.js +83 -0
- package/dist/liveActivity.js.map +1 -0
- package/dist/liveStatus.d.ts +168 -0
- package/dist/liveStatus.d.ts.map +1 -0
- package/dist/liveStatus.js +82 -0
- package/dist/liveStatus.js.map +1 -0
- package/dist/mcp.d.ts +23 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +54 -6
- package/dist/mcp.js.map +1 -1
- package/dist/types.d.ts +292 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +53 -6
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +93 -25
- package/dist/webhooks.js.map +1 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
|
|
1
2
|
import { HttpClient } from './http.js';
|
|
2
|
-
import { sleep } from './internal/async.js';
|
|
3
|
+
import { combineSignals, sleep } from './internal/async.js';
|
|
3
4
|
import { assertActionNumber, assertStructuredData, requireNonEmpty } from './internal/guards.js';
|
|
4
5
|
import { McpClient } from './mcp.js';
|
|
5
6
|
const DEFAULT_BASE_URL = 'https://api.pingroom.io';
|
|
7
|
+
const DEFAULT_PAIRING_SCOPES = [
|
|
8
|
+
'pingroom:rooms:read',
|
|
9
|
+
'pingroom:broadcast:send',
|
|
10
|
+
'pingroom:handoffs:create',
|
|
11
|
+
];
|
|
6
12
|
/** Extra wall-clock allowance over the server's hold window for long-poll calls. */
|
|
7
13
|
const LONG_POLL_BUFFER_MS = 10_000;
|
|
8
14
|
const DEFAULT_WAIT_SECONDS = 20;
|
|
15
|
+
const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
|
|
16
|
+
const MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS = 2100;
|
|
9
17
|
function envBaseUrl() {
|
|
10
18
|
const proc = globalThis.process;
|
|
11
19
|
return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
|
|
@@ -30,6 +38,101 @@ function assertPing(ping) {
|
|
|
30
38
|
function waitTimeoutMs(seconds) {
|
|
31
39
|
return (seconds ?? DEFAULT_WAIT_SECONDS) * 1000 + LONG_POLL_BUFFER_MS;
|
|
32
40
|
}
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function isNonEmptyString(value) {
|
|
45
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
46
|
+
}
|
|
47
|
+
function isNullableString(value) {
|
|
48
|
+
return value === null || typeof value === 'string';
|
|
49
|
+
}
|
|
50
|
+
function invalidInboxResponse(message, body = {}) {
|
|
51
|
+
throw new PingRoomError(message, { code: 'inbox_invalid_response', body });
|
|
52
|
+
}
|
|
53
|
+
function validateInboxEnsure(value) {
|
|
54
|
+
if (!isRecord(value) || value['onboarded'] !== true || typeof value['replayed'] !== 'boolean') {
|
|
55
|
+
return invalidInboxResponse('PingRoom returned an invalid Agent Inbox ensure response.');
|
|
56
|
+
}
|
|
57
|
+
const room = value['room'];
|
|
58
|
+
const question = value['question'];
|
|
59
|
+
if (!isRecord(room)
|
|
60
|
+
|| !isNonEmptyString(room['id'])
|
|
61
|
+
|| typeof room['name'] !== 'string'
|
|
62
|
+
|| !isNonEmptyString(room['invite_code'])
|
|
63
|
+
|| typeof room['is_agent_inbox'] !== 'boolean'
|
|
64
|
+
|| !isRecord(question)
|
|
65
|
+
|| !isNonEmptyString(question['id'])
|
|
66
|
+
|| question['kind'] !== 'question'
|
|
67
|
+
|| !isNonEmptyString(question['prompt'])
|
|
68
|
+
|| !Array.isArray(question['options'])
|
|
69
|
+
|| question['options'].some((option) => (!isRecord(option)
|
|
70
|
+
|| !isNonEmptyString(option['value'])
|
|
71
|
+
|| !isNonEmptyString(option['label'])))
|
|
72
|
+
|| (question['state'] !== 'pending'
|
|
73
|
+
&& question['state'] !== 'answered'
|
|
74
|
+
&& question['state'] !== 'expired'
|
|
75
|
+
&& question['state'] !== 'cancelled')
|
|
76
|
+
|| !isNullableString(question['expires_at'])
|
|
77
|
+
|| !isNullableString(question['created_at'])) {
|
|
78
|
+
return invalidInboxResponse('PingRoom returned an incomplete Agent Inbox ensure response.');
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function validateInboxWait(value, expectedId) {
|
|
83
|
+
if (!isRecord(value)) {
|
|
84
|
+
return invalidInboxResponse('PingRoom returned an invalid Agent Inbox wait response.');
|
|
85
|
+
}
|
|
86
|
+
const state = value['state'];
|
|
87
|
+
if (!isNonEmptyString(value['id'])
|
|
88
|
+
|| value['id'] !== expectedId
|
|
89
|
+
|| value['kind'] !== 'question'
|
|
90
|
+
|| (state !== 'pending' && state !== 'answered' && state !== 'expired' && state !== 'cancelled')
|
|
91
|
+
|| (value['activation_completed'] !== undefined && typeof value['activation_completed'] !== 'boolean')
|
|
92
|
+
|| (state !== 'answered' && value['activation_completed'] === true)) {
|
|
93
|
+
return invalidInboxResponse('PingRoom returned a mismatched Agent Inbox wait response.', {
|
|
94
|
+
expected_id: expectedId,
|
|
95
|
+
received_id: typeof value['id'] === 'string' ? value['id'] : null,
|
|
96
|
+
kind: typeof value['kind'] === 'string' ? value['kind'] : null,
|
|
97
|
+
state: typeof state === 'string' ? state : null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (state === 'answered') {
|
|
101
|
+
const answer = value['answer'];
|
|
102
|
+
if (!isRecord(answer)
|
|
103
|
+
|| !isNullableString(answer['value'])
|
|
104
|
+
|| !isNullableString(answer['label'])
|
|
105
|
+
|| !isNullableString(answer['text'])
|
|
106
|
+
|| (!isNonEmptyString(answer['value']) && !isNonEmptyString(answer['text']))
|
|
107
|
+
|| !isNullableString(answer['answered_at'])
|
|
108
|
+
|| (answer['responder'] !== null && !isRecord(answer['responder']))) {
|
|
109
|
+
return invalidInboxResponse('PingRoom returned an answered activation without a valid answer.', {
|
|
110
|
+
id: expectedId,
|
|
111
|
+
state,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const responder = answer['responder'];
|
|
115
|
+
if (isRecord(responder)
|
|
116
|
+
&& (!isNullableString(responder['id']) || !isNullableString(responder['display_name']))) {
|
|
117
|
+
return invalidInboxResponse('PingRoom returned an invalid activation responder.', {
|
|
118
|
+
id: expectedId,
|
|
119
|
+
state,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (value['answer'] !== undefined && value['answer'] !== null) {
|
|
124
|
+
return invalidInboxResponse('PingRoom returned an answer for an unresolved activation.', {
|
|
125
|
+
id: expectedId,
|
|
126
|
+
state,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function throwIfAborted(signal) {
|
|
132
|
+
if (signal.aborted) {
|
|
133
|
+
throw signal.reason ?? new Error('aborted');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
33
136
|
// --- namespaces -----------------------------------------------------------
|
|
34
137
|
/** Agent credential lifecycle (auth.md flow). */
|
|
35
138
|
class AuthApi {
|
|
@@ -53,6 +156,107 @@ class AuthApi {
|
|
|
53
156
|
requireNonEmpty(params.otp, 'otp');
|
|
54
157
|
return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
|
|
55
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Register a pending agent and create a short-lived link that opens the
|
|
161
|
+
* PingRoom app. The person approving it chooses the delivery room and sees
|
|
162
|
+
* the exact requested scopes. The pending credential stays private inside
|
|
163
|
+
* this client and is replaced automatically once waitForPairing() succeeds.
|
|
164
|
+
*/
|
|
165
|
+
async startPairing(params = {}) {
|
|
166
|
+
const scopes = params.scopes ?? [...DEFAULT_PAIRING_SCOPES];
|
|
167
|
+
const previousToken = this.http.getToken();
|
|
168
|
+
const pending = await this.register({
|
|
169
|
+
type: 'anonymous',
|
|
170
|
+
scopes,
|
|
171
|
+
...(params.agent_label ? { agent_label: params.agent_label } : {}),
|
|
172
|
+
});
|
|
173
|
+
this.http.setToken(pending.credential);
|
|
174
|
+
try {
|
|
175
|
+
return await this.http.request('POST', '/api/agent/auth/pair/start', {
|
|
176
|
+
body: { scopes },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (this.http.getToken() === pending.credential) {
|
|
181
|
+
this.http.setToken(previousToken);
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Read the current state of an app pairing using the pending credential. */
|
|
187
|
+
pairingStatus(options = {}) {
|
|
188
|
+
return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Wait until the person approves in PingRoom, or the short-lived link
|
|
192
|
+
* expires. Transient network, 429, and 5xx failures are retried inside the
|
|
193
|
+
* server-provided lifetime. On success the client immediately starts using
|
|
194
|
+
* the active credential returned by the server.
|
|
195
|
+
*/
|
|
196
|
+
async waitForPairing(pairing, options = {}) {
|
|
197
|
+
const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
|
|
198
|
+
const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
|
|
199
|
+
const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
|
|
200
|
+
const deadline = Date.now() + lifetimeMs;
|
|
201
|
+
let transientFailures = 0;
|
|
202
|
+
while (Date.now() < deadline) {
|
|
203
|
+
this.throwIfPairingAborted(options.signal);
|
|
204
|
+
try {
|
|
205
|
+
const status = await this.pairingStatus({ signal: options.signal });
|
|
206
|
+
this.throwIfPairingAborted(options.signal);
|
|
207
|
+
transientFailures = 0;
|
|
208
|
+
if (status.status === 'active') {
|
|
209
|
+
if (typeof status.credential !== 'string'
|
|
210
|
+
|| status.credential.trim() === ''
|
|
211
|
+
|| status.credential_type !== 'active'
|
|
212
|
+
|| !Array.isArray(status.scopes)
|
|
213
|
+
|| status.scopes.some((scope) => typeof scope !== 'string' || scope.trim() === '')
|
|
214
|
+
|| (status.expires_in !== null
|
|
215
|
+
&& (typeof status.expires_in !== 'number'
|
|
216
|
+
|| !Number.isFinite(status.expires_in)
|
|
217
|
+
|| status.expires_in < 0))
|
|
218
|
+
|| !status.room
|
|
219
|
+
|| typeof status.room.invite_code !== 'string'
|
|
220
|
+
|| status.room.invite_code.trim() === '') {
|
|
221
|
+
throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
|
|
222
|
+
code: 'pairing_invalid_response',
|
|
223
|
+
body: { status: status.status },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
this.http.setToken(status.credential);
|
|
227
|
+
const { status: _status, ...credential } = status;
|
|
228
|
+
return credential;
|
|
229
|
+
}
|
|
230
|
+
if (status.status === 'expired') {
|
|
231
|
+
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
232
|
+
code: 'pairing_expired',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (error instanceof PingRoomError
|
|
238
|
+
&& (error.code === 'network_error'
|
|
239
|
+
|| error.code === 'timeout'
|
|
240
|
+
|| error.status === 429
|
|
241
|
+
|| error.status >= 500)) {
|
|
242
|
+
transientFailures += 1;
|
|
243
|
+
const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
|
|
244
|
+
await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
|
|
250
|
+
}
|
|
251
|
+
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
252
|
+
code: 'pairing_expired',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
throwIfPairingAborted(signal) {
|
|
256
|
+
if (signal?.aborted) {
|
|
257
|
+
throw signal.reason ?? new Error('aborted');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
56
260
|
/** Rotate to a fresh credential (same scopes/handle). */
|
|
57
261
|
refresh() {
|
|
58
262
|
return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
|
|
@@ -119,6 +323,23 @@ class NotificationsApi {
|
|
|
119
323
|
list(input = {}) {
|
|
120
324
|
return this.http.request('GET', '/api/agent/notifications', { query: dropUndefined({ ...input }) });
|
|
121
325
|
}
|
|
326
|
+
/** Fetch one visible notification, including its current acknowledgement state. */
|
|
327
|
+
getNotification(notificationId) {
|
|
328
|
+
requireNonEmpty(notificationId, 'notificationId');
|
|
329
|
+
return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}`);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* One bounded long-poll for an ack-required ping. The returned state may
|
|
333
|
+
* still be `open` when the hold timeout elapses; call again to keep waiting.
|
|
334
|
+
*/
|
|
335
|
+
waitForAcknowledgement(notificationId, input = {}) {
|
|
336
|
+
requireNonEmpty(notificationId, 'notificationId');
|
|
337
|
+
return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}/ack/wait`, {
|
|
338
|
+
query: dropUndefined({ timeout: input.timeout }),
|
|
339
|
+
timeoutMs: waitTimeoutMs(input.timeout),
|
|
340
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
122
343
|
/** One long-poll: resolves as soon as new pings land, or with an empty list at timeout. */
|
|
123
344
|
wait(input = {}) {
|
|
124
345
|
return this.http.request('GET', '/api/agent/notifications/wait', {
|
|
@@ -176,7 +397,11 @@ class AgentsApi {
|
|
|
176
397
|
constructor(http) {
|
|
177
398
|
this.http = http;
|
|
178
399
|
}
|
|
179
|
-
/**
|
|
400
|
+
/**
|
|
401
|
+
* @deprecated Retired server-side — always fails with 410
|
|
402
|
+
* `cross_account_ping_retired`. An agent can only reach the account that
|
|
403
|
+
* connected it; to reach anyone else, post into a room you both belong to.
|
|
404
|
+
*/
|
|
180
405
|
ping(handle, ping) {
|
|
181
406
|
requireNonEmpty(handle, 'handle');
|
|
182
407
|
assertPing(ping);
|
|
@@ -279,6 +504,192 @@ class QuestionsApi {
|
|
|
279
504
|
return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
|
|
280
505
|
}
|
|
281
506
|
}
|
|
507
|
+
/** A bare string option is shorthand for `{ value, label: value }`. */
|
|
508
|
+
function normalizeHandoffOptions(options) {
|
|
509
|
+
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
510
|
+
}
|
|
511
|
+
/** Terminal states never change again — the wait loop can stop as soon as one lands. */
|
|
512
|
+
function isHandoffTerminal(handoff) {
|
|
513
|
+
const s = handoff.state;
|
|
514
|
+
return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
|
|
518
|
+
* human) and PRIVATE (no other room member can read it). Backed by the ack and
|
|
519
|
+
* question primitives, so its {@link Handoff.state | state} space is their union
|
|
520
|
+
* — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
|
|
521
|
+
* question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
|
|
522
|
+
*
|
|
523
|
+
* const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
|
|
524
|
+
* const result = await pr.handoffs.waitForResult(task.id);
|
|
525
|
+
*
|
|
526
|
+
* const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
|
|
527
|
+
*/
|
|
528
|
+
class HandoffsApi {
|
|
529
|
+
http;
|
|
530
|
+
constructor(http) {
|
|
531
|
+
this.http = http;
|
|
532
|
+
}
|
|
533
|
+
/** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
|
|
534
|
+
requestAck(input) {
|
|
535
|
+
requireNonEmpty(input.prompt, 'prompt');
|
|
536
|
+
assertStructuredData(input.data);
|
|
537
|
+
return this.create('ack', input);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Ask a human a multi-option question (a `question` handoff). Resolves on the
|
|
541
|
+
* first tapped answer. Bare-string options normalize to `{ value, label }`.
|
|
542
|
+
*/
|
|
543
|
+
ask(input) {
|
|
544
|
+
requireNonEmpty(input.prompt, 'prompt');
|
|
545
|
+
assertStructuredData(input.data);
|
|
546
|
+
if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
|
|
547
|
+
throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
|
|
548
|
+
code: 'invalid_request',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
return this.create('question', input, normalizeHandoffOptions(input.options));
|
|
552
|
+
}
|
|
553
|
+
/** Fetch a handoff's current state. */
|
|
554
|
+
get(id) {
|
|
555
|
+
requireNonEmpty(id, 'id');
|
|
556
|
+
return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Block until the handoff resolves (or expires/cancels), looping the bounded
|
|
560
|
+
* long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
|
|
561
|
+
* `hold`) all surface as normal terminal results — nothing is thrown for them.
|
|
562
|
+
*/
|
|
563
|
+
async waitForResult(id, input = {}) {
|
|
564
|
+
requireNonEmpty(id, 'id');
|
|
565
|
+
for (;;) {
|
|
566
|
+
if (input.signal?.aborted) {
|
|
567
|
+
throw input.signal.reason ?? new Error('aborted');
|
|
568
|
+
}
|
|
569
|
+
const handoff = await this.wait(id, input);
|
|
570
|
+
if (isHandoffTerminal(handoff)) {
|
|
571
|
+
return handoff;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** List the agent's open handoffs or bounded recent history. */
|
|
576
|
+
async list(input = {}) {
|
|
577
|
+
const res = await this.http.request('GET', '/api/agent/handoffs', {
|
|
578
|
+
query: dropUndefined({ state: input.state }),
|
|
579
|
+
});
|
|
580
|
+
return res.handoffs ?? [];
|
|
581
|
+
}
|
|
582
|
+
/** One bounded long-poll; returns the (possibly still-open) handoff. */
|
|
583
|
+
wait(id, input) {
|
|
584
|
+
return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
|
|
585
|
+
query: dropUndefined({ timeout: input.timeout }),
|
|
586
|
+
timeoutMs: waitTimeoutMs(input.timeout),
|
|
587
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
create(kind, input, options) {
|
|
591
|
+
const body = dropUndefined({
|
|
592
|
+
kind,
|
|
593
|
+
prompt: input.prompt,
|
|
594
|
+
audience: { type: 'direct', user_id: input.target ?? 'me' },
|
|
595
|
+
expires_in: input.expiresIn,
|
|
596
|
+
urgency: input.urgency,
|
|
597
|
+
options,
|
|
598
|
+
correlation_id: input.correlationId,
|
|
599
|
+
reply_to: input.replyTo,
|
|
600
|
+
data: input.data,
|
|
601
|
+
});
|
|
602
|
+
return this.http.request('POST', '/api/agent/handoffs', {
|
|
603
|
+
body,
|
|
604
|
+
...(input.idempotencyKey ? { headers: { 'Idempotency-Key': input.idempotencyKey } } : {}),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** Setup and activation of the bound human's Agent Inbox delivery room. */
|
|
609
|
+
class InboxApi {
|
|
610
|
+
http;
|
|
611
|
+
constructor(http) {
|
|
612
|
+
this.http = http;
|
|
613
|
+
}
|
|
614
|
+
/** Replay the viable activation attempt or create its next numbered retry. */
|
|
615
|
+
ensure(input = {}) {
|
|
616
|
+
return this.http.request('POST', '/api/agent/inbox/ensure', {
|
|
617
|
+
body: {},
|
|
618
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Ensure the bound human's Agent Inbox, then block on its onboarding
|
|
623
|
+
* Question until the server confirms activation, it expires/cancels, or the
|
|
624
|
+
* overall deadline elapses. Pairing remains a separate operation so adopting
|
|
625
|
+
* a credential never silently waits for a phone response.
|
|
626
|
+
*/
|
|
627
|
+
async activate(input = {}) {
|
|
628
|
+
const overallTimeoutMs = input.overallTimeoutMs ?? DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS;
|
|
629
|
+
if (!Number.isFinite(overallTimeoutMs) || overallTimeoutMs < 0) {
|
|
630
|
+
throw new PingRoomError('`overallTimeoutMs` must be a non-negative finite number.', {
|
|
631
|
+
code: 'invalid_request',
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const deadlineError = () => (new PingRoomActivationIncompleteError('deadline_exceeded', 'Agent Inbox activation did not complete before the overall deadline.', { overall_timeout_ms: overallTimeoutMs }));
|
|
635
|
+
const deadlineController = new AbortController();
|
|
636
|
+
const deadlineTimer = overallTimeoutMs === 0
|
|
637
|
+
? null
|
|
638
|
+
: setTimeout(() => deadlineController.abort(deadlineError()), overallTimeoutMs);
|
|
639
|
+
if (overallTimeoutMs === 0)
|
|
640
|
+
deadlineController.abort(deadlineError());
|
|
641
|
+
const signal = input.signal
|
|
642
|
+
? combineSignals(input.signal, deadlineController.signal)
|
|
643
|
+
: deadlineController.signal;
|
|
644
|
+
try {
|
|
645
|
+
throwIfAborted(signal);
|
|
646
|
+
const ensured = validateInboxEnsure(await this.ensure({ signal }));
|
|
647
|
+
const questionId = ensured.question.id;
|
|
648
|
+
for (;;) {
|
|
649
|
+
throwIfAborted(signal);
|
|
650
|
+
const pollStartedAt = Date.now();
|
|
651
|
+
const question = validateInboxWait(await this.http.request('GET', `/api/agent/handoffs/${enc(questionId)}/wait`, {
|
|
652
|
+
query: dropUndefined({ timeout: input.timeout }),
|
|
653
|
+
timeoutMs: waitTimeoutMs(input.timeout),
|
|
654
|
+
signal,
|
|
655
|
+
}), questionId);
|
|
656
|
+
throwIfAborted(signal);
|
|
657
|
+
if (question.state === 'pending') {
|
|
658
|
+
await sleep(Math.max(0, MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (question.state === 'answered' && question.activation_completed === true) {
|
|
662
|
+
return {
|
|
663
|
+
...ensured,
|
|
664
|
+
activation_completed: true,
|
|
665
|
+
question: question,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (question.state === 'answered') {
|
|
669
|
+
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 });
|
|
670
|
+
}
|
|
671
|
+
if (question.state === 'expired' || question.state === 'cancelled') {
|
|
672
|
+
throw new PingRoomActivationIncompleteError(question.state, `The onboarding Question ${question.state} before Agent Inbox activation completed.`, { question_id: questionId, activation_completed: question.activation_completed ?? null });
|
|
673
|
+
}
|
|
674
|
+
return invalidInboxResponse('PingRoom returned an invalid terminal activation state.', {
|
|
675
|
+
id: questionId,
|
|
676
|
+
state: question.state,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
// A custom fetch implementation may reject with a generic AbortError
|
|
682
|
+
// instead of the signal's richer reason. Preserve caller cancellation
|
|
683
|
+
// and the activation-specific deadline error at this public boundary.
|
|
684
|
+
throwIfAborted(signal);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
if (deadlineTimer !== null)
|
|
689
|
+
clearTimeout(deadlineTimer);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
282
693
|
class ProfileApi {
|
|
283
694
|
http;
|
|
284
695
|
constructor(http) {
|
|
@@ -294,6 +705,102 @@ class ProfileApi {
|
|
|
294
705
|
return this.http.request('POST', '/api/agent/profile/handle/rotate', { body: {} });
|
|
295
706
|
}
|
|
296
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Live-status streams — a self-updating card on the room members' lock screen.
|
|
710
|
+
*
|
|
711
|
+
* Requires the `pingroom:live:write` scope. Free accounts get a small daily
|
|
712
|
+
* budget of NEW streams; updates and the terminal ping are never charged, so a
|
|
713
|
+
* card can never be quota-blocked into hanging open.
|
|
714
|
+
*/
|
|
715
|
+
class LiveApi {
|
|
716
|
+
http;
|
|
717
|
+
constructor(http) {
|
|
718
|
+
this.http = http;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Send one stream ping — start, update, or end, chosen by `live_status.state`
|
|
722
|
+
* and whether the correlation already exists.
|
|
723
|
+
*/
|
|
724
|
+
push(inviteCode, ping) {
|
|
725
|
+
requireNonEmpty(inviteCode, 'inviteCode');
|
|
726
|
+
requireNonEmpty(ping.correlation_id, 'correlation_id');
|
|
727
|
+
assertStructuredData(ping.data);
|
|
728
|
+
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/live`, { body: ping });
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Read back a stream this credential started, so a restarted producer can
|
|
732
|
+
* reconcile instead of opening a duplicate. Returns null when there is no
|
|
733
|
+
* such stream in the last 24 hours.
|
|
734
|
+
*/
|
|
735
|
+
async get(inviteCode, correlationId) {
|
|
736
|
+
requireNonEmpty(inviteCode, 'inviteCode');
|
|
737
|
+
requireNonEmpty(correlationId, 'correlationId');
|
|
738
|
+
try {
|
|
739
|
+
return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
if (error instanceof PingRoomError && error.status === 404)
|
|
743
|
+
return null;
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Private files that a ping can carry.
|
|
750
|
+
*
|
|
751
|
+
* Upload first, then pass the returned ids to `broadcast()` / `questions.ask()`
|
|
752
|
+
* as `attachment_ids`. Bytes never travel over MCP or a JSON ping body. An
|
|
753
|
+
* upload that is never attached expires on its own after 24 hours.
|
|
754
|
+
*
|
|
755
|
+
* Uploading requires the bound account to hold Pro (the API answers 402
|
|
756
|
+
* `pro_required` otherwise). Reading and deleting are not gated.
|
|
757
|
+
*/
|
|
758
|
+
class AttachmentsApi {
|
|
759
|
+
http;
|
|
760
|
+
constructor(http) {
|
|
761
|
+
this.http = http;
|
|
762
|
+
}
|
|
763
|
+
/** Upload one file and get back the id a send can claim. */
|
|
764
|
+
upload(input) {
|
|
765
|
+
requireNonEmpty(input.filename, 'filename');
|
|
766
|
+
const body = new FormData();
|
|
767
|
+
body.append('file', toAttachmentBlob(input), input.filename);
|
|
768
|
+
return this.http
|
|
769
|
+
.request('POST', '/api/agent/attachments', {
|
|
770
|
+
body,
|
|
771
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
772
|
+
})
|
|
773
|
+
.then((res) => res.attachment);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Raw bytes for an attachment this credential is allowed to see. Returns the
|
|
777
|
+
* undecoded Response so a caller can stream it, or read text/arrayBuffer.
|
|
778
|
+
*/
|
|
779
|
+
content(attachmentId, signal) {
|
|
780
|
+
requireNonEmpty(attachmentId, 'attachmentId');
|
|
781
|
+
return this.http.raw('GET', `/api/agent/attachments/${enc(attachmentId)}/content`, {
|
|
782
|
+
...(signal ? { signal } : {}),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
/** Discard an upload that has not been attached to a ping yet. */
|
|
786
|
+
delete(attachmentId) {
|
|
787
|
+
requireNonEmpty(attachmentId, 'attachmentId');
|
|
788
|
+
return this.http.request('DELETE', `/api/agent/attachments/${enc(attachmentId)}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/** Normalise the three accepted content shapes into one multipart part. */
|
|
792
|
+
function toAttachmentBlob(input) {
|
|
793
|
+
const type = input.contentType ?? '';
|
|
794
|
+
if (input.content instanceof Blob) {
|
|
795
|
+
return type ? new Blob([input.content], { type }) : input.content;
|
|
796
|
+
}
|
|
797
|
+
// Copy into a plain ArrayBuffer: a Uint8Array view may be a window onto a
|
|
798
|
+
// larger (or SharedArrayBuffer-backed) buffer, which Blob would not accept.
|
|
799
|
+
const parts = typeof input.content === 'string'
|
|
800
|
+
? [input.content]
|
|
801
|
+
: [new Uint8Array(input.content).slice().buffer];
|
|
802
|
+
return new Blob(parts, type ? { type } : undefined);
|
|
803
|
+
}
|
|
297
804
|
/** Public, unauthenticated agent directory. */
|
|
298
805
|
class DirectoryApi {
|
|
299
806
|
http;
|
|
@@ -316,9 +823,13 @@ export class PingRoom {
|
|
|
316
823
|
rooms;
|
|
317
824
|
actions;
|
|
318
825
|
notifications;
|
|
826
|
+
attachments;
|
|
319
827
|
agents;
|
|
320
828
|
approvals;
|
|
321
829
|
questions;
|
|
830
|
+
handoffs;
|
|
831
|
+
inbox;
|
|
832
|
+
live;
|
|
322
833
|
profile;
|
|
323
834
|
directory;
|
|
324
835
|
mcp;
|
|
@@ -337,9 +848,13 @@ export class PingRoom {
|
|
|
337
848
|
this.rooms = new RoomsApi(this.http);
|
|
338
849
|
this.actions = new ActionsApi(this.http);
|
|
339
850
|
this.notifications = new NotificationsApi(this.http);
|
|
851
|
+
this.attachments = new AttachmentsApi(this.http);
|
|
340
852
|
this.agents = new AgentsApi(this.http);
|
|
341
853
|
this.approvals = new ApprovalsApi(this.http);
|
|
342
854
|
this.questions = new QuestionsApi(this.http);
|
|
855
|
+
this.handoffs = new HandoffsApi(this.http);
|
|
856
|
+
this.inbox = new InboxApi(this.http);
|
|
857
|
+
this.live = new LiveApi(this.http);
|
|
343
858
|
this.profile = new ProfileApi(this.http);
|
|
344
859
|
this.directory = new DirectoryApi(this.http);
|
|
345
860
|
this.mcp = new McpClient(this.http);
|