@pingroom/cli 0.6.0 → 0.6.1
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 +111 -9
- package/bin/pingroom.js +618 -30
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -6,21 +6,51 @@ from CI, scripts, and agents. Delivered as push straight to your phone.
|
|
|
6
6
|
One dependency (`qrcode-terminal`, used only to draw the pairing QR). Works
|
|
7
7
|
anywhere Node ≥ 20 runs.
|
|
8
8
|
|
|
9
|
+
> **Release status:** npm currently serves 0.6.0. The automatic verified-phone
|
|
10
|
+
> activation flow and `pingroom activate` documented below are in the tested
|
|
11
|
+
> 0.6.1 release candidate on `main`; the public GitHub Action remains pinned to
|
|
12
|
+
> 0.6.0 until 0.6.1 is published and clean-install verified.
|
|
13
|
+
|
|
14
|
+
## Install and first run
|
|
15
|
+
|
|
16
|
+
Install globally, then connect:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install --global @pingroom/cli
|
|
20
|
+
pingroom
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or use it without a global install:
|
|
24
|
+
|
|
9
25
|
```bash
|
|
10
|
-
npx @pingroom/cli
|
|
26
|
+
npx --yes @pingroom/cli
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Either command starts the same connection prompt. QR pairing stores the selected
|
|
30
|
+
account, credential, and delivery room in `~/.pingroom`; later commands reuse
|
|
31
|
+
them, so a local invocation needs neither `PINGROOM_TOKEN` nor `PINGROOM_ROOM`.
|
|
32
|
+
The email fallback stores the credential only. Setting
|
|
33
|
+
`pingroom config set default_room <invite-code>` enables room-addressed commands,
|
|
34
|
+
but private Agent Inbox and Handoff delivery require a connection approved with
|
|
35
|
+
a delivery room. Use QR pairing for the complete flow.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pingroom ping -m "Deploy succeeded ✅"
|
|
39
|
+
# or: npx --yes @pingroom/cli ping -m "Deploy succeeded ✅"
|
|
11
40
|
```
|
|
12
41
|
|
|
13
42
|
Commands: `ping` (send), `ask` (ask a human), `watch` (block on an existing
|
|
14
43
|
question), `list`, `cancel`, `handoff` (hand a decision to a specific human),
|
|
15
44
|
`handoffs` (list open or recent Handoffs), `live` (lock-screen progress card),
|
|
16
|
-
`hook` (Claude Code), `
|
|
45
|
+
`hook` (Claude Code), `mcp` (client setup), `activate` (retry the Agent Inbox
|
|
46
|
+
test), `config`, and `logout`.
|
|
17
47
|
Run `pingroom --help` for the full reference.
|
|
18
48
|
|
|
19
49
|
## Connecting
|
|
20
50
|
|
|
21
|
-
Run `pingroom`
|
|
22
|
-
|
|
23
|
-
emailed-code fallback.
|
|
51
|
+
Run `pingroom` (global install) or `npx --yes @pingroom/cli` (no install) with no
|
|
52
|
+
arguments. It prints a QR code — scan it with the PingRoom app and pick both the
|
|
53
|
+
account and the room the agent delivers to — or take the emailed-code fallback.
|
|
24
54
|
|
|
25
55
|
```
|
|
26
56
|
$ pingroom
|
|
@@ -32,8 +62,38 @@ $ pingroom
|
|
|
32
62
|
[QR]
|
|
33
63
|
Or open: https://pingroom.io/app/agents/pair?token=…
|
|
34
64
|
Waiting for approval… ✓ Connected as @agt_ab12cd34ef → #Project X
|
|
65
|
+
Sending a test question to PingRoom…
|
|
66
|
+
Answer “PingRoom connected. Can you answer this?” on your phone.
|
|
67
|
+
✓ Test question answered (Yes). Agent Inbox is ready.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
After QR approval, the CLI saves the active credential first, then sends one
|
|
71
|
+
idempotent onboarding Question and observes it through the Handoff wait API.
|
|
72
|
+
Short network and server failures are retried, and `Retry-After` is honored for
|
|
73
|
+
rate limits within the two-minute overall deadline. An answer alone is not
|
|
74
|
+
reported as success: the terminal response must also carry the server's exact
|
|
75
|
+
`activation_completed: true` stamp. On supporting server and mobile builds,
|
|
76
|
+
that stamp means the native phone returned the opaque proof carried in the push
|
|
77
|
+
before the human answer, and this CLI then observed the result. An answered
|
|
78
|
+
response whose stamp is false or missing is incomplete and is not retried as if
|
|
79
|
+
history could be rewritten. If the Question is still pending when the local
|
|
80
|
+
deadline elapses, or activation cannot start, the CLI prints the recovery
|
|
81
|
+
command and exits with the connection still saved and usable. A terminal test
|
|
82
|
+
without the stamp requires a current PingRoom app and a fresh numbered attempt;
|
|
83
|
+
run `pingroom activate` again with the saved connection.
|
|
84
|
+
|
|
85
|
+
Resume an open idempotent check with the saved QR credential:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pingroom activate
|
|
35
89
|
```
|
|
36
90
|
|
|
91
|
+
An incomplete explicit retry exits `1`; it never deletes or replaces the saved
|
|
92
|
+
credential. The command does not fall back to `PINGROOM_TOKEN`, an email-only
|
|
93
|
+
credential, or a credential without `pingroom:handoffs:create` and a
|
|
94
|
+
QR-selected delivery room. Email-code and other non-interactive credential
|
|
95
|
+
flows do not run the phone-response loop automatically.
|
|
96
|
+
|
|
37
97
|
There is deliberately no `login` command: being unconnected is a state the tool
|
|
38
98
|
resolves, not one you have to discover. Once connected, bare `pingroom` prints
|
|
39
99
|
that status line followed by the usual help.
|
|
@@ -67,6 +127,11 @@ So `--room` beats `PINGROOM_ROOM` beats `default_room` (and, last of all, the
|
|
|
67
127
|
room the credential was paired to); `--api` beats `PINGROOM_API_URL` beats
|
|
68
128
|
`api_url`.
|
|
69
129
|
|
|
130
|
+
A stored paired credential is bound to the API origin that issued it. An API
|
|
131
|
+
override can change the path on that origin, but the CLI refuses to send the
|
|
132
|
+
stored bearer to a different origin. For an intentional custom-host override,
|
|
133
|
+
provide that host's token explicitly with `--token` or `PINGROOM_TOKEN`.
|
|
134
|
+
|
|
70
135
|
## Getting a webhook URL
|
|
71
136
|
|
|
72
137
|
In the PingRoom app, open a room → **Connections → Incoming webhooks → Add**. Copy the
|
|
@@ -85,6 +150,7 @@ pingroom ping [options]
|
|
|
85
150
|
--button-label <t> Link button text (<= 26 chars; requires --url)
|
|
86
151
|
--require-ack Keep the ping open until an eligible recipient acknowledges it
|
|
87
152
|
--ack-timeout <s> Ack deadline in seconds (requires --require-ack)
|
|
153
|
+
--attach <path> Attach a file; repeat for up to 10 (requires --token)
|
|
88
154
|
-w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
|
|
89
155
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
90
156
|
--room <code> Room invite code (used with --token)
|
|
@@ -115,6 +181,21 @@ pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \
|
|
|
115
181
|
|
|
116
182
|
The URL must be absolute http(s) (≤ 2048 chars); the label caps at 26 chars.
|
|
117
183
|
|
|
184
|
+
To send the file itself rather than a link to it, use `--attach`. Each file is
|
|
185
|
+
uploaded separately and only the resulting ids ride the ping; recipients open
|
|
186
|
+
them from the ping, authenticated:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
pingroom ping --token "$PINGROOM_TOKEN" --room AB12 -m "Nightly report" \
|
|
190
|
+
--attach ./report.pdf --attach ./summary.md
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Accepted types are `md`, `pdf`, `html`, `txt`, `jpg`, `jpeg`, `png`, up to
|
|
194
|
+
20 MiB each and 10 per ping. `--attach` needs an agent token — a webhook ping
|
|
195
|
+
has no uploader identity to bind private files to — and the bound account must
|
|
196
|
+
hold Pro (otherwise the upload fails with `pro_required`). An upload that never
|
|
197
|
+
reaches a ping expires by itself after 24 hours.
|
|
198
|
+
|
|
118
199
|
Exit codes: `0` success · `1` delivery failed · `2` bad usage. So CI fails loudly if a
|
|
119
200
|
ping doesn't land.
|
|
120
201
|
|
|
@@ -360,13 +441,19 @@ Print a ready-to-paste config:
|
|
|
360
441
|
|
|
361
442
|
```bash
|
|
362
443
|
pingroom hook --print-config
|
|
444
|
+
# no global install: npx --yes @pingroom/cli hook --print-config
|
|
363
445
|
```
|
|
364
446
|
|
|
365
|
-
|
|
366
|
-
|
|
447
|
+
If you have not connected yet, run `pingroom` (or `npx --yes @pingroom/cli`) and
|
|
448
|
+
scan the QR first. The hook reads the stored credential and the room selected
|
|
449
|
+
during pairing automatically. No environment variables are needed for a normal
|
|
450
|
+
local setup; merge the printed `hooks` block into `~/.claude/settings.json`.
|
|
451
|
+
|
|
452
|
+
Environment variables remain available for CI and other headless shells, and
|
|
453
|
+
take precedence over the paired values:
|
|
367
454
|
|
|
368
455
|
```bash
|
|
369
|
-
export PINGROOM_TOKEN="<your agent token>"
|
|
456
|
+
export PINGROOM_TOKEN="<your agent token>"
|
|
370
457
|
export PINGROOM_ROOM="<room invite code>"
|
|
371
458
|
```
|
|
372
459
|
|
|
@@ -386,8 +473,23 @@ blocks the agent. Because the `PreToolUse` hook holds the tool call open while i
|
|
|
386
473
|
waits for you, give it a generous `timeout` (the printed config uses 960s) and
|
|
387
474
|
tune the approval-question expiry with `--ttl <seconds>` (default 900).
|
|
388
475
|
|
|
476
|
+
## MCP client setup
|
|
477
|
+
|
|
478
|
+
Print the canonical remote endpoint, a copy-ready Claude Code command, Cursor
|
|
479
|
+
JSON, and the Claude Desktop custom-connector steps:
|
|
480
|
+
|
|
481
|
+
```bash
|
|
482
|
+
pingroom mcp
|
|
483
|
+
# no global install: npx --yes @pingroom/cli mcp
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`pingroom mcp add claude-code` prints the exact `claude mcp add` command but does
|
|
487
|
+
not execute it or modify client configuration. After adding the server, use the
|
|
488
|
+
client's MCP controls to authenticate in the browser; no PingRoom API key is
|
|
489
|
+
pasted into its config.
|
|
490
|
+
|
|
389
491
|
For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
|
|
390
|
-
See <https://pingroom.io/connect-mcp.md>
|
|
492
|
+
See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
|
|
391
493
|
|
|
392
494
|
## License
|
|
393
495
|
|
package/bin/pingroom.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// handoffs List the agent's open handoffs or bounded recent history.
|
|
22
22
|
// live Drive a live progress card (iOS Live Activity / Android live
|
|
23
23
|
// update) on the room members' lock screen: start / update / end.
|
|
24
|
+
// mcp Print the canonical remote MCP endpoint and client setup snippets.
|
|
25
|
+
// activate Retry Agent Inbox activation with the saved QR-paired credential.
|
|
24
26
|
// config Read/write ~/.pingroom/config.json (default_room, api_url).
|
|
25
27
|
// logout Forget the credential in ~/.pingroom/credentials.json.
|
|
26
28
|
//
|
|
@@ -35,12 +37,13 @@ import {
|
|
|
35
37
|
import { homedir } from 'node:os';
|
|
36
38
|
import { join } from 'node:path';
|
|
37
39
|
|
|
38
|
-
// Kept in lockstep with package.json / package-lock.json
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const VERSION = '0.6.
|
|
40
|
+
// Kept in lockstep with package.json / package-lock.json. The GitHub Action is
|
|
41
|
+
// pinned independently to the latest version already published on npm; a test
|
|
42
|
+
// makes that release gate explicit. `hook --print-config` emits this candidate.
|
|
43
|
+
const VERSION = '0.6.1';
|
|
42
44
|
|
|
43
45
|
const BUILTIN_API = 'https://api.pingroom.io';
|
|
46
|
+
const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
|
|
44
47
|
const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
|
|
45
48
|
|
|
46
49
|
const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
|
|
@@ -60,6 +63,9 @@ Commands:
|
|
|
60
63
|
live Drive a live progress card on the lock screen (Live Activity)
|
|
61
64
|
hook Claude Code hook: ping on Stop/Notification, and route tool
|
|
62
65
|
permission prompts to a PingRoom question you answer from your phone
|
|
66
|
+
mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
|
|
67
|
+
Claude Desktop
|
|
68
|
+
activate Retry Agent Inbox activation with the saved QR-paired credential
|
|
63
69
|
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
64
70
|
logout Forget the stored credential
|
|
65
71
|
|
|
@@ -72,6 +78,8 @@ ping options:
|
|
|
72
78
|
--button-label <t> Link button text (<= 26 chars; requires --url)
|
|
73
79
|
--require-ack Keep the ping open until an eligible recipient acknowledges it
|
|
74
80
|
--ack-timeout <s> Ack deadline in seconds (requires --require-ack)
|
|
81
|
+
--attach <path> Attach a file (md/pdf/html/txt/jpg/jpeg/png, <= 20 MiB);
|
|
82
|
+
repeat for up to 10. Requires --token and a Pro account
|
|
75
83
|
-w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
|
|
76
84
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
77
85
|
--room <code> Room invite code (used with --token)
|
|
@@ -145,12 +153,21 @@ live <start|update|end|get> options (agent token, or a room webhook):
|
|
|
145
153
|
--room <code> Room invite code (used with --token)
|
|
146
154
|
-w, --webhook <url> Room webhook URL instead of a token
|
|
147
155
|
|
|
148
|
-
hook options (
|
|
149
|
-
--room <code> Room invite code (or env
|
|
156
|
+
hook options (reads a Claude Code event; defaults to stored credentials/config):
|
|
157
|
+
--room <code> Room invite code (or env/config/paired room)
|
|
150
158
|
--ttl <seconds> Approval-question expiry for PreToolUse (default 900)
|
|
151
159
|
--quiet Suppress the informational stderr lines
|
|
152
160
|
--print-config Print a ready-to-paste ~/.claude/settings.json block
|
|
153
161
|
|
|
162
|
+
mcp:
|
|
163
|
+
pingroom mcp Print the endpoint and client setup snippets
|
|
164
|
+
pingroom mcp add claude-code Print the Claude Code setup command
|
|
165
|
+
(output-only; does not change client config)
|
|
166
|
+
|
|
167
|
+
activate:
|
|
168
|
+
pingroom activate Replay or create the next Agent Inbox test using
|
|
169
|
+
the saved QR-paired credential
|
|
170
|
+
|
|
154
171
|
config options:
|
|
155
172
|
pingroom config list Print the stored settings
|
|
156
173
|
pingroom config get <key> Print one setting
|
|
@@ -162,12 +179,26 @@ Shared:
|
|
|
162
179
|
--api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
|
|
163
180
|
--json Print the raw JSON response
|
|
164
181
|
-h, --help Show this help
|
|
182
|
+
-v, --version Show the CLI version
|
|
165
183
|
|
|
166
184
|
Connecting:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
185
|
+
Install globally, then run with no arguments:
|
|
186
|
+
npm install --global @pingroom/cli
|
|
187
|
+
pingroom
|
|
188
|
+
|
|
189
|
+
Or connect without installing globally:
|
|
190
|
+
npx --yes @pingroom/cli
|
|
191
|
+
|
|
192
|
+
It prints a QR code you scan with the PingRoom app — you pick the account and
|
|
193
|
+
delivery room there. Once paired, it saves the credential, sends one test
|
|
194
|
+
Question, and waits briefly for the server to confirm the completed phone
|
|
195
|
+
round-trip; an answer alone is not treated as activation, and a setup problem
|
|
196
|
+
never discards the usable connection. Run "pingroom activate" to retry that
|
|
197
|
+
test later. The emailed-code fallback stores no server-side delivery room.
|
|
198
|
+
"config set default_room" enables room-addressed commands, but private
|
|
199
|
+
Inbox/Handoff delivery requires QR pairing.
|
|
200
|
+
There is no "login" command: being unconnected is a state the tool resolves,
|
|
201
|
+
not one you have to discover.
|
|
171
202
|
|
|
172
203
|
The credential is written to ~/.pingroom/credentials.json (mode 0600, in a
|
|
173
204
|
0700 directory). PINGROOM_HOME overrides that directory. PINGROOM_TOKEN in the
|
|
@@ -179,8 +210,10 @@ Connecting:
|
|
|
179
210
|
credential > built-in default
|
|
180
211
|
So --room beats PINGROOM_ROOM beats "config set default_room", and --api beats
|
|
181
212
|
PINGROOM_API_URL beats "config set api_url" beats the host you paired against,
|
|
182
|
-
beats ${BUILTIN_API}.
|
|
183
|
-
|
|
213
|
+
beats ${BUILTIN_API}. A stored credential is bound to the origin it was paired
|
|
214
|
+
against: an API override may change the path on that origin, but a different
|
|
215
|
+
origin is refused before the token is sent. To target another origin
|
|
216
|
+
intentionally, provide that host's token with --token or PINGROOM_TOKEN.
|
|
184
217
|
|
|
185
218
|
Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
|
|
186
219
|
PINGROOM_TOKEN there instead.
|
|
@@ -225,15 +258,22 @@ Examples:
|
|
|
225
258
|
# ...or end it as a failure, which still delivers one completion alert:
|
|
226
259
|
# pingroom live end ... --failed -m "Rollback triggered"
|
|
227
260
|
|
|
228
|
-
# Connect Claude Code to your
|
|
261
|
+
# Connect Claude Code hooks to your paired credential (no env vars needed):
|
|
229
262
|
pingroom hook --print-config
|
|
230
263
|
|
|
264
|
+
# Connect an MCP client through browser OAuth (no API key needed):
|
|
265
|
+
pingroom mcp
|
|
266
|
+
|
|
231
267
|
Security:
|
|
232
268
|
Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
|
|
233
269
|
secrets as --webhook / --token flags: argv is visible to other users via the
|
|
234
270
|
process table (ps) and may be captured in shell history. URLs must use https
|
|
235
271
|
(loopback http is allowed for local dev).
|
|
236
272
|
|
|
273
|
+
A paired credential is only sent to its recorded API origin. --api,
|
|
274
|
+
PINGROOM_API_URL and config.api_url cannot redirect that stored bearer to a
|
|
275
|
+
different origin; provide an explicit --token or PINGROOM_TOKEN to override.
|
|
276
|
+
|
|
237
277
|
Exit codes: 0 on success (answered / acked), 1 on error (network/auth/5xx),
|
|
238
278
|
2 on bad usage, 3 when a handoff or question expired, 4 when it was cancelled
|
|
239
279
|
or the recipient was not ready (409 recipient_not_ready). A question answered
|
|
@@ -338,6 +378,11 @@ function resolveToken(args) {
|
|
|
338
378
|
* this layer the next command would present that bearer to api.pingroom.io —
|
|
339
379
|
* leaking it to a host it was never issued for. resolveRoom() already consults
|
|
340
380
|
* the credential last, so the two layerings now agree.
|
|
381
|
+
*
|
|
382
|
+
* It is also an issuer boundary when resolveToken() falls through to the stored
|
|
383
|
+
* credential. Overrides may change the path on the same origin, but
|
|
384
|
+
* requireStoredCredentialOrigin() refuses a different origin unless the caller
|
|
385
|
+
* supplies an explicit --token or PINGROOM_TOKEN for that host.
|
|
341
386
|
*/
|
|
342
387
|
function resolveApiBase(args) {
|
|
343
388
|
const raw = args.api
|
|
@@ -348,6 +393,38 @@ function resolveApiBase(args) {
|
|
|
348
393
|
return String(raw).replace(/\/$/, '');
|
|
349
394
|
}
|
|
350
395
|
|
|
396
|
+
/**
|
|
397
|
+
* A paired bearer belongs to the API origin that minted it. API settings still
|
|
398
|
+
* resolve independently so callers can select a path or an intentional custom
|
|
399
|
+
* host, but a stored token may only follow them within its recorded origin.
|
|
400
|
+
* Supplying --token / PINGROOM_TOKEN makes the token source explicit and opts
|
|
401
|
+
* out of this stored-credential binding.
|
|
402
|
+
*/
|
|
403
|
+
function storedCredentialOriginError(args, apiBase) {
|
|
404
|
+
if (args.token || process.env.PINGROOM_TOKEN) return null;
|
|
405
|
+
|
|
406
|
+
const credential = readStoredCredential();
|
|
407
|
+
if (!credential || typeof credential.api_url !== 'string' || credential.api_url === '') return null;
|
|
408
|
+
|
|
409
|
+
let credentialOrigin;
|
|
410
|
+
let targetOrigin;
|
|
411
|
+
try {
|
|
412
|
+
credentialOrigin = new URL(credential.api_url).origin;
|
|
413
|
+
targetOrigin = new URL(apiBase).origin;
|
|
414
|
+
} catch {
|
|
415
|
+
// URL validation owns malformed values. This guard only compares origins.
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (credentialOrigin === targetOrigin) return null;
|
|
420
|
+
return `stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}. Provide --token or PINGROOM_TOKEN for an intentional API origin override`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function requireStoredCredentialOrigin(args, apiBase) {
|
|
424
|
+
const error = storedCredentialOriginError(args, apiBase);
|
|
425
|
+
if (error) fail(error, EXIT.USAGE);
|
|
426
|
+
}
|
|
427
|
+
|
|
351
428
|
/**
|
|
352
429
|
* Room invite code: --room > PINGROOM_ROOM > config.default_room > the room the
|
|
353
430
|
* credential was paired to. The paired room is last because it is the weakest
|
|
@@ -404,6 +481,7 @@ function parseArgs(argv) {
|
|
|
404
481
|
'--button-label': 'button_label',
|
|
405
482
|
'--require-ack': 'require_ack',
|
|
406
483
|
'--ack-timeout': 'ack_timeout',
|
|
484
|
+
'--attach': 'attach',
|
|
407
485
|
'--token': 'token',
|
|
408
486
|
'--room': 'room',
|
|
409
487
|
'--api': 'api',
|
|
@@ -411,6 +489,7 @@ function parseArgs(argv) {
|
|
|
411
489
|
'-h': 'help', '--help': 'help',
|
|
412
490
|
};
|
|
413
491
|
const booleans = new Set(['require_ack', 'json', 'help']);
|
|
492
|
+
const repeatable = new Set(['attach']);
|
|
414
493
|
|
|
415
494
|
for (let i = 0; i < argv.length; i++) {
|
|
416
495
|
const token = argv[i];
|
|
@@ -426,7 +505,8 @@ function parseArgs(argv) {
|
|
|
426
505
|
if (value === undefined) {
|
|
427
506
|
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
428
507
|
}
|
|
429
|
-
args[key]
|
|
508
|
+
if (repeatable.has(key)) (args[key] ||= []).push(value);
|
|
509
|
+
else args[key] = value;
|
|
430
510
|
} else if (token.startsWith('-')) {
|
|
431
511
|
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
432
512
|
} else {
|
|
@@ -540,17 +620,30 @@ function parseHandoffArgs(argv) {
|
|
|
540
620
|
return args;
|
|
541
621
|
}
|
|
542
622
|
|
|
623
|
+
// True when a URL is safe to attach a bearer token or webhook secret to: https,
|
|
624
|
+
// or http on loopback so local dev against http://localhost still works.
|
|
625
|
+
// Split out of requireSafeUrl for the `hook` command, which must apply the same
|
|
626
|
+
// rule but fails open (it defers instead of exiting — see hook()).
|
|
627
|
+
function isSafeUrl(raw) {
|
|
628
|
+
let u;
|
|
629
|
+
try {
|
|
630
|
+
u = new URL(raw);
|
|
631
|
+
} catch {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
635
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && isLoopback);
|
|
636
|
+
}
|
|
637
|
+
|
|
543
638
|
// Refuse to send a bearer token or webhook secret over cleartext http. A
|
|
544
639
|
// loopback host is allowed so local dev against http://localhost still works.
|
|
545
640
|
function requireSafeUrl(kind, raw) {
|
|
546
|
-
let u;
|
|
547
641
|
try {
|
|
548
|
-
|
|
642
|
+
new URL(raw);
|
|
549
643
|
} catch {
|
|
550
644
|
fail(`${kind} is not a valid URL`, EXIT.USAGE);
|
|
551
645
|
}
|
|
552
|
-
|
|
553
|
-
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopback)) {
|
|
646
|
+
if (!isSafeUrl(raw)) {
|
|
554
647
|
fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
|
|
555
648
|
}
|
|
556
649
|
return raw;
|
|
@@ -569,11 +662,11 @@ function parseDataObject(raw) {
|
|
|
569
662
|
return data;
|
|
570
663
|
}
|
|
571
664
|
|
|
572
|
-
// `soft: true` returns { error } instead of exiting on a transport failure.
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
async function httpJson(method, url, { body, headers = {}, soft = false } = {}) {
|
|
665
|
+
// `soft: true` returns { error } instead of exiting on a transport failure. The
|
|
666
|
+
// bounded pairing and activation loops use it so a single DNS blip or dropped
|
|
667
|
+
// connection does not discard an otherwise recoverable human workflow. Every
|
|
668
|
+
// other caller keeps the hard exit.
|
|
669
|
+
async function httpJson(method, url, { body, headers = {}, soft = false, signal } = {}) {
|
|
577
670
|
let res;
|
|
578
671
|
try {
|
|
579
672
|
res = await fetch(url, {
|
|
@@ -584,6 +677,7 @@ async function httpJson(method, url, { body, headers = {}, soft = false } = {})
|
|
|
584
677
|
...headers,
|
|
585
678
|
},
|
|
586
679
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
680
|
+
...(signal ? { signal } : {}),
|
|
587
681
|
});
|
|
588
682
|
} catch (err) {
|
|
589
683
|
if (soft) return { res: null, text: '', json: null, error: err };
|
|
@@ -604,6 +698,90 @@ async function httpJson(method, url, { body, headers = {}, soft = false } = {})
|
|
|
604
698
|
return { res, text, json };
|
|
605
699
|
}
|
|
606
700
|
|
|
701
|
+
// The extensions the attachment endpoint accepts. Mirrored here so a typo is a
|
|
702
|
+
// local usage error instead of a 422 after the bytes have already been sent.
|
|
703
|
+
// Keep in lockstep with laravel config/attachments.php `allowed_extensions`.
|
|
704
|
+
const ATTACHMENT_EXTENSIONS = ['md', 'pdf', 'html', 'txt', 'jpg', 'jpeg', 'png'];
|
|
705
|
+
const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
|
706
|
+
const ATTACHMENT_MAX_COUNT = 10;
|
|
707
|
+
const ATTACHMENT_MIME = {
|
|
708
|
+
md: 'text/markdown',
|
|
709
|
+
pdf: 'application/pdf',
|
|
710
|
+
html: 'text/html',
|
|
711
|
+
txt: 'text/plain',
|
|
712
|
+
jpg: 'image/jpeg',
|
|
713
|
+
jpeg: 'image/jpeg',
|
|
714
|
+
png: 'image/png',
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Upload each --attach path and return the ids in flag order. Bytes go up as
|
|
719
|
+
* multipart; only the resulting ids ride the ping body. An id we never manage
|
|
720
|
+
* to attach expires server-side after 24h, so a mid-run failure leaks nothing
|
|
721
|
+
* permanent.
|
|
722
|
+
*/
|
|
723
|
+
async function uploadAttachments(paths, apiBase, token) {
|
|
724
|
+
if (paths.length > ATTACHMENT_MAX_COUNT) {
|
|
725
|
+
fail(`--attach accepts at most ${ATTACHMENT_MAX_COUNT} files`, EXIT.USAGE);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const { readFile, stat } = await import('node:fs/promises');
|
|
729
|
+
const { basename, extname } = await import('node:path');
|
|
730
|
+
const ids = [];
|
|
731
|
+
|
|
732
|
+
for (const path of paths) {
|
|
733
|
+
const name = basename(path);
|
|
734
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
735
|
+
if (!ATTACHMENT_EXTENSIONS.includes(ext)) {
|
|
736
|
+
fail(`--attach ${name}: only ${ATTACHMENT_EXTENSIONS.join(', ')} files are supported`, EXIT.USAGE);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let info;
|
|
740
|
+
try {
|
|
741
|
+
info = await stat(path);
|
|
742
|
+
} catch {
|
|
743
|
+
fail(`--attach ${path}: file not found`, EXIT.USAGE);
|
|
744
|
+
}
|
|
745
|
+
if (!info.isFile()) fail(`--attach ${path}: not a file`, EXIT.USAGE);
|
|
746
|
+
if (info.size < 1) fail(`--attach ${name}: file is empty`, EXIT.USAGE);
|
|
747
|
+
if (info.size > ATTACHMENT_MAX_BYTES) {
|
|
748
|
+
fail(`--attach ${name}: file exceeds the 20 MiB limit`, EXIT.USAGE);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const body = new FormData();
|
|
752
|
+
body.append('file', new Blob([await readFile(path)], { type: ATTACHMENT_MIME[ext] }), name);
|
|
753
|
+
|
|
754
|
+
let res;
|
|
755
|
+
try {
|
|
756
|
+
// Not httpJson: that helper JSON-encodes the body and would strip the
|
|
757
|
+
// multipart boundary the runtime generates for us.
|
|
758
|
+
res = await fetch(`${apiBase}/api/agent/attachments`, {
|
|
759
|
+
method: 'POST',
|
|
760
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
|
761
|
+
body,
|
|
762
|
+
});
|
|
763
|
+
} catch (err) {
|
|
764
|
+
fail(`network error uploading ${name}: ${err.message}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const text = await res.text().catch(() => '');
|
|
768
|
+
let json = null;
|
|
769
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
770
|
+
|
|
771
|
+
if (res.status === 402) {
|
|
772
|
+
fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
|
|
773
|
+
}
|
|
774
|
+
if (!res.ok || !json?.attachment?.id) {
|
|
775
|
+
const detail = json?.message || json?.error || `HTTP ${res.status}`;
|
|
776
|
+
fail(`upload failed for ${name}: ${detail}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
ids.push(json.attachment.id);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return ids;
|
|
783
|
+
}
|
|
784
|
+
|
|
607
785
|
async function ping(args) {
|
|
608
786
|
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
609
787
|
|
|
@@ -662,6 +840,13 @@ async function ping(args) {
|
|
|
662
840
|
|
|
663
841
|
let result;
|
|
664
842
|
|
|
843
|
+
// Attachments exist only on the agent-token path: an incoming webhook has no
|
|
844
|
+
// uploader identity to bind private files to, so the API takes no ids there.
|
|
845
|
+
const attachPaths = args.attach ?? [];
|
|
846
|
+
if (attachPaths.length && (webhook || !token)) {
|
|
847
|
+
fail('--attach requires an agent token (--token / PINGROOM_TOKEN), not a webhook ping', EXIT.USAGE);
|
|
848
|
+
}
|
|
849
|
+
|
|
665
850
|
if (webhook) {
|
|
666
851
|
if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
|
|
667
852
|
fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
|
|
@@ -675,6 +860,7 @@ async function ping(args) {
|
|
|
675
860
|
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
676
861
|
result = await httpJson('POST', webhook, { body });
|
|
677
862
|
} else if (token) {
|
|
863
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
678
864
|
if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
679
865
|
if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
|
|
680
866
|
fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
|
|
@@ -687,6 +873,9 @@ async function ping(args) {
|
|
|
687
873
|
if (data) body.data = data;
|
|
688
874
|
if (args.require_ack) body.requires_ack = true;
|
|
689
875
|
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
876
|
+
if (attachPaths.length) {
|
|
877
|
+
body.attachment_ids = await uploadAttachments(attachPaths, apiBase, token);
|
|
878
|
+
}
|
|
690
879
|
result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
691
880
|
} else {
|
|
692
881
|
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
|
|
@@ -711,6 +900,11 @@ async function ping(args) {
|
|
|
711
900
|
|
|
712
901
|
// --- live status -----------------------------------------------------------
|
|
713
902
|
|
|
903
|
+
// The templates the server accepts on `live start`. Mirrored here so a typo is
|
|
904
|
+
// a local usage error instead of a 422 from the API. Keep in lockstep with the
|
|
905
|
+
// --template line in HELP and with LIVE_ACTIVITY_TEMPLATES.md.
|
|
906
|
+
const LIVE_TEMPLATES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'question', 'matchup'];
|
|
907
|
+
|
|
714
908
|
// Parser for `live`: a leading subcommand (start|update|end|get) plus the
|
|
715
909
|
// live-status flags. Unknown flags fail like the other parsers.
|
|
716
910
|
function parseLiveArgs(argv) {
|
|
@@ -844,6 +1038,7 @@ async function live(args) {
|
|
|
844
1038
|
|
|
845
1039
|
if (sub === 'get') {
|
|
846
1040
|
if (!token) fail('live get requires an agent token (--token or PINGROOM_TOKEN)', EXIT.USAGE);
|
|
1041
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
847
1042
|
if (!room) fail('--room is required', EXIT.USAGE);
|
|
848
1043
|
requireSafeUrl('--api', apiBase);
|
|
849
1044
|
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live/${encodeURIComponent(correlationId)}`;
|
|
@@ -896,7 +1091,15 @@ async function live(args) {
|
|
|
896
1091
|
// Template, category and step labels are fixed when the stream is created;
|
|
897
1092
|
// sending them on an update is a no-op server-side, so only `start` takes them.
|
|
898
1093
|
if (sub === 'start') {
|
|
899
|
-
|
|
1094
|
+
// Validated locally for the same reason --category is: a typo'd name is a
|
|
1095
|
+
// usage error, and letting it reach the server turns it into a 422 round
|
|
1096
|
+
// trip that reads like an outage.
|
|
1097
|
+
if (args.template) {
|
|
1098
|
+
if (!LIVE_TEMPLATES.includes(args.template)) {
|
|
1099
|
+
fail(`--template must be one of: ${LIVE_TEMPLATES.join(', ')}`, EXIT.USAGE);
|
|
1100
|
+
}
|
|
1101
|
+
liveStatus.template = args.template;
|
|
1102
|
+
}
|
|
900
1103
|
// `alert` has no template equivalent and is the only way to start a stream
|
|
901
1104
|
// time-sensitive (breaking through Focus) without also demanding an ack.
|
|
902
1105
|
if (args.category) {
|
|
@@ -935,6 +1138,7 @@ async function live(args) {
|
|
|
935
1138
|
requireSafeUrl('--webhook', webhook);
|
|
936
1139
|
result = await httpJson('POST', webhook, { body });
|
|
937
1140
|
} else if (token) {
|
|
1141
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
938
1142
|
if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
939
1143
|
requireSafeUrl('--api', apiBase);
|
|
940
1144
|
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
|
|
@@ -972,6 +1176,7 @@ function agentContext(args, { needRoom = false } = {}) {
|
|
|
972
1176
|
);
|
|
973
1177
|
}
|
|
974
1178
|
const apiBase = resolveApiBase(args);
|
|
1179
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
975
1180
|
requireSafeUrl('--api', apiBase);
|
|
976
1181
|
const room = resolveRoom(args);
|
|
977
1182
|
if (needRoom && !room) {
|
|
@@ -1526,7 +1731,7 @@ async function hookWaitForAnswer(id, { token, apiBase }) {
|
|
|
1526
1731
|
|
|
1527
1732
|
async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
1528
1733
|
if (!token || !room) {
|
|
1529
|
-
emitPreToolUseDecision('ask', 'PingRoom not configured (
|
|
1734
|
+
emitPreToolUseDecision('ask', 'PingRoom not configured (pair by QR, or configure both a token and room)');
|
|
1530
1735
|
return EXIT.OK;
|
|
1531
1736
|
}
|
|
1532
1737
|
|
|
@@ -1596,7 +1801,7 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
|
1596
1801
|
|
|
1597
1802
|
async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
1598
1803
|
if (!token || !room) {
|
|
1599
|
-
if (!args.quiet) process.stderr.write('pingroom: hook skipped (
|
|
1804
|
+
if (!args.quiet) process.stderr.write('pingroom: hook skipped (pair by QR, or configure both a token and room)\n');
|
|
1600
1805
|
return EXIT.OK;
|
|
1601
1806
|
}
|
|
1602
1807
|
|
|
@@ -1653,9 +1858,12 @@ function printHookConfig() {
|
|
|
1653
1858
|
process.stdout.write(
|
|
1654
1859
|
`# PingRoom × Claude Code — merge this into ~/.claude/settings.json
|
|
1655
1860
|
#
|
|
1656
|
-
# 1.
|
|
1657
|
-
#
|
|
1658
|
-
#
|
|
1861
|
+
# 1. Connect once and choose a delivery room when you scan the QR:
|
|
1862
|
+
# npm install --global @pingroom/cli && pingroom
|
|
1863
|
+
# Or, without a global install:
|
|
1864
|
+
# npx --yes @pingroom/cli@${VERSION}
|
|
1865
|
+
# The hook reads that stored credential and paired room automatically; you do
|
|
1866
|
+
# not need to export PINGROOM_TOKEN or PINGROOM_ROOM for a local setup.
|
|
1659
1867
|
#
|
|
1660
1868
|
# 2. Merge the "hooks" block below into ~/.claude/settings.json.
|
|
1661
1869
|
# Stop / Notification -> ping your phone.
|
|
@@ -1665,6 +1873,7 @@ function printHookConfig() {
|
|
|
1665
1873
|
#
|
|
1666
1874
|
# If PingRoom is unreachable the hook defers to the normal local prompt — it
|
|
1667
1875
|
# never auto-approves and never blocks the agent.
|
|
1876
|
+
# PINGROOM_TOKEN / PINGROOM_ROOM remain supported for CI and headless shells.
|
|
1668
1877
|
|
|
1669
1878
|
${JSON.stringify(config, null, 2)}
|
|
1670
1879
|
`);
|
|
@@ -1685,12 +1894,82 @@ async function hook(args) {
|
|
|
1685
1894
|
const room = resolveRoom(args);
|
|
1686
1895
|
const apiBase = resolveApiBase(args);
|
|
1687
1896
|
|
|
1897
|
+
const originError = storedCredentialOriginError(args, apiBase);
|
|
1898
|
+
if (originError) {
|
|
1899
|
+
if (name === 'PreToolUse') {
|
|
1900
|
+
emitPreToolUseDecision('ask', `${originError}; deferring to local prompt`);
|
|
1901
|
+
} else if (!args.quiet) {
|
|
1902
|
+
process.stderr.write(`pingroom: hook skipped (${originError})\n`);
|
|
1903
|
+
}
|
|
1904
|
+
return EXIT.OK;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Every other command that attaches a bearer gates its base through
|
|
1908
|
+
// requireSafeUrl first; the hook was the one that didn't, so a config or env
|
|
1909
|
+
// pointing at plain http shipped `Authorization: Bearer …` in the clear with
|
|
1910
|
+
// nothing on screen. Same rule here — but enforced by deferring, not by
|
|
1911
|
+
// exiting: the hook's whole contract is that it never blocks the agent, so a
|
|
1912
|
+
// hard failure would trade a credential leak for a broken session.
|
|
1913
|
+
if (!isSafeUrl(apiBase)) {
|
|
1914
|
+
const why = `${apiBase} is not https — refusing to send credentials over cleartext`;
|
|
1915
|
+
if (name === 'PreToolUse') {
|
|
1916
|
+
emitPreToolUseDecision('ask', `PingRoom API base ${why}; deferring to local prompt`);
|
|
1917
|
+
} else if (!args.quiet) {
|
|
1918
|
+
process.stderr.write(`pingroom: hook skipped (API base ${why})\n`);
|
|
1919
|
+
}
|
|
1920
|
+
return EXIT.OK;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1688
1923
|
if (name === 'PreToolUse') {
|
|
1689
1924
|
return hookPreToolUse(event, { token, room, apiBase, args });
|
|
1690
1925
|
}
|
|
1691
1926
|
return hookNotify(event, name, { token, room, apiBase, args });
|
|
1692
1927
|
}
|
|
1693
1928
|
|
|
1929
|
+
// --- MCP client setup ------------------------------------------------------
|
|
1930
|
+
|
|
1931
|
+
function mcp(rest) {
|
|
1932
|
+
const claudeCommand = `claude mcp add --transport http pingroom ${MCP_ENDPOINT}`;
|
|
1933
|
+
|
|
1934
|
+
if (rest.length === 0 || (rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help'))) {
|
|
1935
|
+
const config = {
|
|
1936
|
+
mcpServers: {
|
|
1937
|
+
pingroom: { url: MCP_ENDPOINT },
|
|
1938
|
+
},
|
|
1939
|
+
};
|
|
1940
|
+
process.stdout.write(
|
|
1941
|
+
`PingRoom MCP endpoint:
|
|
1942
|
+
${MCP_ENDPOINT}
|
|
1943
|
+
|
|
1944
|
+
Claude Code:
|
|
1945
|
+
${claudeCommand}
|
|
1946
|
+
|
|
1947
|
+
Cursor JSON (~/.cursor/mcp.json):
|
|
1948
|
+
${JSON.stringify(config, null, 2)}
|
|
1949
|
+
|
|
1950
|
+
Claude Desktop:
|
|
1951
|
+
Customize > Connectors > Add custom connector
|
|
1952
|
+
Name: PingRoom
|
|
1953
|
+
URL: ${MCP_ENDPOINT}
|
|
1954
|
+
|
|
1955
|
+
After adding the server, use your client's MCP controls to authenticate in the
|
|
1956
|
+
browser. No API key is needed.
|
|
1957
|
+
This command only prints setup instructions and does not modify client config.
|
|
1958
|
+
`);
|
|
1959
|
+
return EXIT.OK;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
if (rest.length === 2 && rest[0] === 'add' && rest[1] === 'claude-code') {
|
|
1963
|
+
process.stdout.write(
|
|
1964
|
+
`No client configuration was changed. Copy and run:
|
|
1965
|
+
${claudeCommand}
|
|
1966
|
+
`);
|
|
1967
|
+
return EXIT.OK;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
fail('usage: pingroom mcp [add claude-code]', EXIT.USAGE);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1694
1973
|
// --- connecting (pairing + email fallback) ---------------------------------
|
|
1695
1974
|
//
|
|
1696
1975
|
// Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture —
|
|
@@ -1711,6 +1990,28 @@ const CLI_SCOPES = [
|
|
|
1711
1990
|
];
|
|
1712
1991
|
|
|
1713
1992
|
const AGENT_LABEL = 'pingroom-cli';
|
|
1993
|
+
// A connect command should prove the phone round-trip, but it must not hold a
|
|
1994
|
+
// terminal for the onboarding Question's full 24-hour server TTL. The Question
|
|
1995
|
+
// remains answerable after this local deadline and the credential is already
|
|
1996
|
+
// durable before the wait begins.
|
|
1997
|
+
const ACTIVATION_MAX_WAIT_MS = 2 * 60 * 1000;
|
|
1998
|
+
// The wait route is limited to 30 requests/minute. Keep immediate pending or
|
|
1999
|
+
// answered-without-completion observations safely below that ceiling while a
|
|
2000
|
+
// mixed-version or commit-propagation race is still being reconciled.
|
|
2001
|
+
const ACTIVATION_MIN_POLL_INTERVAL_MS = 2100;
|
|
2002
|
+
|
|
2003
|
+
function activationMaxWaitMs() {
|
|
2004
|
+
// Keep production fixed at two minutes. The guarded override lets the real
|
|
2005
|
+
// subprocess tests exercise deadline behavior without holding the suite for
|
|
2006
|
+
// two minutes; it is ignored outside NODE_ENV=test.
|
|
2007
|
+
if (process.env.NODE_ENV === 'test') {
|
|
2008
|
+
const testValue = Number(process.env.PINGROOM_INTERNAL_ACTIVATION_TIMEOUT_MS);
|
|
2009
|
+
if (Number.isInteger(testValue) && testValue > 0 && testValue <= ACTIVATION_MAX_WAIT_MS) {
|
|
2010
|
+
return testValue;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
return ACTIVATION_MAX_WAIT_MS;
|
|
2014
|
+
}
|
|
1714
2015
|
|
|
1715
2016
|
// Widest QR we render (compact half-block form of a ~110-char pair URL is 39
|
|
1716
2017
|
// columns). Anything narrower would wrap and become unscannable, so we print
|
|
@@ -1843,6 +2144,284 @@ function connectedLine(cred) {
|
|
|
1843
2144
|
return `✓ Connected as ${who}${room ? ` → #${room}` : ''}`;
|
|
1844
2145
|
}
|
|
1845
2146
|
|
|
2147
|
+
function activationFailureDetail(result) {
|
|
2148
|
+
if (result.error) return result.error.message;
|
|
2149
|
+
const status = result.res ? `HTTP ${result.res.status}` : 'request failed';
|
|
2150
|
+
return (result.json && (result.json.message || result.json.error || result.json.code)) || status;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function isJsonObject(value) {
|
|
2154
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function isNonEmptyString(value) {
|
|
2158
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function isNullableString(value) {
|
|
2162
|
+
return value === null || typeof value === 'string';
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function validateActivationEnsure(json) {
|
|
2166
|
+
const room = json?.room;
|
|
2167
|
+
const question = json?.question;
|
|
2168
|
+
const validState = question?.state === 'pending'
|
|
2169
|
+
|| question?.state === 'answered'
|
|
2170
|
+
|| question?.state === 'expired'
|
|
2171
|
+
|| question?.state === 'cancelled';
|
|
2172
|
+
if (
|
|
2173
|
+
!isJsonObject(json)
|
|
2174
|
+
|| json.onboarded !== true
|
|
2175
|
+
|| typeof json.replayed !== 'boolean'
|
|
2176
|
+
|| !isJsonObject(room)
|
|
2177
|
+
|| !isNonEmptyString(room.id)
|
|
2178
|
+
|| typeof room.name !== 'string'
|
|
2179
|
+
|| !isNonEmptyString(room.invite_code)
|
|
2180
|
+
|| typeof room.is_agent_inbox !== 'boolean'
|
|
2181
|
+
|| !isJsonObject(question)
|
|
2182
|
+
|| !isNonEmptyString(question.id)
|
|
2183
|
+
|| question.kind !== 'question'
|
|
2184
|
+
|| !isNonEmptyString(question.prompt)
|
|
2185
|
+
|| !Array.isArray(question.options)
|
|
2186
|
+
|| question.options.some((option) => (
|
|
2187
|
+
!isJsonObject(option)
|
|
2188
|
+
|| !isNonEmptyString(option.value)
|
|
2189
|
+
|| !isNonEmptyString(option.label)
|
|
2190
|
+
))
|
|
2191
|
+
|| !validState
|
|
2192
|
+
|| !isNullableString(question.expires_at)
|
|
2193
|
+
|| !isNullableString(question.created_at)
|
|
2194
|
+
) {
|
|
2195
|
+
return { error: 'PingRoom returned an incomplete Agent Inbox ensure response' };
|
|
2196
|
+
}
|
|
2197
|
+
return { question };
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function validateActivationWait(json, questionId) {
|
|
2201
|
+
const state = json?.state;
|
|
2202
|
+
const validState = state === 'pending' || state === 'answered' || state === 'expired' || state === 'cancelled';
|
|
2203
|
+
if (
|
|
2204
|
+
!isJsonObject(json)
|
|
2205
|
+
|| !isNonEmptyString(json.id)
|
|
2206
|
+
|| json.id !== questionId
|
|
2207
|
+
|| json.kind !== 'question'
|
|
2208
|
+
|| !validState
|
|
2209
|
+
|| (json.activation_completed !== undefined && typeof json.activation_completed !== 'boolean')
|
|
2210
|
+
|| (state !== 'answered' && json.activation_completed === true)
|
|
2211
|
+
) {
|
|
2212
|
+
return { error: 'PingRoom returned a mismatched Agent Inbox wait response' };
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
if (state === 'answered') {
|
|
2216
|
+
const answer = json.answer;
|
|
2217
|
+
const responder = answer?.responder;
|
|
2218
|
+
if (
|
|
2219
|
+
!isJsonObject(answer)
|
|
2220
|
+
|| !isNullableString(answer.value)
|
|
2221
|
+
|| !isNullableString(answer.label)
|
|
2222
|
+
|| !isNullableString(answer.text)
|
|
2223
|
+
|| (!isNonEmptyString(answer.value) && !isNonEmptyString(answer.text))
|
|
2224
|
+
|| !isNullableString(answer.answered_at)
|
|
2225
|
+
|| (responder !== null && !isJsonObject(responder))
|
|
2226
|
+
|| (isJsonObject(responder)
|
|
2227
|
+
&& (!isNullableString(responder.id) || !isNullableString(responder.display_name)))
|
|
2228
|
+
) {
|
|
2229
|
+
return { error: 'PingRoom returned an answered activation without a valid answer' };
|
|
2230
|
+
}
|
|
2231
|
+
} else if (json.answer !== undefined && json.answer !== null) {
|
|
2232
|
+
return { error: 'PingRoom returned an answer for an unresolved activation' };
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
return { value: json };
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function retryAfterMs(response) {
|
|
2239
|
+
const raw = response?.headers?.get('retry-after')?.trim();
|
|
2240
|
+
if (!raw) return null;
|
|
2241
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw) * 1000;
|
|
2242
|
+
const at = Date.parse(raw);
|
|
2243
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function activationRetryDelay(result, transientRun, deadline) {
|
|
2247
|
+
const fromHeader = result.res?.status === 429 ? retryAfterMs(result.res) : null;
|
|
2248
|
+
const fallback = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 10_000);
|
|
2249
|
+
return Math.max(0, Math.min(fromHeader ?? fallback, deadline - Date.now()));
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function activationIncomplete(detail, instruction = 'Run "pingroom activate" to retry with this saved connection.') {
|
|
2253
|
+
const safeDetail = detail ? `: ${stripControlChars(detail)}` : '';
|
|
2254
|
+
process.stdout.write(` Agent Inbox activation is not complete${safeDetail}\n`);
|
|
2255
|
+
process.stdout.write(' Your connection is saved and usable.\n');
|
|
2256
|
+
process.stdout.write(` ${instruction}\n`);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* Prove the freshly paired credential can complete a human round-trip. This is
|
|
2261
|
+
* intentionally best-effort: saveCredential() has already committed the active
|
|
2262
|
+
* bearer atomically, so no activation outage can roll back or corrupt it.
|
|
2263
|
+
*/
|
|
2264
|
+
async function activateInboxAfterPairing(cred) {
|
|
2265
|
+
const headers = { Authorization: `Bearer ${cred.token}` };
|
|
2266
|
+
const overallDeadline = Date.now() + activationMaxWaitMs();
|
|
2267
|
+
process.stdout.write(' Sending a test question to PingRoom…\n');
|
|
2268
|
+
|
|
2269
|
+
let ensured;
|
|
2270
|
+
let ensureTransientRun = 0;
|
|
2271
|
+
while (Date.now() < overallDeadline) {
|
|
2272
|
+
ensured = await httpJson('POST', `${cred.apiBase}/api/agent/inbox/ensure`, {
|
|
2273
|
+
body: {},
|
|
2274
|
+
headers,
|
|
2275
|
+
soft: true,
|
|
2276
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(15_000, overallDeadline - Date.now()))),
|
|
2277
|
+
});
|
|
2278
|
+
const transient = ensured.error || ensured.res?.status === 429 || ensured.res?.status >= 500;
|
|
2279
|
+
if (!transient) break;
|
|
2280
|
+
ensureTransientRun += 1;
|
|
2281
|
+
await sleep(activationRetryDelay(ensured, ensureTransientRun, overallDeadline));
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
if (!ensured.res?.ok) {
|
|
2285
|
+
const detail = Date.now() >= overallDeadline
|
|
2286
|
+
? 'the two-minute activation deadline elapsed while PingRoom was unavailable'
|
|
2287
|
+
: activationFailureDetail(ensured);
|
|
2288
|
+
activationIncomplete(detail);
|
|
2289
|
+
return false;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const ensureEnvelope = validateActivationEnsure(ensured.json);
|
|
2293
|
+
if (ensureEnvelope.error) {
|
|
2294
|
+
activationIncomplete(ensureEnvelope.error);
|
|
2295
|
+
return false;
|
|
2296
|
+
}
|
|
2297
|
+
const { question } = ensureEnvelope;
|
|
2298
|
+
|
|
2299
|
+
process.stdout.write(' Answer “PingRoom connected. Can you answer this?” on your phone.\n');
|
|
2300
|
+
// The server stamp, not the terminal state by itself, is the activation
|
|
2301
|
+
// authority. A terminal answer without the stamp cannot become a valid
|
|
2302
|
+
// receipt-before-answer sequence later, so fail clearly instead of polling a
|
|
2303
|
+
// state the server intentionally will not rewrite.
|
|
2304
|
+
const deadline = overallDeadline;
|
|
2305
|
+
let transientRun = 0;
|
|
2306
|
+
|
|
2307
|
+
while (Date.now() < deadline) {
|
|
2308
|
+
const pollStartedAt = Date.now();
|
|
2309
|
+
const remainingSeconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
2310
|
+
const hold = Math.min(20, remainingSeconds);
|
|
2311
|
+
const waited = await httpJson(
|
|
2312
|
+
'GET',
|
|
2313
|
+
`${cred.apiBase}/api/agent/handoffs/${encodeURIComponent(question.id)}/wait?timeout=${hold}`,
|
|
2314
|
+
{
|
|
2315
|
+
headers,
|
|
2316
|
+
soft: true,
|
|
2317
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(
|
|
2318
|
+
hold * 1000 + 10_000,
|
|
2319
|
+
deadline - Date.now(),
|
|
2320
|
+
))),
|
|
2321
|
+
},
|
|
2322
|
+
);
|
|
2323
|
+
|
|
2324
|
+
const transient = waited.error || waited.res?.status === 429 || waited.res?.status >= 500;
|
|
2325
|
+
if (transient) {
|
|
2326
|
+
transientRun += 1;
|
|
2327
|
+
const retryDelay = activationRetryDelay(waited, transientRun, deadline);
|
|
2328
|
+
const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
|
|
2329
|
+
await sleep(Math.max(0, Math.min(Math.max(retryDelay, cadenceDelay), deadline - Date.now())));
|
|
2330
|
+
continue;
|
|
2331
|
+
}
|
|
2332
|
+
transientRun = 0;
|
|
2333
|
+
|
|
2334
|
+
if (!waited.res?.ok) {
|
|
2335
|
+
activationIncomplete(activationFailureDetail(waited));
|
|
2336
|
+
return false;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
const waitEnvelope = validateActivationWait(waited.json, question.id);
|
|
2340
|
+
if (waitEnvelope.error) {
|
|
2341
|
+
activationIncomplete(waitEnvelope.error);
|
|
2342
|
+
return false;
|
|
2343
|
+
}
|
|
2344
|
+
const resolved = waitEnvelope.value;
|
|
2345
|
+
const state = resolved.state;
|
|
2346
|
+
if (state === 'answered') {
|
|
2347
|
+
if (resolved.activation_completed !== true) {
|
|
2348
|
+
activationIncomplete(
|
|
2349
|
+
'the test question was answered without verified phone receipt before the answer',
|
|
2350
|
+
'Update the PingRoom app if needed, then run "pingroom activate" to send a fresh test with this saved connection.',
|
|
2351
|
+
);
|
|
2352
|
+
return false;
|
|
2353
|
+
}
|
|
2354
|
+
const answer = resolved.answer.text || resolved.answer.label || resolved.answer.value;
|
|
2355
|
+
process.stdout.write(`✓ Test question answered (${stripControlChars(answer)}). Agent Inbox is ready.\n`);
|
|
2356
|
+
return true;
|
|
2357
|
+
}
|
|
2358
|
+
if (state === 'expired' || state === 'cancelled') {
|
|
2359
|
+
activationIncomplete(
|
|
2360
|
+
`the test question ${state}`,
|
|
2361
|
+
'Run "pingroom activate" to send a fresh test with this saved connection.',
|
|
2362
|
+
);
|
|
2363
|
+
return false;
|
|
2364
|
+
}
|
|
2365
|
+
// `pending` at the bounded hold timeout — continue at a throttle-safe
|
|
2366
|
+
// cadence until the local/server deadline.
|
|
2367
|
+
const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
|
|
2368
|
+
await sleep(Math.max(0, Math.min(cadenceDelay, deadline - Date.now())));
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
activationIncomplete(
|
|
2372
|
+
'still waiting for the test answer at the activation deadline',
|
|
2373
|
+
);
|
|
2374
|
+
return false;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/** Retry activation only for the durable credential created by QR pairing. */
|
|
2378
|
+
async function activateStoredInbox(args) {
|
|
2379
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
2380
|
+
if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
|
|
2381
|
+
if (args.token !== undefined) {
|
|
2382
|
+
fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
|
|
2383
|
+
}
|
|
2384
|
+
const unsupported = Object.keys(args).filter((key) => !['_', 'help', 'api', 'token'].includes(key));
|
|
2385
|
+
if (unsupported.length > 0) {
|
|
2386
|
+
fail('usage: pingroom activate [--api <url>]', EXIT.USAGE);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const credential = readStoredCredential();
|
|
2390
|
+
if (!credential) {
|
|
2391
|
+
fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
|
|
2392
|
+
}
|
|
2393
|
+
if (!credential.room || !isNonEmptyString(credential.room.invite_code)) {
|
|
2394
|
+
fail('the saved credential has no QR-selected delivery room; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
|
|
2395
|
+
}
|
|
2396
|
+
if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
|
|
2397
|
+
fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
const apiBase = resolveApiBase(args);
|
|
2401
|
+
requireSafeUrl('--api', apiBase);
|
|
2402
|
+
if (!isNonEmptyString(credential.api_url)) {
|
|
2403
|
+
fail('the saved QR-paired credential has no trusted API origin; pair again before running "pingroom activate"', EXIT.USAGE);
|
|
2404
|
+
}
|
|
2405
|
+
let credentialOrigin;
|
|
2406
|
+
let targetOrigin;
|
|
2407
|
+
try {
|
|
2408
|
+
credentialOrigin = new URL(credential.api_url).origin;
|
|
2409
|
+
targetOrigin = new URL(apiBase).origin;
|
|
2410
|
+
} catch {
|
|
2411
|
+
fail('the saved QR-paired credential has an invalid API origin; pair again', EXIT.USAGE);
|
|
2412
|
+
}
|
|
2413
|
+
if (credentialOrigin !== targetOrigin) {
|
|
2414
|
+
fail(`stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}`, EXIT.USAGE);
|
|
2415
|
+
}
|
|
2416
|
+
process.stdout.write(`${connectedLine(credential)}\n`);
|
|
2417
|
+
|
|
2418
|
+
const completed = await activateInboxAfterPairing({
|
|
2419
|
+
...credential,
|
|
2420
|
+
apiBase,
|
|
2421
|
+
});
|
|
2422
|
+
return completed ? EXIT.OK : EXIT.ERROR;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
1846
2425
|
/**
|
|
1847
2426
|
* The QR path. Mints a pre-claim credential, asks the server for a pairing
|
|
1848
2427
|
* token, renders it, then polls until the human approves. Returns a credential
|
|
@@ -1943,6 +2522,7 @@ async function connectByPairing(apiBase, ask) {
|
|
|
1943
2522
|
};
|
|
1944
2523
|
saveCredential(cred);
|
|
1945
2524
|
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
2525
|
+
await activateInboxAfterPairing(cred);
|
|
1946
2526
|
return cred;
|
|
1947
2527
|
}
|
|
1948
2528
|
if (status === 'expired') break;
|
|
@@ -2017,7 +2597,8 @@ async function connectByEmail(apiBase, ask) {
|
|
|
2017
2597
|
saveCredential(cred);
|
|
2018
2598
|
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
2019
2599
|
if (!cred.room) {
|
|
2020
|
-
process.stdout.write('
|
|
2600
|
+
process.stdout.write(' For room commands: pingroom config set default_room <invite code>\n');
|
|
2601
|
+
process.stdout.write(' For private Inbox/Handoff delivery, reconnect with QR pairing.\n');
|
|
2021
2602
|
}
|
|
2022
2603
|
return cred;
|
|
2023
2604
|
}
|
|
@@ -2224,6 +2805,8 @@ const COMMANDS = {
|
|
|
2224
2805
|
handoff: (rest) => handoff(parseHandoffArgs(rest)),
|
|
2225
2806
|
handoffs: (rest) => listHandoffs(parseQArgs(rest)),
|
|
2226
2807
|
hook: (rest) => hook(parseHookArgs(rest)),
|
|
2808
|
+
mcp,
|
|
2809
|
+
activate: (rest) => activateStoredInbox(parseQArgs(rest)),
|
|
2227
2810
|
live: (rest) => live(parseLiveArgs(rest)),
|
|
2228
2811
|
config: (rest) => config(parseQArgs(rest)),
|
|
2229
2812
|
logout: (rest) => logout(parseQArgs(rest)),
|
|
@@ -2242,6 +2825,11 @@ async function main() {
|
|
|
2242
2825
|
process.exit(EXIT.OK);
|
|
2243
2826
|
}
|
|
2244
2827
|
|
|
2828
|
+
if (command === '-v' || command === '--version') {
|
|
2829
|
+
process.stdout.write(`${VERSION}\n`);
|
|
2830
|
+
process.exit(EXIT.OK);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2245
2833
|
// Bare `pingroom` resolves the auth state instead of only printing help:
|
|
2246
2834
|
// connected -> status + help; not connected -> pair (interactive only).
|
|
2247
2835
|
// A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pingroom/cli",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Send PingRoom
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pingroom": "bin/pingroom.js"
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
"homepage": "https://pingroom.io",
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
32
|
-
"url": "git+https://
|
|
32
|
+
"url": "git+https://github.com/pingroom/cli.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/pingroom/cli/issues"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
38
|
"qrcode-terminal": "^0.12.0"
|