@pingroom/cli 0.6.0 → 0.6.2

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 (3) hide show
  1. package/README.md +111 -9
  2. package/bin/pingroom.js +618 -30
  3. 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.1. The corrected attachment
10
+ > limits are in the tested 0.6.2 release candidate on `main`; the public GitHub
11
+ > Action remains pinned to 0.6.1 until 0.6.2 is published and clean-install
12
+ > 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 ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
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), `config`, and `logout`.
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` with no arguments. It prints a QR code — scan it with the PingRoom
22
- app and pick both the account and the room the agent delivers to or take the
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 4 (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
+ 5 MiB each and at most 4 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
- Then set your credentials and merge the printed `hooks` block into
366
- `~/.claude/settings.json`:
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>" # a room the agent belongs to
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> to connect Cursor, Claude Desktop, or Claude Code.
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 / action.yml (a test
39
- // asserts the GitHub Action pins this exact version). `hook --print-config`
40
- // emits an `npx @pingroom/cli@<VERSION>` command, so it must match too.
41
- const VERSION = '0.6.0';
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.2';
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, <= 5 MiB);
82
+ repeat for up to 4. 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 (agent token required; reads a Claude Code hook event on stdin):
149
- --room <code> Room invite code (or env PINGROOM_ROOM)
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
- Run "pingroom" with no arguments to connect. It prints a QR code you scan with
168
- the PingRoom app — you pick the account and the delivery room there — or you
169
- can choose the emailed-code fallback. There is no "login" command: being
170
- unconnected is a state the tool resolves, not one you have to discover.
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}. The credential layer is why a token minted by a
183
- self-hosted server is never presented to ${BUILTIN_API}.
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 phone (prints the settings.json to paste):
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] = value;
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
- u = new URL(raw);
642
+ new URL(raw);
549
643
  } catch {
550
644
  fail(`${kind} is not a valid URL`, EXIT.USAGE);
551
645
  }
552
- const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
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. Only
573
- // the pairing poll passes it: there, a single DNS blip or dropped connection
574
- // would otherwise kill a 15-minute wait the human is still walking towards their
575
- // phone for. Every other caller keeps the hard exit.
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 = 5 * 1024 * 1024;
706
+ const ATTACHMENT_MAX_COUNT = 4;
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 5 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
- if (args.template) liveStatus.template = args.template;
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 (set PINGROOM_TOKEN and PINGROOM_ROOM)');
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 (set PINGROOM_TOKEN and PINGROOM_ROOM)\n');
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. Set your credentials in the environment (e.g. in your shell profile):
1657
- # export PINGROOM_TOKEN="<your agent token>"
1658
- # export PINGROOM_ROOM="<room invite code>"
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(' Pick a delivery room with: pingroom config set default_room <invite code>\n');
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.0",
4
- "description": "Send PingRoom pings and ask humans blocking questions from CI, scripts, and agents.",
3
+ "version": "0.6.2",
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://gitlab.com/pingroom/cli.git"
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"