@pingroom/cli 0.6.2 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +85 -40
  2. package/bin/pingroom.js +487 -225
  3. package/package.json +1 -1
package/bin/pingroom.js CHANGED
@@ -19,10 +19,11 @@
19
19
  // handoff Hand a decision to a specific human (ack or question) and, with
20
20
  // --wait, block until they acknowledge / answer.
21
21
  // handoffs List the agent's open handoffs or bounded recent history.
22
+ // listen Long-poll for pings arriving in the agent's rooms.
22
23
  // live Drive a live progress card (iOS Live Activity / Android live
23
24
  // update) on the room members' lock screen: start / update / end.
24
25
  // mcp Print the canonical remote MCP endpoint and client setup snippets.
25
- // activate Retry Agent Inbox activation with the saved QR-paired credential.
26
+ // activate Send one optional test Question with the saved QR-paired credential.
26
27
  // config Read/write ~/.pingroom/config.json (default_room, api_url).
27
28
  // logout Forget the credential in ~/.pingroom/credentials.json.
28
29
  //
@@ -40,13 +41,17 @@ import { join } from 'node:path';
40
41
  // Kept in lockstep with package.json / package-lock.json. The GitHub Action is
41
42
  // pinned independently to the latest version already published on npm; a test
42
43
  // makes that release gate explicit. `hook --print-config` emits this candidate.
43
- const VERSION = '0.6.2';
44
+ const VERSION = '0.7.2';
44
45
 
45
46
  const BUILTIN_API = 'https://api.pingroom.io';
46
47
  const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
47
48
  const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
48
49
 
49
- const HELP = `pingroom send a ping, or ask a human a question, from CI/scripts/agents
50
+ // The help text lives as one section per command plus intro/shared/tail, so
51
+ // `pingroom <command> --help` can print a focused excerpt (see commandHelp).
52
+ // The full HELP below joins them in the historical order — `pingroom --help`
53
+ // output is byte-identical to the pre-split single blob.
54
+ const HELP_INTRO = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
50
55
 
51
56
  Usage:
52
57
  pingroom <command> [options]
@@ -60,6 +65,7 @@ Commands:
60
65
  handoff Hand a decision (ack or question) to a specific human; with --wait,
61
66
  block until they acknowledge or answer
62
67
  handoffs List the agent's open handoffs or bounded recent history
68
+ listen Block on pings arriving in your rooms and print them as they land
63
69
  live Drive a live progress card on the lock screen (Live Activity)
64
70
  hook Claude Code hook: ping on Stop/Notification, and route tool
65
71
  permission prompts to a PingRoom question you answer from your phone
@@ -67,9 +73,9 @@ Commands:
67
73
  Claude Desktop
68
74
  activate Retry Agent Inbox activation with the saved QR-paired credential
69
75
  config Read/write local settings (config list | get <key> | set <key> <val>)
70
- logout Forget the stored credential
76
+ logout Forget the stored credential`;
71
77
 
72
- ping options:
78
+ const HELP_PING = `ping options:
73
79
  -m, --message <text> Ping body text (required)
74
80
  -t, --title <text> Ping title (<= 40 chars)
75
81
  -a, --action <1-4> Quick-action slot to attribute the ping to
@@ -82,9 +88,9 @@ ping options:
82
88
  repeat for up to 4. Requires --token and a Pro account
83
89
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
84
90
  --token <token> Agent access token (or env PINGROOM_TOKEN)
85
- --room <code> Room invite code (used with --token)
91
+ --room <code> Room invite code (used with --token)`;
86
92
 
87
- ask options (agent token required):
93
+ const HELP_ASK = `ask options (agent token required):
88
94
  -p, --prompt <text> The question a human reads (required)
89
95
  -o, --option <v:label[:style]>
90
96
  An answer option (style: primary|danger|default);
@@ -100,12 +106,12 @@ ask options (agent token required):
100
106
  -d, --data <json> Structured data object echoed back on the answer
101
107
  --correlation-id <id> Opaque id echoed on every read of this question
102
108
  --reply-to <id> Id of the ping this question replies to
103
- --room <code> Room invite code (required for ask)
109
+ --room <code> Room invite code (required for ask)`;
104
110
 
105
- list options:
106
- --state <s> pending | answered | expired | cancelled | all
111
+ const HELP_LIST = `list options:
112
+ --state <s> pending | answered | expired | cancelled | all`;
107
113
 
108
- handoff options (agent token required; consent scope pingroom:handoffs:create):
114
+ const HELP_HANDOFF = `handoff options (agent token required; consent scope pingroom:handoffs:create):
109
115
  -m, --message <text> The prompt a human reads (required)
110
116
  --question Make it a question (else a simple acknowledge). Also
111
117
  implied whenever one or more --option is given.
@@ -119,15 +125,24 @@ handoff options (agent token required; consent scope pingroom:handoffs:create):
119
125
  -d, --data <json> Structured data object echoed on the handoff
120
126
  --wait Block until acked / answered / expired / cancelled
121
127
  --timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
122
- --github-output <path> Safely append handoff outputs for GitHub Actions
128
+ --github-output <path> Safely append handoff outputs for GitHub Actions`;
123
129
 
124
- handoffs options (agent token required; consent scope pingroom:handoffs:create):
125
- --state <s> open | all (default open)
130
+ const HELP_HANDOFFS = `handoffs options (agent token required; consent scope pingroom:handoffs:create):
131
+ --state <s> open | all (default open)`;
126
132
 
127
- live <start|update|end|get> options (agent token, or a room webhook):
133
+ const HELP_LISTEN = `listen options (agent token required; consent scope pingroom:notifications:read):
134
+ --timeout <sec> Per long-poll hold (0-30, default 25)
135
+ --limit <n> Max pings per batch (1-100, default 50)
136
+ --from <id> Start after this ping id instead of "now"
137
+ --once Print one batch and exit instead of blocking forever
138
+ --json One JSON object per line instead of a readable line`;
139
+
140
+ const HELP_LIVE = `live <start|update|end|get> options (agent token, or a room webhook):
128
141
  -c, --correlation-id <id> The stream key — reuse it for every ping (required)
129
142
  --template <name> start only: status | steps | progress | metrics |
130
- countdown | question | matchup (fixed at creation)
143
+ countdown | decision | matchup (fixed at creation;
144
+ 'decision' is the app's name for the wire id
145
+ 'question', which is still accepted)
131
146
  --category <name> start only: status | steps | alert. Legacy, but
132
147
  'alert' has no template equivalent and is the only
133
148
  way to start time-sensitive without --require-ack
@@ -138,50 +153,52 @@ live <start|update|end|get> options (agent token, or a room webhook):
138
153
  --metric <label:value> Repeatable, up to 3 (metrics template)
139
154
  --deadline-at <epoch> Countdown target (countdown template)
140
155
  --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
156
+ --prompt <text> The ask (decision template)
157
+ --option <value:label> Repeatable, up to 4 (decision template). A bare
143
158
  token is both value and label
144
159
  --left <label:value> Left side (matchup template)
145
160
  --right <label:value> Right side (matchup template)
146
161
  --center <text> Center score/clock, <= 40 (matchup template)
147
162
  --accent-override <#rrggbb> Semantic accent for this frame
148
163
  --failed end only: finish as failed instead of done
164
+ -d, --data <json> Structured data object carried on this frame
149
165
  -t, --title <text> Card title (<= 40 chars)
150
166
  -a, --action <1-4> Quick-action slot supplying the icon and sound
151
167
  --require-ack Add an Acknowledge button
152
168
  --ack-timeout <s> Ack deadline in seconds
153
169
  --room <code> Room invite code (used with --token)
154
- -w, --webhook <url> Room webhook URL instead of a token
170
+ -w, --webhook <url> Room webhook URL instead of a token`;
155
171
 
156
- hook options (reads a Claude Code event; defaults to stored credentials/config):
172
+ const HELP_HOOK = `hook options (reads a Claude Code event; defaults to stored credentials/config):
157
173
  --room <code> Room invite code (or env/config/paired room)
158
174
  --ttl <seconds> Approval-question expiry for PreToolUse (default 900)
159
175
  --quiet Suppress the informational stderr lines
160
- --print-config Print a ready-to-paste ~/.claude/settings.json block
176
+ --print-config Print a ready-to-paste ~/.claude/settings.json block`;
161
177
 
162
- mcp:
178
+ const HELP_MCP = `mcp:
163
179
  pingroom mcp Print the endpoint and client setup snippets
164
180
  pingroom mcp add claude-code Print the Claude Code setup command
165
- (output-only; does not change client config)
181
+ (output-only; does not change client config)`;
166
182
 
167
- activate:
168
- pingroom activate Replay or create the next Agent Inbox test using
169
- the saved QR-paired credential
183
+ const HELP_ACTIVATE = `activate:
184
+ pingroom activate Send one test Question to your phone to prove the
185
+ saved QR-paired credential works (optional —
186
+ connecting no longer does this for you)`;
170
187
 
171
- config options:
188
+ const HELP_CONFIG = `config options:
172
189
  pingroom config list Print the stored settings
173
190
  pingroom config get <key> Print one setting
174
191
  pingroom config set <key> <val> Store a setting (an empty value clears it)
175
- Keys: default_room, api_url
192
+ Keys: default_room, api_url`;
176
193
 
177
- Shared:
194
+ const HELP_SHARED = `Shared:
178
195
  --token <token> Agent access token (or env PINGROOM_TOKEN)
179
196
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
180
197
  --json Print the raw JSON response
181
198
  -h, --help Show this help
182
- -v, --version Show the CLI version
199
+ -v, --version Show the CLI version`;
183
200
 
184
- Connecting:
201
+ const HELP_TAIL = `Connecting:
185
202
  Install globally, then run with no arguments:
186
203
  npm install --global @pingroom/cli
187
204
  pingroom
@@ -190,11 +207,10 @@ Connecting:
190
207
  npx --yes @pingroom/cli
191
208
 
192
209
  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.
210
+ the rooms it may reach there (one, several, or all of them). Once paired, it
211
+ saves the credential and you are done; connecting sends nothing to your phone.
212
+ Run "pingroom activate" if you want to prove the round-trip with one test
213
+ Question. The emailed-code fallback stores no server-side delivery room.
198
214
  "config set default_room" enables room-addressed commands, but private
199
215
  Inbox/Handoff delivery requires QR pairing.
200
216
  There is no "login" command: being unconnected is a state the tool resolves,
@@ -280,6 +296,54 @@ or the recipient was not ready (409 recipient_not_ready). A question answered
280
296
  with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
281
297
  human decision is not an infrastructure failure.`;
282
298
 
299
+ const HELP = [
300
+ HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
301
+ HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
302
+ HELP_SHARED, HELP_TAIL,
303
+ ].join('\n\n');
304
+
305
+ // Sections for `pingroom <command> --help`. watch/cancel/logout have no block
306
+ // of their own in the full help, so they get a minimal one here.
307
+ const COMMAND_HELP_SECTIONS = {
308
+ ping: HELP_PING,
309
+ ask: HELP_ASK,
310
+ watch: `watch:
311
+ pingroom watch <question-id> Block until the question resolves and
312
+ print the outcome
313
+ --timeout <sec> Per long-poll hold (0–30, default 25)`,
314
+ cancel: `cancel:
315
+ pingroom cancel <question-id> Withdraw a pending question`,
316
+ list: HELP_LIST,
317
+ handoff: HELP_HANDOFF,
318
+ handoffs: HELP_HANDOFFS,
319
+ listen: HELP_LISTEN,
320
+ live: HELP_LIVE,
321
+ hook: HELP_HOOK,
322
+ activate: HELP_ACTIVATE,
323
+ config: HELP_CONFIG,
324
+ logout: `logout:
325
+ pingroom logout Forget the stored credential (PINGROOM_TOKEN
326
+ in the environment is unaffected)`,
327
+ };
328
+
329
+ // config and logout are local-only commands that reject --token/--api (and,
330
+ // for logout, --json), so their help gets a footer that only lists what they
331
+ // actually accept instead of the full shared block.
332
+ const COMMAND_HELP_FOOTERS = {
333
+ config: `Shared:
334
+ --json Print the raw JSON response
335
+ -h, --help Show this help`,
336
+ logout: `Shared:
337
+ -h, --help Show this help`,
338
+ };
339
+
340
+ // `<command> --help`: that command's section plus the shared flags, instead of
341
+ // the full reference `pingroom --help` / `pingroom help` still print.
342
+ function commandHelp(name) {
343
+ const section = COMMAND_HELP_SECTIONS[name];
344
+ return section ? `${section}\n\n${COMMAND_HELP_FOOTERS[name] ?? HELP_SHARED}` : HELP;
345
+ }
346
+
283
347
  const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
284
348
 
285
349
  function fail(message, code = EXIT.ERROR) {
@@ -287,6 +351,35 @@ function fail(message, code = EXIT.ERROR) {
287
351
  process.exit(code);
288
352
  }
289
353
 
354
+ /**
355
+ * The fixes that live on THIS side of the wire. The server's message always
356
+ * leads; these are appended only for the codes where the operator would
357
+ * otherwise have no way to know what to do next, and where the answer is a
358
+ * local action rather than "try again".
359
+ */
360
+ const API_HINTS = {
361
+ room_not_granted:
362
+ 'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom" to reconnect and pick it.',
363
+ insufficient_scope:
364
+ 'This credential was approved before the command needed that permission. Run "pingroom" to reconnect and re-approve.',
365
+ no_room_configured:
366
+ 'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
367
+ };
368
+
369
+ /**
370
+ * What to print when an API call fails: the server's own wording, plus the one
371
+ * thing that would fix it when we know one.
372
+ */
373
+ function apiDetail(res, json) {
374
+ // The server's wording is untrusted text headed for the terminal — strip
375
+ // escapes so a hostile API can't smuggle ANSI (same threat model as pair_url).
376
+ const base = stripControlChars(
377
+ (json && (json.message || json.error || json.code)) || `HTTP ${res ? res.status : 'error'}`,
378
+ );
379
+ const hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
380
+ return hint ? `${base}\n ${hint}` : base;
381
+ }
382
+
290
383
  // --- local state (~/.pingroom) ---------------------------------------------
291
384
  //
292
385
  // Two files, both under a 0700 directory:
@@ -462,16 +555,86 @@ function sleep(ms) {
462
555
  // Drop C0/C1 control characters before echoing server-supplied text to the
463
556
  // terminal. Without this an attacker-controlled API base can smuggle ANSI
464
557
  // escapes into the output and repaint, erase or overwrite the lines around them.
558
+ /**
559
+ * Reject an over-long field here rather than letting it become a 422.
560
+ *
561
+ * Every bound mirrors a Laravel rule (StoreNotificationRequest,
562
+ * StoreQuestionRequest, LiveStatusRules) and is documented in --help, so a value
563
+ * past it was always going to be refused — locally it reads as the usage error
564
+ * it is, with the limit and the actual length named.
565
+ */
566
+ function requireMaxLength(value, max, flag) {
567
+ if (typeof value === 'string' && value.length > max) {
568
+ fail(`${flag} must be at most ${max} characters (got ${value.length})`, EXIT.USAGE);
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Validate --timeout and resolve the per-poll hold. Called by ask/handoff
574
+ * BEFORE the create POST: the old in-wait check ran only after the question or
575
+ * handoff already existed, so `--timeout -5` put a live question on someone's
576
+ * phone and then exited 2, orphaning it until its TTL.
577
+ */
578
+ function resolveWaitHold(args, { def, cap }) {
579
+ if (args.timeout === undefined) return Math.min(def, cap);
580
+ const hold = Number(args.timeout);
581
+ if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
582
+ return Math.min(hold, cap);
583
+ }
584
+
465
585
  function stripControlChars(value) {
466
586
  // eslint-disable-next-line no-control-regex
467
587
  return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
468
588
  }
469
589
 
590
+ // --- argument parsing -------------------------------------------------------
591
+
592
+ /**
593
+ * Build an argv parser from a flag table. Every command parser runs the same
594
+ * loop; only the tables differ:
595
+ * aliases flag or alias -> canonical args key
596
+ * booleans keys that take no value
597
+ * repeatable keys collected into an array (the flag may repeat)
598
+ * bareDashIsPositional whether a lone `-` collects into `_` (the question-
599
+ * style parsers) or fails as an unknown option (ping,
600
+ * live)
601
+ * Unknown flags always fail as a usage error; bare words collect into `_`.
602
+ */
603
+ function makeParser({ aliases, booleans, repeatable = [], bareDashIsPositional = false }) {
604
+ const booleanKeys = new Set(booleans);
605
+ const repeatableKeys = new Set(repeatable);
606
+ return function parse(argv) {
607
+ const args = { _: [] };
608
+ for (let i = 0; i < argv.length; i++) {
609
+ const token = argv[i];
610
+ // Object.hasOwn, not aliases[token]: a bare lookup walks the prototype
611
+ // chain, so `constructor` / `toString` / `__proto__` in flag position
612
+ // resolve to a truthy inherited value, get treated as an option, and
613
+ // swallow the next argument instead of failing as an unknown flag.
614
+ const key = Object.hasOwn(aliases, token) ? aliases[token] : undefined;
615
+ if (key && booleanKeys.has(key)) {
616
+ args[key] = true;
617
+ } else if (key) {
618
+ const value = argv[++i];
619
+ if (value === undefined) {
620
+ fail(`option ${token} needs a value`, EXIT.USAGE);
621
+ }
622
+ if (repeatableKeys.has(key)) (args[key] ||= []).push(value);
623
+ else args[key] = value;
624
+ } else if (token.startsWith('-') && !(bareDashIsPositional && token === '-')) {
625
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
626
+ } else {
627
+ args._.push(token);
628
+ }
629
+ }
630
+ return args;
631
+ };
632
+ }
633
+
470
634
  // --- ping (unchanged wire behaviour) ---------------------------------------
471
635
 
472
- function parseArgs(argv) {
473
- const args = { _: [] };
474
- const alias = {
636
+ const parseArgs = makeParser({
637
+ aliases: {
475
638
  '-m': 'message', '--message': 'message',
476
639
  '-t': 'title', '--title': 'title',
477
640
  '-a': 'action', '--action': 'action',
@@ -487,40 +650,15 @@ function parseArgs(argv) {
487
650
  '--api': 'api',
488
651
  '--json': 'json',
489
652
  '-h': 'help', '--help': 'help',
490
- };
491
- const booleans = new Set(['require_ack', 'json', 'help']);
492
- const repeatable = new Set(['attach']);
493
-
494
- for (let i = 0; i < argv.length; i++) {
495
- const token = argv[i];
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;
501
- if (key && booleans.has(key)) {
502
- args[key] = true;
503
- } else if (key) {
504
- const value = argv[++i];
505
- if (value === undefined) {
506
- fail(`option ${token} needs a value`, EXIT.USAGE);
507
- }
508
- if (repeatable.has(key)) (args[key] ||= []).push(value);
509
- else args[key] = value;
510
- } else if (token.startsWith('-')) {
511
- fail(`Unknown option: ${token}`, EXIT.USAGE);
512
- } else {
513
- args._.push(token);
514
- }
515
- }
516
- return args;
517
- }
653
+ },
654
+ booleans: ['require_ack', 'json', 'help'],
655
+ repeatable: ['attach'],
656
+ });
518
657
 
519
658
  // Parser for the question commands: supports repeatable --option and a trailing
520
659
  // positional (a question id). Unknown flags fail like the ping parser.
521
- function parseQArgs(argv) {
522
- const args = { _: [] };
523
- const alias = {
660
+ const parseQArgs = makeParser({
661
+ aliases: {
524
662
  '-p': 'prompt', '--prompt': 'prompt',
525
663
  '-o': 'option', '--option': 'option',
526
664
  '-c': 'context', '--context': 'context',
@@ -534,46 +672,25 @@ function parseQArgs(argv) {
534
672
  '--text-max': 'text_max',
535
673
  '--timeout': 'timeout',
536
674
  '--state': 'state',
675
+ '--limit': 'limit',
676
+ '--from': 'from',
677
+ '--once': 'once',
537
678
  '--token': 'token',
538
679
  '--room': 'room',
539
680
  '--api': 'api',
540
681
  '--wait': 'wait',
541
682
  '--json': 'json',
542
683
  '-h': 'help', '--help': 'help',
543
- };
544
- const booleans = new Set(['wait', 'json', 'help']);
545
- const multi = new Set(['option']);
546
-
547
- for (let i = 0; i < argv.length; i++) {
548
- const token = argv[i];
549
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
550
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
551
- if (key && booleans.has(key)) {
552
- args[key] = true;
553
- } else if (key) {
554
- const value = argv[++i];
555
- if (value === undefined) {
556
- fail(`option ${token} needs a value`, EXIT.USAGE);
557
- }
558
- if (multi.has(key)) {
559
- (args[key] ||= []).push(value);
560
- } else {
561
- args[key] = value;
562
- }
563
- } else if (token.startsWith('-') && token !== '-') {
564
- fail(`Unknown option: ${token}`, EXIT.USAGE);
565
- } else {
566
- args._.push(token);
567
- }
568
- }
569
- return args;
570
- }
684
+ },
685
+ booleans: ['wait', 'json', 'help', 'once'],
686
+ repeatable: ['option'],
687
+ bareDashIsPositional: true,
688
+ });
571
689
 
572
690
  // Parser for `handoff`: --message plus repeatable --option, boolean --question,
573
691
  // and the handoff-specific flags. Unknown flags fail like the other parsers.
574
- function parseHandoffArgs(argv) {
575
- const args = { _: [] };
576
- const alias = {
692
+ const parseHandoffArgs = makeParser({
693
+ aliases: {
577
694
  '-m': 'message', '--message': 'message',
578
695
  '--question': 'question',
579
696
  '-o': 'option', '--option': 'option',
@@ -591,34 +708,11 @@ function parseHandoffArgs(argv) {
591
708
  '--wait': 'wait',
592
709
  '--json': 'json',
593
710
  '-h': 'help', '--help': 'help',
594
- };
595
- const booleans = new Set(['question', 'wait', 'json', 'help']);
596
- const multi = new Set(['option']);
597
-
598
- for (let i = 0; i < argv.length; i++) {
599
- const token = argv[i];
600
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
601
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
602
- if (key && booleans.has(key)) {
603
- args[key] = true;
604
- } else if (key) {
605
- const value = argv[++i];
606
- if (value === undefined) {
607
- fail(`option ${token} needs a value`, EXIT.USAGE);
608
- }
609
- if (multi.has(key)) {
610
- (args[key] ||= []).push(value);
611
- } else {
612
- args[key] = value;
613
- }
614
- } else if (token.startsWith('-') && token !== '-') {
615
- fail(`Unknown option: ${token}`, EXIT.USAGE);
616
- } else {
617
- args._.push(token);
618
- }
619
- }
620
- return args;
621
- }
711
+ },
712
+ booleans: ['question', 'wait', 'json', 'help'],
713
+ repeatable: ['option'],
714
+ bareDashIsPositional: true,
715
+ });
622
716
 
623
717
  // True when a URL is safe to attach a bearer token or webhook secret to: https,
624
718
  // or http on loopback so local dev against http://localhost still works.
@@ -772,7 +866,7 @@ async function uploadAttachments(paths, apiBase, token) {
772
866
  fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
773
867
  }
774
868
  if (!res.ok || !json?.attachment?.id) {
775
- const detail = json?.message || json?.error || `HTTP ${res.status}`;
869
+ const detail = apiDetail(res, json);
776
870
  fail(`upload failed for ${name}: ${detail}`);
777
871
  }
778
872
 
@@ -783,10 +877,12 @@ async function uploadAttachments(paths, apiBase, token) {
783
877
  }
784
878
 
785
879
  async function ping(args) {
786
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
880
+ if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
787
881
 
788
882
  const message = args.message;
789
883
  if (!message) fail('a --message is required', EXIT.USAGE);
884
+ requireMaxLength(message, 500, '--message');
885
+ requireMaxLength(args.title, 40, '--title');
790
886
 
791
887
  if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
792
888
  fail('--action must be an integer 1–4', EXIT.USAGE);
@@ -890,7 +986,7 @@ async function ping(args) {
890
986
  const ok = res.ok && !(json && json.success === false);
891
987
 
892
988
  if (!ok) {
893
- const detail = (json && (json.message || json.error)) || `HTTP ${res.status}`;
989
+ const detail = apiDetail(res, json);
894
990
  fail(`delivery failed: ${detail}`);
895
991
  }
896
992
 
@@ -905,11 +1001,30 @@ async function ping(args) {
905
1001
  // --template line in HELP and with LIVE_ACTIVITY_TEMPLATES.md.
906
1002
  const LIVE_TEMPLATES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'question', 'matchup'];
907
1003
 
1004
+ /**
1005
+ * Names the API does not take, folded onto the wire id it does.
1006
+ *
1007
+ * The `question` template is labelled **Decision** everywhere a person sees it,
1008
+ * so it is never confused with PingRoom's first-class Question protocol — that
1009
+ * one is answered through `pingroom ask`, carries a real Question id, and this
1010
+ * template does not. The wire id stayed `question`, so someone who reads
1011
+ * "Decision" in the app and types it would otherwise get a usage error for
1012
+ * using the only name they have been shown.
1013
+ */
1014
+ const LIVE_TEMPLATE_ALIASES = { decision: 'question' };
1015
+
1016
+ /** The wire id for a template name a human typed, or the name unchanged. */
1017
+ function canonicalTemplate(name) {
1018
+ return LIVE_TEMPLATE_ALIASES[name] ?? name;
1019
+ }
1020
+
1021
+ /** What we offer in help and errors: the alias leads, since it is what the app shows. */
1022
+ const LIVE_TEMPLATE_NAMES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'decision', 'matchup'];
1023
+
908
1024
  // Parser for `live`: a leading subcommand (start|update|end|get) plus the
909
1025
  // live-status flags. Unknown flags fail like the other parsers.
910
- function parseLiveArgs(argv) {
911
- const args = { _: [] };
912
- const alias = {
1026
+ const parseLiveArgs = makeParser({
1027
+ aliases: {
913
1028
  '-c': 'correlation_id', '--correlation-id': 'correlation_id',
914
1029
  '-t': 'title', '--title': 'title',
915
1030
  '-m': 'message', '--message': 'message',
@@ -938,29 +1053,10 @@ function parseLiveArgs(argv) {
938
1053
  '--api': 'api',
939
1054
  '--json': 'json',
940
1055
  '-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
- }
1056
+ },
1057
+ booleans: ['require_ack', 'json', 'help', 'failed'],
1058
+ repeatable: ['metric', 'option'],
1059
+ });
964
1060
 
965
1061
  // "label:value" -> {label, value}. Only the first colon splits.
966
1062
  function buildMetrics(list) {
@@ -1022,6 +1118,7 @@ function numberOption(raw, flag, { min, max, integer = false } = {}) {
1022
1118
  * (--webhook), which speak the same `live_status` contract.
1023
1119
  */
1024
1120
  async function live(args) {
1121
+ if (args.help) { process.stdout.write(`${commandHelp('live')}\n`); return EXIT.OK; }
1025
1122
  const sub = args._[0];
1026
1123
  const known = ['start', 'update', 'end', 'get'];
1027
1124
  if (!sub || !known.includes(sub)) {
@@ -1045,7 +1142,7 @@ async function live(args) {
1045
1142
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1046
1143
  if (args.json) process.stdout.write(`${text || '{}'}\n`);
1047
1144
  if (!res.ok) {
1048
- fail(`read failed: ${(json && (json.message || json.code)) || `HTTP ${res.status}`}`);
1145
+ fail(`read failed: ${apiDetail(res, json)}`);
1049
1146
  }
1050
1147
  if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
1051
1148
  return EXIT.OK;
@@ -1055,6 +1152,11 @@ async function live(args) {
1055
1152
  state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
1056
1153
  };
1057
1154
 
1155
+ // 256, not the 500 a ping body gets: this is the card's one live line.
1156
+ requireMaxLength(args.message, 256, '--message');
1157
+ requireMaxLength(args.title, 40, '--title');
1158
+ requireMaxLength(args.prompt, 256, '--prompt');
1159
+ requireMaxLength(args.center, 40, '--center');
1058
1160
  if (args.message !== undefined) liveStatus.message = args.message;
1059
1161
  if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
1060
1162
 
@@ -1095,10 +1197,11 @@ async function live(args) {
1095
1197
  // usage error, and letting it reach the server turns it into a 422 round
1096
1198
  // trip that reads like an outage.
1097
1199
  if (args.template) {
1098
- if (!LIVE_TEMPLATES.includes(args.template)) {
1099
- fail(`--template must be one of: ${LIVE_TEMPLATES.join(', ')}`, EXIT.USAGE);
1200
+ const template = canonicalTemplate(args.template);
1201
+ if (!LIVE_TEMPLATES.includes(template)) {
1202
+ fail(`--template must be one of: ${LIVE_TEMPLATE_NAMES.join(', ')}`, EXIT.USAGE);
1100
1203
  }
1101
- liveStatus.template = args.template;
1204
+ liveStatus.template = template;
1102
1205
  }
1103
1206
  // `alert` has no template equivalent and is the only way to start a stream
1104
1207
  // time-sensitive (breaking through Focus) without also demanding an ack.
@@ -1151,7 +1254,7 @@ async function live(args) {
1151
1254
  if (args.json) process.stdout.write(`${text || '{}'}\n`);
1152
1255
 
1153
1256
  if (!res.ok || (json && json.success === false)) {
1154
- const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
1257
+ const detail = apiDetail(res, json);
1155
1258
  fail(`live ${sub} failed: ${detail}`);
1156
1259
  }
1157
1260
 
@@ -1234,15 +1337,14 @@ function printResolution(q) {
1234
1337
  // and return the state's exit code. The server expires it at its ttl, so this
1235
1338
  // always terminates.
1236
1339
  async function waitForResolution(id, args, { token, apiBase }) {
1237
- let hold = args.timeout !== undefined ? Number(args.timeout) : 25;
1238
- if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
1239
- hold = Math.min(hold, 30);
1340
+ const hold = resolveWaitHold(args, { def: 25, cap: 30 });
1240
1341
 
1241
1342
  for (;;) {
1343
+ const started = Date.now();
1242
1344
  const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1243
1345
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1244
1346
  if (!res.ok) {
1245
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1347
+ const detail = apiDetail(res, json);
1246
1348
  fail(`wait failed: ${detail}`);
1247
1349
  }
1248
1350
  if (json && json.state && json.state !== 'pending') {
@@ -1250,15 +1352,21 @@ async function waitForResolution(id, args, { token, apiBase }) {
1250
1352
  else printResolution(json);
1251
1353
  return exitForState(json.state);
1252
1354
  }
1253
- // Still pending at the hold timeout — poll again.
1355
+ // Still pending at the hold timeout — poll again, but never hot-loop: a
1356
+ // misbehaving server that answers `pending` instantly (ignoring the hold)
1357
+ // would otherwise be hammered at full speed.
1358
+ const elapsed = Date.now() - started;
1359
+ if (elapsed < 1000) await sleep(1000 - elapsed);
1254
1360
  }
1255
1361
  }
1256
1362
 
1257
1363
  async function ask(args) {
1258
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1364
+ if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
1259
1365
 
1260
1366
  const prompt = args.prompt;
1261
1367
  if (!prompt) fail('a --prompt is required', EXIT.USAGE);
1368
+ requireMaxLength(prompt, 500, '--prompt');
1369
+ requireMaxLength(args.context, 40, '--context');
1262
1370
 
1263
1371
  const { token, apiBase, room } = agentContext(args, { needRoom: true });
1264
1372
 
@@ -1291,10 +1399,13 @@ async function ask(args) {
1291
1399
  }
1292
1400
  if (args.data !== undefined) body.data = parseDataObject(args.data);
1293
1401
 
1402
+ // Pre-flight: reject a bad --timeout before the question exists.
1403
+ if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
1404
+
1294
1405
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
1295
1406
  const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
1296
1407
  if (!res.ok) {
1297
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1408
+ const detail = apiDetail(res, json);
1298
1409
  fail(`ask failed: ${detail}`);
1299
1410
  }
1300
1411
 
@@ -1308,7 +1419,7 @@ async function ask(args) {
1308
1419
  }
1309
1420
 
1310
1421
  async function watch(args) {
1311
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1422
+ if (args.help) { process.stdout.write(`${commandHelp('watch')}\n`); return EXIT.OK; }
1312
1423
  const id = args._[0];
1313
1424
  if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
1314
1425
  const { token, apiBase } = agentContext(args);
@@ -1316,14 +1427,14 @@ async function watch(args) {
1316
1427
  }
1317
1428
 
1318
1429
  async function cancel(args) {
1319
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1430
+ if (args.help) { process.stdout.write(`${commandHelp('cancel')}\n`); return EXIT.OK; }
1320
1431
  const id = args._[0];
1321
1432
  if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
1322
1433
  const { token, apiBase } = agentContext(args);
1323
1434
  const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
1324
1435
  const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
1325
1436
  if (!res.ok) {
1326
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1437
+ const detail = apiDetail(res, json);
1327
1438
  fail(`cancel failed: ${detail}`);
1328
1439
  }
1329
1440
  if (args.json) process.stdout.write(`${text}\n`);
@@ -1332,13 +1443,13 @@ async function cancel(args) {
1332
1443
  }
1333
1444
 
1334
1445
  async function list(args) {
1335
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1446
+ if (args.help) { process.stdout.write(`${commandHelp('list')}\n`); return EXIT.OK; }
1336
1447
  const { token, apiBase } = agentContext(args);
1337
1448
  const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
1338
1449
  const url = `${apiBase}/api/agent/questions${qs}`;
1339
1450
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1340
1451
  if (!res.ok) {
1341
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1452
+ const detail = apiDetail(res, json);
1342
1453
  fail(`list failed: ${detail}`);
1343
1454
  }
1344
1455
  if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
@@ -1352,8 +1463,100 @@ async function list(args) {
1352
1463
  return EXIT.OK;
1353
1464
  }
1354
1465
 
1466
+ // --- listen ----------------------------------------------------------------
1467
+ //
1468
+ // The inbound half. Everything else here talks; this is how an agent hears —
1469
+ // replies to its own structured pings, a human's ping in a room it belongs to,
1470
+ // anything landing while it works.
1471
+ //
1472
+ // The server holds each request open until something arrives or the timeout
1473
+ // elapses, so this is a long-poll, not a poll loop: an idle hour costs ~144
1474
+ // requests, not one per second.
1475
+
1476
+ /** Cursor bookkeeping is the whole protocol: `after` in, `cursor` back. */
1477
+ async function listen(args) {
1478
+ if (args.help) { process.stdout.write(`${commandHelp('listen')}\n`); return EXIT.OK; }
1479
+
1480
+ const { token, apiBase } = agentContext(args);
1481
+ const headers = { Authorization: `Bearer ${token}` };
1482
+
1483
+ const timeout = numberOption(args.timeout, '--timeout', { min: 0, max: 30, integer: true }) ?? 25;
1484
+ const limit = numberOption(args.limit, '--limit', { min: 1, max: 100, integer: true }) ?? 50;
1485
+
1486
+ // No cursor means "from now": the server answers an empty `after` with the
1487
+ // head id and no rows, so starting up never replays history the agent has
1488
+ // already seen. `--from` opts into catching up from a known id instead.
1489
+ let cursor = args.from;
1490
+ if (!cursor) {
1491
+ const { res, json } = await httpJson('GET', `${apiBase}/api/agent/notifications/wait`, {
1492
+ headers,
1493
+ soft: true,
1494
+ });
1495
+ if (!res?.ok) fail(`listen failed: ${apiDetail(res, json)}`);
1496
+ cursor = json && json.cursor;
1497
+ if (!cursor) {
1498
+ // A brand-new account with no pings at all has no head id. Nothing is
1499
+ // wrong; there is simply nothing to be after yet.
1500
+ cursor = '';
1501
+ }
1502
+ }
1503
+
1504
+ let transientRun = 0;
1505
+
1506
+ for (;;) {
1507
+ const query = new URLSearchParams({ timeout: String(timeout), limit: String(limit) });
1508
+ if (cursor) query.set('after', cursor);
1509
+
1510
+ const { res, json, error } = await httpJson(
1511
+ 'GET',
1512
+ `${apiBase}/api/agent/notifications/wait?${query}`,
1513
+ // The hold plus headroom: aborting at exactly the server's deadline would
1514
+ // race it and turn every quiet window into a client-side error.
1515
+ { headers, soft: true, signal: AbortSignal.timeout((timeout + 10) * 1000) },
1516
+ );
1517
+
1518
+ if (error || res.status === 429 || res.status >= 500) {
1519
+ transientRun += 1;
1520
+ const retryAfter = res?.status === 429 ? retryAfterMs(res) : null;
1521
+ // Geometric backoff so a real outage is not also a thundering herd. The
1522
+ // loop is unbounded by design — `listen` is a daemon, not a request.
1523
+ const backoff = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 30_000);
1524
+ await sleep(Math.max(0, retryAfter ?? backoff));
1525
+ continue;
1526
+ }
1527
+
1528
+ if (!res.ok) fail(`listen failed: ${apiDetail(res, json)}`);
1529
+ transientRun = 0;
1530
+
1531
+ const batch = Array.isArray(json?.notifications) ? json.notifications : [];
1532
+ for (const item of batch) {
1533
+ process.stdout.write(args.json ? `${JSON.stringify(item)}\n` : `${formatIncoming(item)}\n`);
1534
+ }
1535
+ // Advance only on a cursor the server actually returned, or a batch could be
1536
+ // replayed forever against a stale `after`.
1537
+ if (json && typeof json.cursor === 'string' && json.cursor) cursor = json.cursor;
1538
+
1539
+ if (args.once) return EXIT.OK;
1540
+ }
1541
+ }
1542
+
1543
+ /** One readable line per incoming ping. */
1544
+ function formatIncoming(item) {
1545
+ const room = item?.room?.name || item?.room?.code || '?';
1546
+ const body = stripControlChars(item?.message ?? '');
1547
+ const marks = [];
1548
+ if (item?.correlation_id) marks.push(`corr=${stripControlChars(item.correlation_id)}`);
1549
+ if (item?.reply_to) marks.push(`reply_to=${stripControlChars(item.reply_to)}`);
1550
+ if (item?.question) marks.push('question');
1551
+ if (Array.isArray(item?.attachments) && item.attachments.length) {
1552
+ marks.push(`${item.attachments.length} attachment${item.attachments.length === 1 ? '' : 's'}`);
1553
+ }
1554
+ const suffix = marks.length ? ` (${marks.join(' · ')})` : '';
1555
+ return `[${stripControlChars(room)}] ${body}${suffix}`;
1556
+ }
1557
+
1355
1558
  async function listHandoffs(args) {
1356
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1559
+ if (args.help) { process.stdout.write(`${commandHelp('handoffs')}\n`); return EXIT.OK; }
1357
1560
  const { token, apiBase } = agentContext(args);
1358
1561
  const state = args.state || 'open';
1359
1562
  if (state !== 'open' && state !== 'all') {
@@ -1363,7 +1566,7 @@ async function listHandoffs(args) {
1363
1566
  const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
1364
1567
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1365
1568
  if (!res.ok) {
1366
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1569
+ const detail = apiDetail(res, json);
1367
1570
  fail(`handoffs list failed: ${detail}`);
1368
1571
  }
1369
1572
  if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
@@ -1471,15 +1674,14 @@ function writeGitHubHandoffOutputs(path, h) {
1471
1674
  // Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
1472
1675
  // print it and return the state's exit code. Reuses the shared bounded hold.
1473
1676
  async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
1474
- let hold = args.timeout !== undefined ? Number(args.timeout) : 20;
1475
- if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
1476
- hold = Math.min(hold, 25);
1677
+ const hold = resolveWaitHold(args, { def: 20, cap: 25 });
1477
1678
 
1478
1679
  for (;;) {
1680
+ const started = Date.now();
1479
1681
  const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1480
1682
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1481
1683
  if (!res.ok) {
1482
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1684
+ const detail = apiDetail(res, json);
1483
1685
  fail(`wait failed: ${detail}`);
1484
1686
  }
1485
1687
  if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
@@ -1494,15 +1696,19 @@ async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState
1494
1696
  else printHandoff(resolved);
1495
1697
  return exitForHandoffState(resolved.state);
1496
1698
  }
1497
- // Still open/pending at the hold timeout — poll again.
1699
+ // Still open/pending at the hold timeout — poll again, with the same
1700
+ // hot-loop floor as waitForResolution.
1701
+ const elapsed = Date.now() - started;
1702
+ if (elapsed < 1000) await sleep(1000 - elapsed);
1498
1703
  }
1499
1704
  }
1500
1705
 
1501
1706
  async function handoff(args) {
1502
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1707
+ if (args.help) { process.stdout.write(`${commandHelp('handoff')}\n`); return EXIT.OK; }
1503
1708
 
1504
1709
  const message = args.message;
1505
1710
  if (!message) fail('a --message is required', EXIT.USAGE);
1711
+ requireMaxLength(message, 500, '--message');
1506
1712
 
1507
1713
  const { token, apiBase } = agentContext(args);
1508
1714
 
@@ -1548,11 +1754,14 @@ async function handoff(args) {
1548
1754
  headers['Idempotency-Key'] = args.idempotency_key;
1549
1755
  }
1550
1756
 
1757
+ // Pre-flight: reject a bad --timeout before the handoff exists.
1758
+ if (args.wait) resolveWaitHold(args, { def: 20, cap: 25 });
1759
+
1551
1760
  const url = `${apiBase}/api/agent/handoffs`;
1552
1761
  const { res, text, json } = await httpJson('POST', url, { body, headers });
1553
1762
  if (!res.ok) {
1554
1763
  const code = json && json.code;
1555
- const detail = (json && (json.message || code)) || `HTTP ${res.status}`;
1764
+ const detail = apiDetail(res, json);
1556
1765
  // A recipient who isn't reachable yet is a distinct, retriable outcome (4),
1557
1766
  // not a generic error — CI may want to wait and retry rather than fail hard.
1558
1767
  if (res.status === 409 && code === 'recipient_not_ready') {
@@ -1587,9 +1796,8 @@ async function handoff(args) {
1587
1796
  // normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
1588
1797
  // must not call fail() (a non-zero exit — 2 especially — would break the run).
1589
1798
 
1590
- function parseHookArgs(argv) {
1591
- const args = { _: [] };
1592
- const alias = {
1799
+ const parseHookArgs = makeParser({
1800
+ aliases: {
1593
1801
  '--room': 'room',
1594
1802
  '--ttl': 'ttl',
1595
1803
  '--quiet': 'quiet',
@@ -1598,27 +1806,10 @@ function parseHookArgs(argv) {
1598
1806
  '--api': 'api',
1599
1807
  '--json': 'json',
1600
1808
  '-h': 'help', '--help': 'help',
1601
- };
1602
- const booleans = new Set(['quiet', 'print_config', 'json', 'help']);
1603
-
1604
- for (let i = 0; i < argv.length; i++) {
1605
- const token = argv[i];
1606
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1607
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
1608
- if (key && booleans.has(key)) {
1609
- args[key] = true;
1610
- } else if (key) {
1611
- const value = argv[++i];
1612
- if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
1613
- args[key] = value;
1614
- } else if (token.startsWith('-') && token !== '-') {
1615
- fail(`Unknown option: ${token}`, EXIT.USAGE);
1616
- } else {
1617
- args._.push(token);
1618
- }
1619
- }
1620
- return args;
1621
- }
1809
+ },
1810
+ booleans: ['quiet', 'print_config', 'json', 'help'],
1811
+ bareDashIsPositional: true,
1812
+ });
1622
1813
 
1623
1814
  // Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
1624
1815
  // stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
@@ -1655,7 +1846,7 @@ async function hookFetch(method, url, { body, token } = {}) {
1655
1846
  let json = null;
1656
1847
  try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
1657
1848
  if (!res.ok) {
1658
- throw new Error((json && (json.message || json.code)) || `HTTP ${res.status}`);
1849
+ throw new Error(apiDetail(res, json));
1659
1850
  }
1660
1851
  return json;
1661
1852
  }
@@ -1880,7 +2071,7 @@ ${JSON.stringify(config, null, 2)}
1880
2071
  }
1881
2072
 
1882
2073
  async function hook(args) {
1883
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2074
+ if (args.help) { process.stdout.write(`${commandHelp('hook')}\n`); return EXIT.OK; }
1884
2075
  if (args.print_config) { printHookConfig(); return EXIT.OK; }
1885
2076
 
1886
2077
  let event = {};
@@ -1984,12 +2175,16 @@ This command only prints setup instructions and does not modify client config.
1984
2175
  const CLI_SCOPES = [
1985
2176
  'pingroom:rooms:read', // resolve/display the connected room
1986
2177
  'pingroom:broadcast:send', // ping
2178
+ 'pingroom:attachments:write', // ping --attach (the upload leg)
2179
+ 'pingroom:notifications:read',// listen
1987
2180
  'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
1988
2181
  'pingroom:handoffs:create', // handoff / handoffs
1989
2182
  'pingroom:live:write', // live start/update/end/get
1990
2183
  ];
1991
2184
 
1992
- const AGENT_LABEL = 'pingroom-cli';
2185
+ // What the human reads on the approval screen. A product name, not a package
2186
+ // id: the phone shows it verbatim ("PingRoom CLI wants to connect").
2187
+ const AGENT_LABEL = 'PingRoom CLI';
1993
2188
  // A connect command should prove the phone round-trip, but it must not hold a
1994
2189
  // terminal for the onboarding Question's full 24-hour server TTL. The Question
1995
2190
  // remains answerable after this local deadline and the credential is already
@@ -2117,19 +2312,24 @@ async function registerAnonymous(apiBase) {
2117
2312
  body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
2118
2313
  });
2119
2314
  if (!res.ok || !json || typeof json.credential !== 'string') {
2120
- const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
2315
+ const detail = apiDetail(res, json);
2121
2316
  fail(`could not start a connection: ${detail}`);
2122
2317
  }
2123
2318
  return json.credential;
2124
2319
  }
2125
2320
 
2126
2321
  /** Persist the active credential plus the bits the status line prints. */
2127
- function saveCredential({ token, handle, room, account, scopes, apiBase }) {
2322
+ function saveCredential({ token, handle, room, rooms, roomAccess, account, scopes, apiBase }) {
2128
2323
  writeJsonFile(credentialsPath(), {
2129
2324
  version: 1,
2130
2325
  token,
2131
2326
  handle: handle || null,
2327
+ // `room` is the delivery room — where handoffs and questions land. `rooms`
2328
+ // is the whole grant, which can be wider; `room_access: "all"` means the
2329
+ // human granted every room they are in, listing none.
2132
2330
  room: room || null,
2331
+ rooms: Array.isArray(rooms) ? rooms : [],
2332
+ room_access: roomAccess || null,
2133
2333
  account: account || null,
2134
2334
  scopes: scopes || [],
2135
2335
  api_url: apiBase,
@@ -2137,11 +2337,22 @@ function saveCredential({ token, handle, room, account, scopes, apiBase }) {
2137
2337
  });
2138
2338
  }
2139
2339
 
2140
- /** "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown. */
2340
+ /**
2341
+ * "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown,
2342
+ * and widened to "→ all rooms" / "→ #Project X +2 more" when the human granted
2343
+ * this agent more than the one delivery room.
2344
+ */
2141
2345
  function connectedLine(cred) {
2142
2346
  const who = cred.handle ? `@${cred.handle}` : 'this machine';
2143
2347
  const room = cred.room && (cred.room.name || cred.room.invite_code);
2144
- return `✓ Connected as ${who}${room ? ` → #${room}` : ''}`;
2348
+ const access = cred.room_access ?? cred.roomAccess;
2349
+
2350
+ if (access === 'all') return `✓ Connected as ${who} → all rooms`;
2351
+
2352
+ if (!room) return `✓ Connected as ${who}`;
2353
+
2354
+ const extra = Math.max(0, (Array.isArray(cred.rooms) ? cred.rooms.length : 0) - 1);
2355
+ return `✓ Connected as ${who} → #${room}${extra > 0 ? ` +${extra} more` : ''}`;
2145
2356
  }
2146
2357
 
2147
2358
  function activationFailureDetail(result) {
@@ -2376,7 +2587,7 @@ async function activateInboxAfterPairing(cred) {
2376
2587
 
2377
2588
  /** Retry activation only for the durable credential created by QR pairing. */
2378
2589
  async function activateStoredInbox(args) {
2379
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2590
+ if (args.help) { process.stdout.write(`${commandHelp('activate')}\n`); return EXIT.OK; }
2380
2591
  if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
2381
2592
  if (args.token !== undefined) {
2382
2593
  fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
@@ -2391,7 +2602,15 @@ async function activateStoredInbox(args) {
2391
2602
  fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
2392
2603
  }
2393
2604
  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);
2605
+ // Granting every room is a valid answer that pins no destination, so the
2606
+ // fix there is picking one — not pairing again, which would only offer the
2607
+ // same choice back.
2608
+ fail(
2609
+ credential.room_access === 'all'
2610
+ ? 'this agent was granted all rooms but no delivery room; pick one in the PingRoom app under Connected Agents, then run "pingroom activate" again'
2611
+ : 'the saved credential has no QR-selected delivery room; reconnect with QR pairing before running "pingroom activate"',
2612
+ EXIT.USAGE,
2613
+ );
2395
2614
  }
2396
2615
  if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
2397
2616
  fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
@@ -2499,7 +2718,7 @@ async function connectByPairing(apiBase, ask) {
2499
2718
 
2500
2719
  if (!res.ok) {
2501
2720
  process.stdout.write('\n');
2502
- const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
2721
+ const detail = apiDetail(res, json);
2503
2722
  fail(`pairing failed: ${detail}`);
2504
2723
  }
2505
2724
  const status = json && json.status;
@@ -2516,13 +2735,19 @@ async function connectByPairing(apiBase, ask) {
2516
2735
  token: json.credential,
2517
2736
  handle: json.handle,
2518
2737
  room: json.room,
2738
+ rooms: Array.isArray(json.rooms) ? json.rooms : [],
2739
+ roomAccess: typeof json.room_access === 'string' ? json.room_access : null,
2519
2740
  account: json.account,
2520
2741
  scopes: json.scopes,
2521
2742
  apiBase,
2522
2743
  };
2523
2744
  saveCredential(cred);
2524
2745
  process.stdout.write(`${connectedLine(cred)}\n`);
2525
- await activateInboxAfterPairing(cred);
2746
+ // Connecting deliberately sends nothing to the human's phone. The
2747
+ // approval they just tapped IS the round-trip; a test Question on top of
2748
+ // it was one more thing to answer before the tool could be used, and it
2749
+ // made a healthy connection look broken whenever the answer was slow.
2750
+ // `pingroom activate` still sends one for anyone who wants the proof.
2526
2751
  return cred;
2527
2752
  }
2528
2753
  if (status === 'expired') break;
@@ -2715,7 +2940,7 @@ const CONFIG_KEYS = {
2715
2940
  };
2716
2941
 
2717
2942
  async function config(args) {
2718
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2943
+ if (args.help) { process.stdout.write(`${commandHelp('config')}\n`); return EXIT.OK; }
2719
2944
 
2720
2945
  const sub = args._[0];
2721
2946
  const known = ['list', 'get', 'set'];
@@ -2773,7 +2998,7 @@ async function config(args) {
2773
2998
  // --- logout ----------------------------------------------------------------
2774
2999
 
2775
3000
  async function logout(args) {
2776
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
3001
+ if (args.help) { process.stdout.write(`${commandHelp('logout')}\n`); return EXIT.OK; }
2777
3002
 
2778
3003
  const path = credentialsPath();
2779
3004
  const stored = readStoredCredential();
@@ -2795,6 +3020,33 @@ async function logout(args) {
2795
3020
  return EXIT.OK;
2796
3021
  }
2797
3022
 
3023
+ // config/logout/handoffs used to share parseQArgs, which silently accepted and
3024
+ // ignored flags those commands never read (`logout --wait --prompt x`). Minimal
3025
+ // tables instead, so an irrelevant flag is a usage error like everywhere else.
3026
+ const parseConfigArgs = makeParser({
3027
+ aliases: { '--json': 'json', '-h': 'help', '--help': 'help' },
3028
+ booleans: ['json', 'help'],
3029
+ bareDashIsPositional: true,
3030
+ });
3031
+
3032
+ const parseLogoutArgs = makeParser({
3033
+ aliases: { '-h': 'help', '--help': 'help' },
3034
+ booleans: ['help'],
3035
+ bareDashIsPositional: true,
3036
+ });
3037
+
3038
+ const parseHandoffsArgs = makeParser({
3039
+ aliases: {
3040
+ '--state': 'state',
3041
+ '--token': 'token',
3042
+ '--api': 'api',
3043
+ '--json': 'json',
3044
+ '-h': 'help', '--help': 'help',
3045
+ },
3046
+ booleans: ['json', 'help'],
3047
+ bareDashIsPositional: true,
3048
+ });
3049
+
2798
3050
  const COMMANDS = {
2799
3051
  ping: (rest) => ping(parseArgs(rest)),
2800
3052
  ask: (rest) => ask(parseQArgs(rest)),
@@ -2803,13 +3055,14 @@ const COMMANDS = {
2803
3055
  cancel: (rest) => cancel(parseQArgs(rest)),
2804
3056
  list: (rest) => list(parseQArgs(rest)),
2805
3057
  handoff: (rest) => handoff(parseHandoffArgs(rest)),
2806
- handoffs: (rest) => listHandoffs(parseQArgs(rest)),
3058
+ handoffs: (rest) => listHandoffs(parseHandoffsArgs(rest)),
3059
+ listen: (rest) => listen(parseQArgs(rest)),
2807
3060
  hook: (rest) => hook(parseHookArgs(rest)),
2808
3061
  mcp,
2809
3062
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
2810
3063
  live: (rest) => live(parseLiveArgs(rest)),
2811
- config: (rest) => config(parseQArgs(rest)),
2812
- logout: (rest) => logout(parseQArgs(rest)),
3064
+ config: (rest) => config(parseConfigArgs(rest)),
3065
+ logout: (rest) => logout(parseLogoutArgs(rest)),
2813
3066
  };
2814
3067
 
2815
3068
  function waitFrom(handler, rest) {
@@ -2847,4 +3100,13 @@ async function main() {
2847
3100
  process.exit(code);
2848
3101
  }
2849
3102
 
2850
- main();
3103
+ // Anything that escapes a handler is a bug in this tool, not a usage error, but
3104
+ // the operator still gets one clean line instead of a Node stack trace — and the
3105
+ // same exit 1 every other failure uses, so scripts branching on the code are
3106
+ // unaffected. PINGROOM_DEBUG keeps the stack for whoever is fixing it.
3107
+ main().catch((error) => {
3108
+ if (process.env.PINGROOM_DEBUG) {
3109
+ process.stderr.write(`${error?.stack ?? error}\n`);
3110
+ }
3111
+ fail(`unexpected error: ${stripControlChars(error?.message ?? String(error))}`);
3112
+ });