@pingroom/cli 0.4.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.
Files changed (3) hide show
  1. package/README.md +265 -7
  2. package/bin/pingroom.js +1744 -46
  3. package/package.json +9 -3
package/bin/pingroom.js CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // @pingroom/cli — pings and human-in-the-loop questions for CI, scripts, agents.
3
- // Zero dependencies: uses Node's built-in fetch (Node >= 20).
3
+ // Node's built-in fetch (Node >= 20) plus one optional dependency,
4
+ // `qrcode-terminal`, used only to draw the pairing QR. Its absence degrades to
5
+ // printing the pair URL, so every non-interactive path stays dependency-free.
6
+ //
7
+ // Run bare (`pingroom`) it resolves its own auth: connected -> a status line and
8
+ // this help; not connected -> the pairing picker. There is deliberately no
9
+ // `login` subcommand.
4
10
  //
5
11
  // Commands:
6
12
  // ping Send a ping to a room. Webhook mode (a room URL carries its own
@@ -13,19 +19,32 @@
13
19
  // handoff Hand a decision to a specific human (ack or question) and, with
14
20
  // --wait, block until they acknowledge / answer.
15
21
  // handoffs List the agent's open handoffs or bounded recent history.
22
+ // live Drive a live progress card (iOS Live Activity / Android live
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.
26
+ // config Read/write ~/.pingroom/config.json (default_room, api_url).
27
+ // logout Forget the credential in ~/.pingroom/credentials.json.
16
28
  //
17
29
  // Exit codes: 0 success/answered/acked · 1 error · 2 bad usage · 3 expired ·
18
30
  // 4 cancelled/recipient-not-ready.
19
31
 
20
32
  import { randomBytes } from 'node:crypto';
21
- import { appendFileSync, readFileSync } from 'node:fs';
22
-
23
- // Kept in lockstep with package.json / package-lock.json / action.yml (a test
24
- // asserts the GitHub Action pins this exact version). `hook --print-config`
25
- // emits an `npx @pingroom/cli@<VERSION>` command, so it must match too.
26
- const VERSION = '0.4.0';
27
-
28
- const DEFAULT_API = process.env.PINGROOM_API_URL || 'https://api.pingroom.io';
33
+ import {
34
+ appendFileSync, chmodSync, closeSync, fchmodSync, mkdirSync, openSync,
35
+ readFileSync, renameSync, unlinkSync, writeFileSync,
36
+ } from 'node:fs';
37
+ import { homedir } from 'node:os';
38
+ import { join } from 'node:path';
39
+
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';
44
+
45
+ const BUILTIN_API = 'https://api.pingroom.io';
46
+ const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
47
+ const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
29
48
 
30
49
  const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
31
50
 
@@ -41,31 +60,46 @@ Commands:
41
60
  handoff Hand a decision (ack or question) to a specific human; with --wait,
42
61
  block until they acknowledge or answer
43
62
  handoffs List the agent's open handoffs or bounded recent history
63
+ live Drive a live progress card on the lock screen (Live Activity)
44
64
  hook Claude Code hook: ping on Stop/Notification, and route tool
45
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
69
+ config Read/write local settings (config list | get <key> | set <key> <val>)
70
+ logout Forget the stored credential
46
71
 
47
72
  ping options:
48
73
  -m, --message <text> Ping body text (required)
49
74
  -t, --title <text> Ping title (<= 40 chars)
50
75
  -a, --action <1-4> Quick-action slot to attribute the ping to
51
76
  -d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
77
+ --url <https-url> Make the ping a tappable link (absolute http(s) URL)
78
+ --button-label <t> Link button text (<= 26 chars; requires --url)
52
79
  --require-ack Keep the ping open until an eligible recipient acknowledges it
53
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
54
83
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
55
84
  --token <token> Agent access token (or env PINGROOM_TOKEN)
56
85
  --room <code> Room invite code (used with --token)
57
86
 
58
87
  ask options (agent token required):
59
88
  -p, --prompt <text> The question a human reads (required)
60
- -o, --option <v:label> An answer option; repeat for 2–4. Omit for Approve/Deny
89
+ -o, --option <v:label[:style]>
90
+ An answer option (style: primary|danger|default);
91
+ repeat for 2–4. Omit for Approve/Deny
61
92
  -c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
62
93
  --scope <s> Who answers: 'direct' (default) or 'room'
63
94
  --target <uuid> For --scope direct: a specific room member
64
95
  --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
96
+ --text-input <ph> Invite a short typed answer; <ph> is the placeholder
97
+ --text-max <n> Max typed-answer length (1..60)
65
98
  --wait Block until answered/expired/cancelled
66
99
  --timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
67
100
  -d, --data <json> Structured data object echoed back on the answer
68
101
  --correlation-id <id> Opaque id echoed on every read of this question
102
+ --reply-to <id> Id of the ping this question replies to
69
103
  --room <code> Room invite code (required for ask)
70
104
 
71
105
  list options:
@@ -90,22 +124,108 @@ handoff options (agent token required; consent scope pingroom:handoffs:create):
90
124
  handoffs options (agent token required; consent scope pingroom:handoffs:create):
91
125
  --state <s> open | all (default open)
92
126
 
93
- hook options (agent token required; reads a Claude Code hook event on stdin):
94
- --room <code> Room invite code (or env PINGROOM_ROOM)
127
+ live <start|update|end|get> options (agent token, or a room webhook):
128
+ -c, --correlation-id <id> The stream key reuse it for every ping (required)
129
+ --template <name> start only: status | steps | progress | metrics |
130
+ countdown | question | matchup (fixed at creation)
131
+ --category <name> start only: status | steps | alert. Legacy, but
132
+ 'alert' has no template equivalent and is the only
133
+ way to start time-sensitive without --require-ack
134
+ --steps <a,b,c> start only: 2-8 comma-separated step labels
135
+ -m, --message <text> The card's live message line
136
+ --progress <0..1> Progress bar / Dynamic Island gauge
137
+ --step <n> Current step index (steps template)
138
+ --metric <label:value> Repeatable, up to 3 (metrics template)
139
+ --deadline-at <epoch> Countdown target (countdown template)
140
+ --eta-at <epoch> Live ETA (status/progress templates)
141
+ --prompt <text> The ask (question template)
142
+ --option <value:label> Repeatable, up to 4 (question template). A bare
143
+ token is both value and label
144
+ --left <label:value> Left side (matchup template)
145
+ --right <label:value> Right side (matchup template)
146
+ --center <text> Center score/clock, <= 40 (matchup template)
147
+ --accent-override <#rrggbb> Semantic accent for this frame
148
+ --failed end only: finish as failed instead of done
149
+ -t, --title <text> Card title (<= 40 chars)
150
+ -a, --action <1-4> Quick-action slot supplying the icon and sound
151
+ --require-ack Add an Acknowledge button
152
+ --ack-timeout <s> Ack deadline in seconds
153
+ --room <code> Room invite code (used with --token)
154
+ -w, --webhook <url> Room webhook URL instead of a token
155
+
156
+ hook options (reads a Claude Code event; defaults to stored credentials/config):
157
+ --room <code> Room invite code (or env/config/paired room)
95
158
  --ttl <seconds> Approval-question expiry for PreToolUse (default 900)
96
159
  --quiet Suppress the informational stderr lines
97
160
  --print-config Print a ready-to-paste ~/.claude/settings.json block
98
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
+
171
+ config options:
172
+ pingroom config list Print the stored settings
173
+ pingroom config get <key> Print one setting
174
+ pingroom config set <key> <val> Store a setting (an empty value clears it)
175
+ Keys: default_room, api_url
176
+
99
177
  Shared:
100
178
  --token <token> Agent access token (or env PINGROOM_TOKEN)
101
179
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
102
180
  --json Print the raw JSON response
103
181
  -h, --help Show this help
182
+ -v, --version Show the CLI version
183
+
184
+ Connecting:
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.
202
+
203
+ The credential is written to ~/.pingroom/credentials.json (mode 0600, in a
204
+ 0700 directory). PINGROOM_HOME overrides that directory. PINGROOM_TOKEN in the
205
+ environment ALWAYS wins over the stored credential, so CI is unaffected.
206
+ "pingroom logout" forgets it.
207
+
208
+ Settings precedence, highest first:
209
+ explicit flag > env var > ~/.pingroom/config.json > the paired
210
+ credential > built-in default
211
+ So --room beats PINGROOM_ROOM beats "config set default_room", and --api beats
212
+ PINGROOM_API_URL beats "config set api_url" beats the host you paired against,
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.
217
+
218
+ Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
219
+ PINGROOM_TOKEN there instead.
104
220
 
105
221
  Examples:
106
222
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
107
223
  pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
108
224
 
225
+ # Link ping — a tappable button that opens a URL:
226
+ pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
227
+ --url https://ci.example.com/builds/512 --button-label "Open build"
228
+
109
229
  # Gate a deploy on a human tap — the chosen value prints to stdout:
110
230
  if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
111
231
  -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
@@ -128,15 +248,32 @@ Examples:
128
248
 
129
249
  pingroom handoffs --token "$T" --state all # recent history (up to 200/kind)
130
250
 
131
- # Connect Claude Code to your phone (prints the settings.json to paste):
251
+ # A live deploy card on everyone's lock screen one stream, three calls:
252
+ pingroom live start --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
253
+ --template steps --steps "Build,Test,Stage,Ship" -t "Deploy 2.1.0"
254
+ pingroom live update --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
255
+ --step 2 -m "Smoke tests green"
256
+ pingroom live end --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
257
+ -m "Live on production"
258
+ # ...or end it as a failure, which still delivers one completion alert:
259
+ # pingroom live end ... --failed -m "Rollback triggered"
260
+
261
+ # Connect Claude Code hooks to your paired credential (no env vars needed):
132
262
  pingroom hook --print-config
133
263
 
264
+ # Connect an MCP client through browser OAuth (no API key needed):
265
+ pingroom mcp
266
+
134
267
  Security:
135
268
  Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
136
269
  secrets as --webhook / --token flags: argv is visible to other users via the
137
270
  process table (ps) and may be captured in shell history. URLs must use https
138
271
  (loopback http is allowed for local dev).
139
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
+
140
277
  Exit codes: 0 on success (answered / acked), 1 on error (network/auth/5xx),
141
278
  2 on bad usage, 3 when a handoff or question expired, 4 when it was cancelled
142
279
  or the recipient was not ready (409 recipient_not_ready). A question answered
@@ -150,6 +287,186 @@ function fail(message, code = EXIT.ERROR) {
150
287
  process.exit(code);
151
288
  }
152
289
 
290
+ // --- local state (~/.pingroom) ---------------------------------------------
291
+ //
292
+ // Two files, both under a 0700 directory:
293
+ // credentials.json the agent credential this machine paired (mode 0600)
294
+ // config.json user settings: default_room, api_url
295
+ //
296
+ // PINGROOM_HOME relocates the directory (tests, sandboxes, multi-account
297
+ // shells). Every lookup is layered: explicit flag > env var > config file >
298
+ // the paired credential > built-in default. PINGROOM_TOKEN is the one env var
299
+ // that also outranks the stored credential, which is what keeps CI working
300
+ // untouched.
301
+
302
+ function pingroomHome() {
303
+ return process.env.PINGROOM_HOME || join(homedir(), '.pingroom');
304
+ }
305
+
306
+ function credentialsPath() { return join(pingroomHome(), 'credentials.json'); }
307
+ function configPath() { return join(pingroomHome(), 'config.json'); }
308
+
309
+ // Read a JSON object, or null for anything unreadable/corrupt. Local state must
310
+ // never be able to crash a ping: a hand-edited file degrades to "not set".
311
+ function readJsonFile(path) {
312
+ let raw;
313
+ try { raw = readFileSync(path, 'utf8'); } catch { return null; }
314
+ let value;
315
+ try { value = JSON.parse(raw); } catch { return null; }
316
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
317
+ return value;
318
+ }
319
+
320
+ // Write JSON with restrictive permissions, atomically.
321
+ //
322
+ // Writing in place truncates first, so a crash or a full disk between truncate
323
+ // and write leaves a half-written file — and readJsonFile() degrades anything
324
+ // unparseable to {}, so the *next* `config set` would silently drop every other
325
+ // setting. Writing a sibling temp file and renaming over the target means a
326
+ // reader only ever sees the old file or the new one, never a torn one.
327
+ //
328
+ // The temp file is opened 'wx' with mode 0600 and fchmod'd before a single byte
329
+ // is written: `mode` on an existing file is ignored and a post-write chmod
330
+ // leaves a window where the credential is world-readable. rename() carries the
331
+ // 0600 over the target, so a pre-existing loose file is tightened too.
332
+ //
333
+ // mkdirSync(recursive) returns the first path it created, or undefined when the
334
+ // directory already existed. chmod'ing only on the former keeps this from
335
+ // narrowing a directory the user deliberately created at 0755.
336
+ function writeJsonFile(path, value) {
337
+ const dir = pingroomHome();
338
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
339
+ let fd;
340
+ try {
341
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
342
+ if (created !== undefined) chmodSync(dir, 0o700);
343
+
344
+ fd = openSync(tmp, 'wx', 0o600);
345
+ fchmodSync(fd, 0o600); // defeat a permissive umask masking the open mode
346
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
347
+ closeSync(fd);
348
+ fd = undefined;
349
+ renameSync(tmp, path);
350
+ } catch (err) {
351
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
352
+ try { unlinkSync(tmp); } catch { /* never created */ }
353
+ fail(`could not write ${path}: ${err.message}`);
354
+ }
355
+ }
356
+
357
+ function readStoredCredential() {
358
+ const cred = readJsonFile(credentialsPath());
359
+ if (!cred || typeof cred.token !== 'string' || cred.token === '') return null;
360
+ return cred;
361
+ }
362
+
363
+ function readConfigFile() {
364
+ return readJsonFile(configPath()) || {};
365
+ }
366
+
367
+ /** Agent token: --token > PINGROOM_TOKEN > the paired credential. */
368
+ function resolveToken(args) {
369
+ return args.token || process.env.PINGROOM_TOKEN || readStoredCredential()?.token || undefined;
370
+ }
371
+
372
+ /**
373
+ * API base: --api > PINGROOM_API_URL > config.api_url > the host the credential
374
+ * was paired against > built-in, no trailing slash.
375
+ *
376
+ * The credential layer is not optional. saveCredential() records `api_url`, and
377
+ * a token minted by a self-hosted / staging server is only valid there; without
378
+ * this layer the next command would present that bearer to api.pingroom.io —
379
+ * leaking it to a host it was never issued for. resolveRoom() already consults
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.
386
+ */
387
+ function resolveApiBase(args) {
388
+ const raw = args.api
389
+ || process.env.PINGROOM_API_URL
390
+ || readConfigFile().api_url
391
+ || readStoredCredential()?.api_url
392
+ || BUILTIN_API;
393
+ return String(raw).replace(/\/$/, '');
394
+ }
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
+
428
+ /**
429
+ * Room invite code: --room > PINGROOM_ROOM > config.default_room > the room the
430
+ * credential was paired to. The paired room is last because it is the weakest
431
+ * signal — it is where the agent was told to deliver, not necessarily where
432
+ * this invocation means to.
433
+ */
434
+ function resolveRoom(args) {
435
+ return args.room
436
+ || process.env.PINGROOM_ROOM
437
+ || readConfigFile().default_room
438
+ || readStoredCredential()?.room?.invite_code
439
+ || undefined;
440
+ }
441
+
442
+ /**
443
+ * True when it is safe to prompt / draw a QR. Both streams must be a TTY: a
444
+ * piped stdin cannot answer a prompt and a piped stdout would capture the QR as
445
+ * garbage.
446
+ *
447
+ * The override is deliberately double-locked (internal-looking name AND
448
+ * NODE_ENV=test) and not documented in --help. A single well-known env var
449
+ * shipping in the published binary is one stray `export` away from making a CI
450
+ * job prompt into the void and poll for the full 15-minute pairing window
451
+ * instead of failing in a second.
452
+ */
453
+ function isInteractive() {
454
+ if (process.env.PINGROOM_INTERNAL_TEST_TTY === '1' && process.env.NODE_ENV === 'test') return true;
455
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
456
+ }
457
+
458
+ function sleep(ms) {
459
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
460
+ }
461
+
462
+ // Drop C0/C1 control characters before echoing server-supplied text to the
463
+ // terminal. Without this an attacker-controlled API base can smuggle ANSI
464
+ // escapes into the output and repaint, erase or overwrite the lines around them.
465
+ function stripControlChars(value) {
466
+ // eslint-disable-next-line no-control-regex
467
+ return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
468
+ }
469
+
153
470
  // --- ping (unchanged wire behaviour) ---------------------------------------
154
471
 
155
472
  function parseArgs(argv) {
@@ -160,8 +477,11 @@ function parseArgs(argv) {
160
477
  '-a': 'action', '--action': 'action',
161
478
  '-d': 'data', '--data': 'data',
162
479
  '-w': 'webhook', '--webhook': 'webhook',
480
+ '--url': 'url',
481
+ '--button-label': 'button_label',
163
482
  '--require-ack': 'require_ack',
164
483
  '--ack-timeout': 'ack_timeout',
484
+ '--attach': 'attach',
165
485
  '--token': 'token',
166
486
  '--room': 'room',
167
487
  '--api': 'api',
@@ -169,10 +489,15 @@ function parseArgs(argv) {
169
489
  '-h': 'help', '--help': 'help',
170
490
  };
171
491
  const booleans = new Set(['require_ack', 'json', 'help']);
492
+ const repeatable = new Set(['attach']);
172
493
 
173
494
  for (let i = 0; i < argv.length; i++) {
174
495
  const token = argv[i];
175
- const key = alias[token];
496
+ // Object.hasOwn, not alias[token]: a bare lookup walks the prototype chain,
497
+ // so `constructor` / `toString` / `__proto__` in flag position resolve to a
498
+ // truthy inherited value, get treated as an option, and swallow the next
499
+ // argument instead of failing as an unknown flag.
500
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
176
501
  if (key && booleans.has(key)) {
177
502
  args[key] = true;
178
503
  } else if (key) {
@@ -180,7 +505,8 @@ function parseArgs(argv) {
180
505
  if (value === undefined) {
181
506
  fail(`option ${token} needs a value`, EXIT.USAGE);
182
507
  }
183
- args[key] = value;
508
+ if (repeatable.has(key)) (args[key] ||= []).push(value);
509
+ else args[key] = value;
184
510
  } else if (token.startsWith('-')) {
185
511
  fail(`Unknown option: ${token}`, EXIT.USAGE);
186
512
  } else {
@@ -203,6 +529,9 @@ function parseQArgs(argv) {
203
529
  '--ttl': 'ttl',
204
530
  '-d': 'data', '--data': 'data',
205
531
  '--correlation-id': 'correlation_id',
532
+ '--reply-to': 'reply_to',
533
+ '--text-input': 'text_input',
534
+ '--text-max': 'text_max',
206
535
  '--timeout': 'timeout',
207
536
  '--state': 'state',
208
537
  '--token': 'token',
@@ -217,7 +546,8 @@ function parseQArgs(argv) {
217
546
 
218
547
  for (let i = 0; i < argv.length; i++) {
219
548
  const token = argv[i];
220
- const key = alias[token];
549
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
550
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
221
551
  if (key && booleans.has(key)) {
222
552
  args[key] = true;
223
553
  } else if (key) {
@@ -267,7 +597,8 @@ function parseHandoffArgs(argv) {
267
597
 
268
598
  for (let i = 0; i < argv.length; i++) {
269
599
  const token = argv[i];
270
- const key = alias[token];
600
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
601
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
271
602
  if (key && booleans.has(key)) {
272
603
  args[key] = true;
273
604
  } else if (key) {
@@ -289,17 +620,30 @@ function parseHandoffArgs(argv) {
289
620
  return args;
290
621
  }
291
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
+
292
638
  // Refuse to send a bearer token or webhook secret over cleartext http. A
293
639
  // loopback host is allowed so local dev against http://localhost still works.
294
640
  function requireSafeUrl(kind, raw) {
295
- let u;
296
641
  try {
297
- u = new URL(raw);
642
+ new URL(raw);
298
643
  } catch {
299
644
  fail(`${kind} is not a valid URL`, EXIT.USAGE);
300
645
  }
301
- const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
302
- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopback)) {
646
+ if (!isSafeUrl(raw)) {
303
647
  fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
304
648
  }
305
649
  return raw;
@@ -318,7 +662,11 @@ function parseDataObject(raw) {
318
662
  return data;
319
663
  }
320
664
 
321
- async function httpJson(method, url, { body, headers = {} } = {}) {
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 } = {}) {
322
670
  let res;
323
671
  try {
324
672
  res = await fetch(url, {
@@ -329,18 +677,111 @@ async function httpJson(method, url, { body, headers = {} } = {}) {
329
677
  ...headers,
330
678
  },
331
679
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
680
+ ...(signal ? { signal } : {}),
332
681
  });
333
682
  } catch (err) {
683
+ if (soft) return { res: null, text: '', json: null, error: err };
334
684
  fail(`network error: ${err.message}`);
335
685
  }
336
686
 
337
- const text = await res.text();
687
+ let text;
688
+ try {
689
+ text = await res.text();
690
+ } catch (err) {
691
+ // A connection dropped mid-body throws here, not at fetch().
692
+ if (soft) return { res: null, text: '', json: null, error: err };
693
+ fail(`network error: ${err.message}`);
694
+ }
338
695
  let json = null;
339
696
  try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
340
697
 
341
698
  return { res, text, json };
342
699
  }
343
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
+
344
785
  async function ping(args) {
345
786
  if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
346
787
 
@@ -367,12 +808,45 @@ async function ping(args) {
367
808
  data = parseDataObject(args.data);
368
809
  }
369
810
 
811
+ // Link ping: --url/--button-label fold into the structured data object
812
+ // (server contract: data.url = absolute http(s) <= 2048, data.button_label <= 26).
813
+ if (args.button_label !== undefined && args.url === undefined) {
814
+ fail('--button-label requires --url', EXIT.USAGE);
815
+ }
816
+ if (args.url !== undefined) {
817
+ let linkUrl;
818
+ try {
819
+ linkUrl = new URL(args.url);
820
+ } catch {
821
+ fail('--url is not a valid URL', EXIT.USAGE);
822
+ }
823
+ if (linkUrl.protocol !== 'https:' && linkUrl.protocol !== 'http:') {
824
+ fail('--url must be an absolute http(s) URL', EXIT.USAGE);
825
+ }
826
+ if (args.url.length > 2048) {
827
+ fail('--url must be at most 2048 characters', EXIT.USAGE);
828
+ }
829
+ if (args.button_label !== undefined && args.button_label.length > 26) {
830
+ fail('--button-label must be at most 26 characters', EXIT.USAGE);
831
+ }
832
+ data = { ...(data || {}), url: args.url };
833
+ if (args.button_label !== undefined) data.button_label = args.button_label;
834
+ }
835
+
370
836
  const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
371
- const token = args.token || process.env.PINGROOM_TOKEN;
372
- const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
837
+ const token = resolveToken(args);
838
+ const apiBase = resolveApiBase(args);
839
+ const room = resolveRoom(args);
373
840
 
374
841
  let result;
375
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
+
376
850
  if (webhook) {
377
851
  if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
378
852
  fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
@@ -386,21 +860,25 @@ async function ping(args) {
386
860
  if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
387
861
  result = await httpJson('POST', webhook, { body });
388
862
  } else if (token) {
389
- if (!args.room) fail('--room is required when using --token', EXIT.USAGE);
863
+ requireStoredCredentialOrigin(args, apiBase);
864
+ if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
390
865
  if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
391
866
  fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
392
867
  }
393
868
  requireSafeUrl('--api', apiBase);
394
- const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(args.room)}/notifications`;
869
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`;
395
870
  const body = { message };
396
871
  if (args.title) body.title = args.title;
397
872
  if (args.action !== undefined) body.action_number = Number(args.action);
398
873
  if (data) body.data = data;
399
874
  if (args.require_ack) body.requires_ack = true;
400
875
  if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
876
+ if (attachPaths.length) {
877
+ body.attachment_ids = await uploadAttachments(attachPaths, apiBase, token);
878
+ }
401
879
  result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
402
880
  } else {
403
- fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
881
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
404
882
  }
405
883
 
406
884
  const { res, text, json } = result;
@@ -420,15 +898,291 @@ async function ping(args) {
420
898
  return EXIT.OK;
421
899
  }
422
900
 
901
+ // --- live status -----------------------------------------------------------
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
+
908
+ // Parser for `live`: a leading subcommand (start|update|end|get) plus the
909
+ // live-status flags. Unknown flags fail like the other parsers.
910
+ function parseLiveArgs(argv) {
911
+ const args = { _: [] };
912
+ const alias = {
913
+ '-c': 'correlation_id', '--correlation-id': 'correlation_id',
914
+ '-t': 'title', '--title': 'title',
915
+ '-m': 'message', '--message': 'message',
916
+ '--template': 'template',
917
+ '--category': 'category',
918
+ '--progress': 'progress',
919
+ '--step': 'step',
920
+ '--steps': 'steps',
921
+ '--metric': 'metric',
922
+ '--deadline-at': 'deadline_at',
923
+ '--eta-at': 'eta_at',
924
+ '--prompt': 'prompt',
925
+ '--option': 'option',
926
+ '--left': 'left',
927
+ '--right': 'right',
928
+ '--center': 'center',
929
+ '--accent-override': 'accent_override',
930
+ '--failed': 'failed',
931
+ '-a': 'action', '--action': 'action',
932
+ '-d': 'data', '--data': 'data',
933
+ '--require-ack': 'require_ack',
934
+ '--ack-timeout': 'ack_timeout',
935
+ '-w': 'webhook', '--webhook': 'webhook',
936
+ '--token': 'token',
937
+ '--room': 'room',
938
+ '--api': 'api',
939
+ '--json': 'json',
940
+ '-h': 'help', '--help': 'help',
941
+ };
942
+ const booleans = new Set(['require_ack', 'json', 'help', 'failed']);
943
+ const repeatable = new Set(['metric', 'option']);
944
+
945
+ for (let i = 0; i < argv.length; i++) {
946
+ const token = argv[i];
947
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
948
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
949
+ if (key && booleans.has(key)) {
950
+ args[key] = true;
951
+ } else if (key) {
952
+ const value = argv[++i];
953
+ if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
954
+ if (repeatable.has(key)) (args[key] ||= []).push(value);
955
+ else args[key] = value;
956
+ } else if (token.startsWith('-')) {
957
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
958
+ } else {
959
+ args._.push(token);
960
+ }
961
+ }
962
+ return args;
963
+ }
964
+
965
+ // "label:value" -> {label, value}. Only the first colon splits.
966
+ function buildMetrics(list) {
967
+ if (!list || list.length === 0) return undefined;
968
+ return list.map((spec) => {
969
+ const idx = spec.indexOf(':');
970
+ if (idx <= 0) fail(`--metric must be "label:value" (got "${spec}")`, EXIT.USAGE);
971
+ return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
972
+ });
973
+ }
974
+
975
+ // "value:label" -> {value, label}; a bare token is both. Matches the `ask`
976
+ // command's option syntax minus `style`, which live_status options don't carry.
977
+ function buildLiveOptions(list) {
978
+ if (!list || list.length === 0) return undefined;
979
+ return list.map((spec) => {
980
+ const idx = spec.indexOf(':');
981
+ if (idx < 0) return { value: spec, label: spec };
982
+ if (idx === 0) fail(`--option needs a value before the colon (got "${spec}")`, EXIT.USAGE);
983
+ return { value: spec.slice(0, idx), label: spec.slice(idx + 1) };
984
+ });
985
+ }
986
+
987
+ // "label:value" -> {label, value}, for --left / --right on the matchup template.
988
+ function buildSide(spec, flag) {
989
+ if (spec === undefined) return undefined;
990
+ const idx = spec.indexOf(':');
991
+ if (idx <= 0) fail(`${flag} must be "label:value" (got "${spec}")`, EXIT.USAGE);
992
+ return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
993
+ }
994
+
995
+ // The server accepts #rrggbb with or without the leading #; normalize to one
996
+ // form so a shell that ate the # (unquoted) still produces a valid payload.
997
+ function normalizeAccent(raw) {
998
+ if (raw === undefined) return undefined;
999
+ const hex = raw.trim().replace(/^#/, '');
1000
+ if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
1001
+ fail(`--accent-override must be a 6-digit hex color (got "${raw}")`, EXIT.USAGE);
1002
+ }
1003
+ return `#${hex.toLowerCase()}`;
1004
+ }
1005
+
1006
+ function numberOption(raw, flag, { min, max, integer = false } = {}) {
1007
+ if (raw === undefined) return undefined;
1008
+ const value = Number(raw);
1009
+ if (!Number.isFinite(value)) fail(`${flag} must be a number`, EXIT.USAGE);
1010
+ if (integer && !Number.isInteger(value)) fail(`${flag} must be an integer`, EXIT.USAGE);
1011
+ if (min !== undefined && value < min) fail(`${flag} must be at least ${min}`, EXIT.USAGE);
1012
+ if (max !== undefined && value > max) fail(`${flag} must be at most ${max}`, EXIT.USAGE);
1013
+ return value;
1014
+ }
1015
+
1016
+ /**
1017
+ * Drive a live progress card on the room members' lock screen.
1018
+ *
1019
+ * One correlation id = one stream: `start` opens it (one alert), `update` moves
1020
+ * it silently, `end` closes it with one completion alert. Works with either an
1021
+ * agent token (--token, needs pingroom:live:write) or a room's incoming webhook
1022
+ * (--webhook), which speak the same `live_status` contract.
1023
+ */
1024
+ async function live(args) {
1025
+ const sub = args._[0];
1026
+ const known = ['start', 'update', 'end', 'get'];
1027
+ if (!sub || !known.includes(sub)) {
1028
+ fail(`live needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
1029
+ }
1030
+
1031
+ const correlationId = args.correlation_id;
1032
+ if (!correlationId) fail('--correlation-id is required', EXIT.USAGE);
1033
+
1034
+ const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
1035
+ const token = resolveToken(args);
1036
+ const apiBase = resolveApiBase(args);
1037
+ const room = resolveRoom(args);
1038
+
1039
+ if (sub === 'get') {
1040
+ if (!token) fail('live get requires an agent token (--token or PINGROOM_TOKEN)', EXIT.USAGE);
1041
+ requireStoredCredentialOrigin(args, apiBase);
1042
+ if (!room) fail('--room is required', EXIT.USAGE);
1043
+ requireSafeUrl('--api', apiBase);
1044
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live/${encodeURIComponent(correlationId)}`;
1045
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1046
+ if (args.json) process.stdout.write(`${text || '{}'}\n`);
1047
+ if (!res.ok) {
1048
+ fail(`read failed: ${(json && (json.message || json.code)) || `HTTP ${res.status}`}`);
1049
+ }
1050
+ if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
1051
+ return EXIT.OK;
1052
+ }
1053
+
1054
+ const liveStatus = {
1055
+ state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
1056
+ };
1057
+
1058
+ if (args.message !== undefined) liveStatus.message = args.message;
1059
+ if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
1060
+
1061
+ const progress = numberOption(args.progress, '--progress', { min: 0, max: 1 });
1062
+ if (progress !== undefined) liveStatus.progress = progress;
1063
+
1064
+ const step = numberOption(args.step, '--step', { min: 0, max: 8, integer: true });
1065
+ if (step !== undefined) liveStatus.current_step = step;
1066
+
1067
+ const deadlineAt = numberOption(args.deadline_at, '--deadline-at', { min: 0, integer: true });
1068
+ if (deadlineAt !== undefined) liveStatus.deadline_at = deadlineAt;
1069
+
1070
+ const etaAt = numberOption(args.eta_at, '--eta-at', { min: 0, integer: true });
1071
+ if (etaAt !== undefined) liveStatus.eta_at = etaAt;
1072
+
1073
+ const metrics = buildMetrics(args.metric);
1074
+ if (metrics) liveStatus.metrics = metrics;
1075
+
1076
+ const options = buildLiveOptions(args.option);
1077
+ if (options) {
1078
+ if (options.length > 4) fail('--option accepts at most 4 choices', EXIT.USAGE);
1079
+ liveStatus.options = options;
1080
+ }
1081
+
1082
+ const left = buildSide(args.left, '--left');
1083
+ if (left) liveStatus.left = left;
1084
+ const right = buildSide(args.right, '--right');
1085
+ if (right) liveStatus.right = right;
1086
+ if (args.center !== undefined) liveStatus.center = args.center;
1087
+
1088
+ const accent = normalizeAccent(args.accent_override);
1089
+ if (accent) liveStatus.accent_override = accent;
1090
+
1091
+ // Template, category and step labels are fixed when the stream is created;
1092
+ // sending them on an update is a no-op server-side, so only `start` takes them.
1093
+ if (sub === 'start') {
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
+ }
1103
+ // `alert` has no template equivalent and is the only way to start a stream
1104
+ // time-sensitive (breaking through Focus) without also demanding an ack.
1105
+ if (args.category) {
1106
+ if (!['status', 'steps', 'alert'].includes(args.category)) {
1107
+ fail('--category must be status, steps or alert', EXIT.USAGE);
1108
+ }
1109
+ liveStatus.category = args.category;
1110
+ }
1111
+ if (args.steps) {
1112
+ const labels = args.steps.split(',').map((s) => s.trim()).filter(Boolean);
1113
+ if (labels.length < 2 || labels.length > 8) {
1114
+ fail('--steps needs between 2 and 8 comma-separated labels', EXIT.USAGE);
1115
+ }
1116
+ liveStatus.steps = labels;
1117
+ }
1118
+ } else if (args.template || args.steps || args.category) {
1119
+ fail('--template, --category and --steps are fixed at stream creation; pass them to "live start"', EXIT.USAGE);
1120
+ }
1121
+
1122
+ const body = { correlation_id: correlationId, live_status: liveStatus };
1123
+ if (args.title) body.title = args.title;
1124
+ if (args.action !== undefined) body.action = Number(args.action);
1125
+ // Same object-shape guard ping/ask/handoff use. A bare JSON.parse also accepts
1126
+ // an array, which the server then rejects — a wasted round trip for what is a
1127
+ // local usage error.
1128
+ // `!== undefined`, not truthiness: `-d ''` is a malformed value, and a
1129
+ // truthiness test drops it on the floor and ships the ping without the data
1130
+ // the caller believed they attached. ping/ask/handoff all reject it loudly.
1131
+ if (args.data !== undefined) body.data = parseDataObject(args.data);
1132
+ if (args.require_ack) body.requires_ack = true;
1133
+ const ackTimeout = numberOption(args.ack_timeout, '--ack-timeout', { min: 1, max: 86_400, integer: true });
1134
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
1135
+
1136
+ let result;
1137
+ if (webhook) {
1138
+ requireSafeUrl('--webhook', webhook);
1139
+ result = await httpJson('POST', webhook, { body });
1140
+ } else if (token) {
1141
+ requireStoredCredentialOrigin(args, apiBase);
1142
+ if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
1143
+ requireSafeUrl('--api', apiBase);
1144
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
1145
+ result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
1146
+ } else {
1147
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
1148
+ }
1149
+
1150
+ const { res, text, json } = result;
1151
+ if (args.json) process.stdout.write(`${text || '{}'}\n`);
1152
+
1153
+ if (!res.ok || (json && json.success === false)) {
1154
+ const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
1155
+ fail(`live ${sub} failed: ${detail}`);
1156
+ }
1157
+
1158
+ if (!args.json) {
1159
+ const state = (json && (json.state || (json.live_status && json.live_status.state))) || sub;
1160
+ process.stdout.write(`live ${sub} → ${state} ✅\n`);
1161
+ }
1162
+ return EXIT.OK;
1163
+ }
1164
+
423
1165
  // --- questions -------------------------------------------------------------
424
1166
 
1167
+ // Resolve the credential + endpoint a token-only command needs. When nothing is
1168
+ // available this is a usage error pointing at PINGROOM_TOKEN — never a prompt,
1169
+ // so a CI job fails in a second instead of hanging on an invisible question.
425
1170
  function agentContext(args, { needRoom = false } = {}) {
426
- const token = args.token || process.env.PINGROOM_TOKEN;
427
- if (!token) fail('an agent token is required (--token or PINGROOM_TOKEN)', EXIT.USAGE);
428
- const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
1171
+ const token = resolveToken(args);
1172
+ if (!token) {
1173
+ fail(
1174
+ 'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine; in CI set PINGROOM_TOKEN.',
1175
+ EXIT.USAGE,
1176
+ );
1177
+ }
1178
+ const apiBase = resolveApiBase(args);
1179
+ requireStoredCredentialOrigin(args, apiBase);
429
1180
  requireSafeUrl('--api', apiBase);
430
- if (needRoom && !args.room) fail('--room is required', EXIT.USAGE);
431
- return { token, apiBase, room: args.room };
1181
+ const room = resolveRoom(args);
1182
+ if (needRoom && !room) {
1183
+ fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
1184
+ }
1185
+ return { token, apiBase, room };
432
1186
  }
433
1187
 
434
1188
  // value:label -> {value, label}. Labels may contain colons (only the first
@@ -438,9 +1192,20 @@ function buildOptions(list) {
438
1192
  return list.map((spec) => {
439
1193
  const idx = spec.indexOf(':');
440
1194
  const value = idx === -1 ? spec : spec.slice(0, idx);
441
- const label = idx === -1 ? spec : spec.slice(idx + 1);
442
- if (!value) fail(`--option must be "value" or "value:label" (got "${spec}")`, EXIT.USAGE);
443
- return { value, label };
1195
+ let label = idx === -1 ? spec : spec.slice(idx + 1);
1196
+ if (!value) fail(`--option must be "value", "value:label" or "value:label:style" (got "${spec}")`, EXIT.USAGE);
1197
+ // A trailing :primary|:danger|:default segment styles the button; any other
1198
+ // trailing segment stays part of the label (labels may contain colons).
1199
+ let style;
1200
+ const lastColon = label.lastIndexOf(':');
1201
+ if (lastColon !== -1) {
1202
+ const candidate = label.slice(lastColon + 1);
1203
+ if (candidate === 'primary' || candidate === 'danger' || candidate === 'default') {
1204
+ style = candidate;
1205
+ label = label.slice(0, lastColon);
1206
+ }
1207
+ }
1208
+ return style ? { value, label, style } : { value, label };
444
1209
  });
445
1210
  }
446
1211
 
@@ -511,6 +1276,19 @@ async function ask(args) {
511
1276
  body.ttl = Number(args.ttl);
512
1277
  }
513
1278
  if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
1279
+ if (args.reply_to !== undefined) body.reply_to = args.reply_to;
1280
+ if (args.text_input !== undefined || args.text_max !== undefined) {
1281
+ const textInput = {};
1282
+ if (args.text_input) textInput.placeholder = String(args.text_input).slice(0, 60);
1283
+ if (args.text_max !== undefined) {
1284
+ const n = Number(args.text_max);
1285
+ if (!/^\d+$/.test(String(args.text_max)) || n < 1 || n > 60) {
1286
+ fail('--text-max must be an integer between 1 and 60', EXIT.USAGE);
1287
+ }
1288
+ textInput.max_length = n;
1289
+ }
1290
+ body.text_input = textInput;
1291
+ }
514
1292
  if (args.data !== undefined) body.data = parseDataObject(args.data);
515
1293
 
516
1294
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
@@ -825,7 +1603,8 @@ function parseHookArgs(argv) {
825
1603
 
826
1604
  for (let i = 0; i < argv.length; i++) {
827
1605
  const token = argv[i];
828
- const key = alias[token];
1606
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1607
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
829
1608
  if (key && booleans.has(key)) {
830
1609
  args[key] = true;
831
1610
  } else if (key) {
@@ -952,7 +1731,7 @@ async function hookWaitForAnswer(id, { token, apiBase }) {
952
1731
 
953
1732
  async function hookPreToolUse(event, { token, room, apiBase, args }) {
954
1733
  if (!token || !room) {
955
- 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)');
956
1735
  return EXIT.OK;
957
1736
  }
958
1737
 
@@ -1022,7 +1801,7 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
1022
1801
 
1023
1802
  async function hookNotify(event, name, { token, room, apiBase, args }) {
1024
1803
  if (!token || !room) {
1025
- 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');
1026
1805
  return EXIT.OK;
1027
1806
  }
1028
1807
 
@@ -1079,9 +1858,12 @@ function printHookConfig() {
1079
1858
  process.stdout.write(
1080
1859
  `# PingRoom × Claude Code — merge this into ~/.claude/settings.json
1081
1860
  #
1082
- # 1. Set your credentials in the environment (e.g. in your shell profile):
1083
- # export PINGROOM_TOKEN="<your agent token>"
1084
- # 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.
1085
1867
  #
1086
1868
  # 2. Merge the "hooks" block below into ~/.claude/settings.json.
1087
1869
  # Stop / Notification -> ping your phone.
@@ -1091,6 +1873,7 @@ function printHookConfig() {
1091
1873
  #
1092
1874
  # If PingRoom is unreachable the hook defers to the normal local prompt — it
1093
1875
  # never auto-approves and never blocks the agent.
1876
+ # PINGROOM_TOKEN / PINGROOM_ROOM remain supported for CI and headless shells.
1094
1877
 
1095
1878
  ${JSON.stringify(config, null, 2)}
1096
1879
  `);
@@ -1105,9 +1888,37 @@ async function hook(args) {
1105
1888
  if (raw) { try { event = JSON.parse(raw); } catch { event = {}; } }
1106
1889
  const name = event.hook_event_name || '';
1107
1890
 
1108
- const token = args.token || process.env.PINGROOM_TOKEN;
1109
- const room = args.room || process.env.PINGROOM_ROOM;
1110
- const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
1891
+ // The hook fails open, so it reads the same layered config as everything else
1892
+ // but never complains about a missing piece — it just defers.
1893
+ const token = resolveToken(args);
1894
+ const room = resolveRoom(args);
1895
+ const apiBase = resolveApiBase(args);
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
+ }
1111
1922
 
1112
1923
  if (name === 'PreToolUse') {
1113
1924
  return hookPreToolUse(event, { token, room, apiBase, args });
@@ -1115,6 +1926,875 @@ async function hook(args) {
1115
1926
  return hookNotify(event, name, { token, room, apiBase, args });
1116
1927
  }
1117
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
+
1973
+ // --- connecting (pairing + email fallback) ---------------------------------
1974
+ //
1975
+ // Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture —
1976
+ // scanning the QR is where the human picks BOTH the account and the delivery
1977
+ // room, so an agent can never end up connected with nobody's say-so about where
1978
+ // it pings. There is no `login` subcommand: `pingroom` resolves the state.
1979
+
1980
+ // The scopes this CLI can actually use, one per command surface. Requested at
1981
+ // registration so the approval screen shows exactly what it is granting; the
1982
+ // server intersects, so asking for less is always safe and asking for more than
1983
+ // the human approves is impossible.
1984
+ const CLI_SCOPES = [
1985
+ 'pingroom:rooms:read', // resolve/display the connected room
1986
+ 'pingroom:broadcast:send', // ping
1987
+ 'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
1988
+ 'pingroom:handoffs:create', // handoff / handoffs
1989
+ 'pingroom:live:write', // live start/update/end/get
1990
+ ];
1991
+
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
+ }
2015
+
2016
+ // Widest QR we render (compact half-block form of a ~110-char pair URL is 39
2017
+ // columns). Anything narrower would wrap and become unscannable, so we print
2018
+ // the URL alone instead of a broken QR.
2019
+ const QR_MIN_COLUMNS = 41;
2020
+
2021
+ /**
2022
+ * Draw the pair URL as a scannable QR. Returns false when it could not — a too
2023
+ * narrow terminal, or the optional dependency being absent (someone vendored
2024
+ * just bin/) — and the caller falls back to the printed URL, which always works.
2025
+ */
2026
+ async function renderQr(url) {
2027
+ // A real terminal reports its width on the stream; COLUMNS covers the rest.
2028
+ // Unknown width is treated as wide enough — the URL is printed either way.
2029
+ const columns = Number(process.stdout.columns || process.env.COLUMNS || 0);
2030
+ if (columns > 0 && columns < QR_MIN_COLUMNS) return false;
2031
+
2032
+ let qr;
2033
+ try {
2034
+ const mod = await import('qrcode-terminal');
2035
+ qr = mod.default || mod;
2036
+ } catch { return false; }
2037
+ if (!qr || typeof qr.generate !== 'function') return false;
2038
+
2039
+ try {
2040
+ let art = '';
2041
+ // Call it as a method: qrcode-terminal reads its error-correction level off
2042
+ // `this`, so a detached `generate` reference silently builds a version-1
2043
+ // code and throws on anything longer than a few characters.
2044
+ // `small` is the half-block form: two module rows per text row, so the code
2045
+ // stays square-ish and fits an 80-column terminal.
2046
+ qr.generate(url, { small: true }, (rendered) => { art = rendered; });
2047
+ if (!art) return false;
2048
+ process.stdout.write(`\n${art}\n`);
2049
+ return true;
2050
+ } catch { return false; }
2051
+ }
2052
+
2053
+ /**
2054
+ * A line-at-a-time reader over stdin.
2055
+ *
2056
+ * Deliberately not node:readline: its Interface keeps consuming while we are
2057
+ * awaiting an HTTP round trip between two questions and drops the lines nobody
2058
+ * is listening for, which silently loses piped answers. This queues every line
2059
+ * instead, so the answers can arrive in one blob or one keystroke at a time.
2060
+ *
2061
+ * ask() resolves `null` — never a string — once the input is closed, so it can
2062
+ * never be confused with a real empty line. That distinction is load-bearing:
2063
+ * callers treat an empty line as "take the default", and a caller that reads EOF
2064
+ * as an empty line will take that default again on the next question, and the
2065
+ * next, forever, because nothing will ever arrive to change its mind. Callers
2066
+ * that genuinely want the empty-line behaviour opt in with `?? ''`.
2067
+ */
2068
+ function createPrompter() {
2069
+ const queued = [];
2070
+ const waiting = [];
2071
+ let buffer = '';
2072
+ let closed = false;
2073
+
2074
+ const deliver = (line) => {
2075
+ const waiter = waiting.shift();
2076
+ if (waiter) waiter(line);
2077
+ else queued.push(line);
2078
+ };
2079
+ const onData = (chunk) => {
2080
+ buffer += chunk;
2081
+ let idx;
2082
+ while ((idx = buffer.indexOf('\n')) !== -1) {
2083
+ deliver(buffer.slice(0, idx).replace(/\r$/, ''));
2084
+ buffer = buffer.slice(idx + 1);
2085
+ }
2086
+ };
2087
+ const onEnd = () => {
2088
+ if (closed) return;
2089
+ closed = true;
2090
+ if (buffer) { deliver(buffer); buffer = ''; }
2091
+ while (waiting.length) waiting.shift()(null);
2092
+ };
2093
+
2094
+ process.stdin.setEncoding('utf8');
2095
+ process.stdin.on('data', onData);
2096
+ process.stdin.once('end', onEnd);
2097
+ process.stdin.resume();
2098
+
2099
+ return {
2100
+ ask(question) {
2101
+ process.stdout.write(question);
2102
+ if (queued.length > 0) return Promise.resolve(queued.shift());
2103
+ if (closed) return Promise.resolve(null);
2104
+ return new Promise((resolve) => { waiting.push(resolve); });
2105
+ },
2106
+ close() {
2107
+ process.stdin.off('data', onData);
2108
+ process.stdin.off('end', onEnd);
2109
+ process.stdin.pause();
2110
+ },
2111
+ };
2112
+ }
2113
+
2114
+ /** POST /api/agent/auth — anonymous registration, yields the pre-claim credential. */
2115
+ async function registerAnonymous(apiBase) {
2116
+ const { res, json } = await httpJson('POST', `${apiBase}/api/agent/auth`, {
2117
+ body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
2118
+ });
2119
+ if (!res.ok || !json || typeof json.credential !== 'string') {
2120
+ const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
2121
+ fail(`could not start a connection: ${detail}`);
2122
+ }
2123
+ return json.credential;
2124
+ }
2125
+
2126
+ /** Persist the active credential plus the bits the status line prints. */
2127
+ function saveCredential({ token, handle, room, account, scopes, apiBase }) {
2128
+ writeJsonFile(credentialsPath(), {
2129
+ version: 1,
2130
+ token,
2131
+ handle: handle || null,
2132
+ room: room || null,
2133
+ account: account || null,
2134
+ scopes: scopes || [],
2135
+ api_url: apiBase,
2136
+ created_at: new Date().toISOString(),
2137
+ });
2138
+ }
2139
+
2140
+ /** "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown. */
2141
+ function connectedLine(cred) {
2142
+ const who = cred.handle ? `@${cred.handle}` : 'this machine';
2143
+ const room = cred.room && (cred.room.name || cred.room.invite_code);
2144
+ return `✓ Connected as ${who}${room ? ` → #${room}` : ''}`;
2145
+ }
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
+
2425
+ /**
2426
+ * The QR path. Mints a pre-claim credential, asks the server for a pairing
2427
+ * token, renders it, then polls until the human approves. Returns a credential
2428
+ * object, or null when the pairing lapsed and the user declined a fresh one.
2429
+ */
2430
+ async function connectByPairing(apiBase, ask) {
2431
+ for (;;) {
2432
+ const preClaim = await registerAnonymous(apiBase);
2433
+ const headers = { Authorization: `Bearer ${preClaim}` };
2434
+
2435
+ const start = await httpJson('POST', `${apiBase}/api/agent/auth/pair/start`, {
2436
+ body: { scopes: CLI_SCOPES },
2437
+ headers,
2438
+ });
2439
+ if (!start.res.ok || !start.json || typeof start.json.pair_url !== 'string') {
2440
+ const detail = (start.json && (start.json.message || start.json.error || start.json.code))
2441
+ || `HTTP ${start.res.status}`;
2442
+ fail(`could not start pairing: ${detail}`);
2443
+ }
2444
+
2445
+ // The URL is server-controlled and goes straight to the terminal, so strip
2446
+ // C0/C1 controls: an --api / config api_url pointing at a hostile host could
2447
+ // otherwise emit ANSI escapes that repaint or hide the line the user is
2448
+ // about to trust with their account.
2449
+ const pairUrl = stripControlChars(start.json.pair_url);
2450
+ // 900s is the server's pre-claim lifetime; never poll past it, and clamp the
2451
+ // server's suggested interval so a bad value can't busy-loop or stall.
2452
+ // The 1000ms floor is not cosmetic: AGENT_PAIRING_SPEC.md throttles
2453
+ // pair/status at `60,1`, so a faster floor spends the pairing window
2454
+ // collecting 429s instead of the approval.
2455
+ const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
2456
+ const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
2457
+ const deadline = Date.now() + lifetimeMs;
2458
+
2459
+ const drew = await renderQr(pairUrl);
2460
+ process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
2461
+ process.stdout.write(' Waiting for approval… ');
2462
+
2463
+ // A transient failure must not end a wait the human is mid-way through.
2464
+ // Network errors, 5xx and 429 are the load balancer / rate limiter talking,
2465
+ // not the pairing being over; hard-failing on the first one throws away the
2466
+ // whole 15 minutes over a single blip. 401/403/404 still exit immediately —
2467
+ // those say the pre-claim is gone, and retrying can only spin.
2468
+ // The `Date.now() < deadline` bound is what keeps a *persistent* outage from
2469
+ // retrying forever: it ends at the same moment a clean poll would have.
2470
+ let transientRun = 0;
2471
+ let lastTransient = null;
2472
+ let warnedTransient = false;
2473
+
2474
+ while (Date.now() < deadline) {
2475
+ const { res, json, error } = await httpJson(
2476
+ 'GET', `${apiBase}/api/agent/auth/pair/status`, { headers, soft: true },
2477
+ );
2478
+
2479
+ if (error || res.status >= 500 || res.status === 429) {
2480
+ transientRun += 1;
2481
+ lastTransient = error
2482
+ ? error.message
2483
+ : `HTTP ${res.status}`;
2484
+ // Say something rather than sitting mute: a user watching a QR with no
2485
+ // output cannot tell a slow approval from a broken endpoint.
2486
+ if (transientRun === 3 && !warnedTransient) {
2487
+ warnedTransient = true;
2488
+ process.stdout.write(`\n (still trying — ${lastTransient}) `);
2489
+ }
2490
+ // Ride out a short blip at the normal cadence, then back off
2491
+ // geometrically so a real outage is not also a thundering herd. Never
2492
+ // sleep past the deadline this loop is bounded by.
2493
+ const backoff = Math.min(intervalMs * 2 ** Math.max(0, transientRun - 3), 30_000);
2494
+ await sleep(Math.max(0, Math.min(backoff, deadline - Date.now())));
2495
+ continue;
2496
+ }
2497
+
2498
+ transientRun = 0;
2499
+
2500
+ if (!res.ok) {
2501
+ process.stdout.write('\n');
2502
+ const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
2503
+ fail(`pairing failed: ${detail}`);
2504
+ }
2505
+ const status = json && json.status;
2506
+ if (status === 'active') {
2507
+ // A server that says "active" with no credential has not paired us.
2508
+ // Without this, `token: undefined` is written to credentials.json and
2509
+ // every later command reads a credential file that exists but cannot
2510
+ // authenticate — a far more confusing failure than stopping here.
2511
+ if (typeof json.credential !== 'string' || json.credential === '') {
2512
+ process.stdout.write('\n');
2513
+ fail('pairing succeeded but the server returned no credential');
2514
+ }
2515
+ const cred = {
2516
+ token: json.credential,
2517
+ handle: json.handle,
2518
+ room: json.room,
2519
+ account: json.account,
2520
+ scopes: json.scopes,
2521
+ apiBase,
2522
+ };
2523
+ saveCredential(cred);
2524
+ process.stdout.write(`${connectedLine(cred)}\n`);
2525
+ await activateInboxAfterPairing(cred);
2526
+ return cred;
2527
+ }
2528
+ if (status === 'expired') break;
2529
+ // `pending` (or anything unrecognized) — keep waiting.
2530
+ await sleep(intervalMs);
2531
+ }
2532
+
2533
+ if (transientRun > 0) {
2534
+ process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
2535
+ } else {
2536
+ process.stdout.write(`\n That code expired.\n`);
2537
+ }
2538
+
2539
+ // `null` means the input is closed, and that is the whole point of this
2540
+ // guard. Reading EOF as "" would fall through the y/yes test below (empty
2541
+ // means "take the default: yes"), restart the for(;;), mint another
2542
+ // anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
2543
+ // a single pairing attempt into thousands of registrations against the API.
2544
+ const again = await ask(' Show a fresh QR code? [Y/n]: ');
2545
+ if (again === null) { process.stdout.write('\n'); return null; }
2546
+ const answer = again.trim().toLowerCase();
2547
+ if (answer && answer !== 'y' && answer !== 'yes') return null;
2548
+ }
2549
+ }
2550
+
2551
+ /**
2552
+ * The email fallback, over the unchanged claim/* endpoints: the server mails a
2553
+ * link, the web page shows a 6-digit code, the user reads it back here.
2554
+ */
2555
+ async function connectByEmail(apiBase, ask) {
2556
+ const preClaim = await registerAnonymous(apiBase);
2557
+ const headers = { Authorization: `Bearer ${preClaim}` };
2558
+
2559
+ // `?? ''` preserves the old EOF behaviour deliberately: ask() now returns null
2560
+ // at EOF, and without the coalesce this would throw a TypeError on `.trim()`
2561
+ // instead of reaching the "this is required" error the user should see.
2562
+ const email = (await ask(' Your PingRoom email: ') ?? '').trim();
2563
+ if (!email) fail('an email address is required', EXIT.USAGE);
2564
+
2565
+ const start = await httpJson('POST', `${apiBase}/api/agent/auth/claim/start`, {
2566
+ body: { email },
2567
+ headers,
2568
+ });
2569
+ if (!start.res.ok) {
2570
+ const detail = (start.json && (start.json.message || start.json.error || start.json.code))
2571
+ || `HTTP ${start.res.status}`;
2572
+ fail(`could not send the email: ${detail}`);
2573
+ }
2574
+
2575
+ process.stdout.write(' Sent. Open the link in that email — the page shows a 6-digit code.\n');
2576
+
2577
+ // A mistyped code is the common case, so allow a few tries before giving up.
2578
+ // The server locks the registration out after its own attempt cap anyway.
2579
+ for (let attempt = 1; attempt <= 3; attempt++) {
2580
+ // Same reason as the email prompt: EOF stays an empty answer, which the
2581
+ // server rejects, rather than a TypeError on null.
2582
+ const otp = (await ask(' Code: ') ?? '').trim();
2583
+ const done = await httpJson('POST', `${apiBase}/api/agent/auth/claim/complete`, {
2584
+ body: { email, otp },
2585
+ headers,
2586
+ });
2587
+ if (done.res.ok && done.json && typeof done.json.credential === 'string') {
2588
+ const cred = {
2589
+ token: done.json.credential,
2590
+ handle: done.json.handle,
2591
+ // claim/complete carries no room — the email flow does not choose one.
2592
+ room: done.json.room,
2593
+ account: done.json.account,
2594
+ scopes: done.json.scopes,
2595
+ apiBase,
2596
+ };
2597
+ saveCredential(cred);
2598
+ process.stdout.write(`${connectedLine(cred)}\n`);
2599
+ if (!cred.room) {
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');
2602
+ }
2603
+ return cred;
2604
+ }
2605
+ const detail = (done.json && (done.json.message || done.json.error || done.json.code))
2606
+ || `HTTP ${done.res.status}`;
2607
+ if (attempt === 3) fail(`could not connect: ${detail}`);
2608
+ process.stderr.write(`pingroom: ${detail}\n`);
2609
+ }
2610
+ return null;
2611
+ }
2612
+
2613
+ /**
2614
+ * Resolve the unconnected state interactively. Refuses outright when there is no
2615
+ * TTY — a hung prompt in CI is worse than a clean failure, and the fix there is
2616
+ * PINGROOM_TOKEN, not a QR nobody can scan.
2617
+ */
2618
+ async function connect(args) {
2619
+ if (!isInteractive()) {
2620
+ fail(
2621
+ 'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
2622
+ EXIT.USAGE,
2623
+ );
2624
+ }
2625
+
2626
+ const apiBase = resolveApiBase(args);
2627
+ requireSafeUrl('--api', apiBase);
2628
+
2629
+ const prompter = createPrompter();
2630
+ const ask = (question) => prompter.ask(question);
2631
+ try {
2632
+ process.stdout.write(' Not connected. How do you want to connect?\n');
2633
+ process.stdout.write(' 1) Scan a QR code with the PingRoom app\n');
2634
+ process.stdout.write(' 2) Email me a code\n');
2635
+ // EOF here means "no answer", which is what the default already covers, so
2636
+ // coalesce rather than crash on null — the pairing branch below is the one
2637
+ // that must distinguish EOF, and it does.
2638
+ const choice = (await ask(' Choose [1]: ') ?? '').trim();
2639
+ if (choice && choice !== '1' && choice !== '2') {
2640
+ process.stderr.write('pingroom: choose 1 or 2\n');
2641
+ return EXIT.USAGE;
2642
+ }
2643
+
2644
+ const cred = choice === '2'
2645
+ ? await connectByEmail(apiBase, ask)
2646
+ : await connectByPairing(apiBase, ask);
2647
+
2648
+ return cred ? EXIT.OK : EXIT.EXPIRED;
2649
+ } finally {
2650
+ prompter.close();
2651
+ }
2652
+ }
2653
+
2654
+ // --- status / bare invocation ----------------------------------------------
2655
+
2656
+ /**
2657
+ * `pingroom` with no arguments. Connected -> one status line then the usual
2658
+ * help. Not connected -> pair (interactive) or, in a pipe/CI, say so on stderr
2659
+ * and still print the help rather than prompting into the void.
2660
+ */
2661
+ async function bare(args) {
2662
+ const envToken = process.env.PINGROOM_TOKEN;
2663
+ const stored = readStoredCredential();
2664
+
2665
+ if (envToken) {
2666
+ process.stdout.write('Using the agent token from PINGROOM_TOKEN.\n');
2667
+ if (stored) process.stdout.write(`(the stored credential in ${credentialsPath()} is ignored while it is set)\n`);
2668
+ const room = resolveRoom(args);
2669
+ if (room) process.stdout.write(`Default room: ${room}\n`);
2670
+ process.stdout.write(`\n${HELP}\n`);
2671
+ return EXIT.OK;
2672
+ }
2673
+
2674
+ if (stored) {
2675
+ process.stdout.write(`${connectedLine(stored)}\n`);
2676
+ const room = resolveRoom(args);
2677
+ if (room) process.stdout.write(`Default room: ${room}\n`);
2678
+ process.stdout.write(`\n${HELP}\n`);
2679
+ return EXIT.OK;
2680
+ }
2681
+
2682
+ if (!isInteractive()) {
2683
+ process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
2684
+ process.stdout.write(`${HELP}\n`);
2685
+ return EXIT.OK;
2686
+ }
2687
+
2688
+ return connect(args);
2689
+ }
2690
+
2691
+ // --- config ----------------------------------------------------------------
2692
+
2693
+ // Only these keys are storable. An unknown key is a usage error rather than a
2694
+ // silently-ignored setting the user then blames the tool for not honouring.
2695
+ const CONFIG_KEYS = {
2696
+ default_room: {
2697
+ describe: 'Room invite code used when --room / PINGROOM_ROOM is absent',
2698
+ validate: (value) => {
2699
+ if (/\s/.test(value) || value.length > 64) return 'default_room must be an invite code (no spaces, <= 64 chars)';
2700
+ return null;
2701
+ },
2702
+ },
2703
+ api_url: {
2704
+ describe: `API base URL (default ${BUILTIN_API})`,
2705
+ validate: (value) => {
2706
+ let u;
2707
+ try { u = new URL(value); } catch { return 'api_url must be a valid URL'; }
2708
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
2709
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
2710
+ return 'api_url must use https (refusing to send credentials over cleartext)';
2711
+ }
2712
+ return null;
2713
+ },
2714
+ },
2715
+ };
2716
+
2717
+ async function config(args) {
2718
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2719
+
2720
+ const sub = args._[0];
2721
+ const known = ['list', 'get', 'set'];
2722
+ if (!sub || !known.includes(sub)) {
2723
+ fail(`config needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
2724
+ }
2725
+
2726
+ const stored = readConfigFile();
2727
+
2728
+ if (sub === 'list') {
2729
+ if (args.json) { process.stdout.write(`${JSON.stringify(stored)}\n`); return EXIT.OK; }
2730
+ const keys = Object.keys(CONFIG_KEYS).filter((k) => stored[k] !== undefined && stored[k] !== '');
2731
+ if (keys.length === 0) {
2732
+ process.stdout.write(`no settings stored in ${configPath()}\n`);
2733
+ return EXIT.OK;
2734
+ }
2735
+ for (const key of keys) process.stdout.write(`${key}=${stored[key]}\n`);
2736
+ return EXIT.OK;
2737
+ }
2738
+
2739
+ const key = args._[1];
2740
+ if (!key) fail(`config ${sub} needs a key (${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
2741
+ if (!Object.hasOwn(CONFIG_KEYS, key)) {
2742
+ fail(`unknown config key: ${key} (known keys: ${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
2743
+ }
2744
+
2745
+ if (sub === 'get') {
2746
+ const value = stored[key];
2747
+ if (value === undefined || value === '') return EXIT.OK; // unset: print nothing, exit 0
2748
+ process.stdout.write(`${value}\n`);
2749
+ return EXIT.OK;
2750
+ }
2751
+
2752
+ // set
2753
+ const raw = args._[2];
2754
+ if (raw === undefined) fail(`config set needs a value (pass "" to clear ${key})`, EXIT.USAGE);
2755
+ const value = String(raw).trim();
2756
+
2757
+ if (value === '') {
2758
+ delete stored[key];
2759
+ writeJsonFile(configPath(), stored);
2760
+ process.stdout.write(`${key} cleared\n`);
2761
+ return EXIT.OK;
2762
+ }
2763
+
2764
+ const problem = CONFIG_KEYS[key].validate(value);
2765
+ if (problem) fail(problem, EXIT.USAGE);
2766
+
2767
+ stored[key] = value;
2768
+ writeJsonFile(configPath(), stored);
2769
+ process.stdout.write(`${key}=${value}\n`);
2770
+ return EXIT.OK;
2771
+ }
2772
+
2773
+ // --- logout ----------------------------------------------------------------
2774
+
2775
+ async function logout(args) {
2776
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2777
+
2778
+ const path = credentialsPath();
2779
+ const stored = readStoredCredential();
2780
+ try {
2781
+ unlinkSync(path);
2782
+ } catch (err) {
2783
+ if (err.code === 'ENOENT') {
2784
+ process.stdout.write('not connected — there was no stored credential to clear\n');
2785
+ return EXIT.OK;
2786
+ }
2787
+ fail(`could not clear ${path}: ${err.message}`);
2788
+ }
2789
+
2790
+ const who = stored && stored.handle ? ` (@${stored.handle})` : '';
2791
+ process.stdout.write(`logged out${who} — cleared ${path}\n`);
2792
+ if (process.env.PINGROOM_TOKEN) {
2793
+ process.stdout.write('note: PINGROOM_TOKEN is still set in this environment and will keep being used\n');
2794
+ }
2795
+ return EXIT.OK;
2796
+ }
2797
+
1118
2798
  const COMMANDS = {
1119
2799
  ping: (rest) => ping(parseArgs(rest)),
1120
2800
  ask: (rest) => ask(parseQArgs(rest)),
@@ -1125,6 +2805,11 @@ const COMMANDS = {
1125
2805
  handoff: (rest) => handoff(parseHandoffArgs(rest)),
1126
2806
  handoffs: (rest) => listHandoffs(parseQArgs(rest)),
1127
2807
  hook: (rest) => hook(parseHookArgs(rest)),
2808
+ mcp,
2809
+ activate: (rest) => activateStoredInbox(parseQArgs(rest)),
2810
+ live: (rest) => live(parseLiveArgs(rest)),
2811
+ config: (rest) => config(parseQArgs(rest)),
2812
+ logout: (rest) => logout(parseQArgs(rest)),
1128
2813
  };
1129
2814
 
1130
2815
  function waitFrom(handler, rest) {
@@ -1135,11 +2820,24 @@ async function main() {
1135
2820
  const argv = process.argv.slice(2);
1136
2821
  const command = argv[0];
1137
2822
 
1138
- if (!command || command === '-h' || command === '--help' || command === 'help') {
2823
+ if (command === '-h' || command === '--help' || command === 'help') {
1139
2824
  process.stdout.write(`${HELP}\n`);
1140
2825
  process.exit(EXIT.OK);
1141
2826
  }
1142
2827
 
2828
+ if (command === '-v' || command === '--version') {
2829
+ process.stdout.write(`${VERSION}\n`);
2830
+ process.exit(EXIT.OK);
2831
+ }
2832
+
2833
+ // Bare `pingroom` resolves the auth state instead of only printing help:
2834
+ // connected -> status + help; not connected -> pair (interactive only).
2835
+ // A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
2836
+ // configures the connect attempt rather than naming a command.
2837
+ if (!command || command.startsWith('-')) {
2838
+ process.exit(await bare(parseQArgs(argv)));
2839
+ }
2840
+
1143
2841
  const handler = COMMANDS[command];
1144
2842
  if (!handler) {
1145
2843
  fail(`unknown command: ${command}\nRun "pingroom --help".`, EXIT.USAGE);