@pingroom/cli 0.7.0 → 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 +2 -2
  2. package/bin/pingroom.js +236 -196
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,9 +6,9 @@ from CI, scripts, and agents. Delivered as push straight to your phone.
6
6
  One dependency (`qrcode-terminal`, used only to draw the pairing QR). Works
7
7
  anywhere Node ≥ 20 runs.
8
8
 
9
- > **Release status:** 0.7.0 is prepared for publish — the room grant, the
9
+ > **Release status:** 0.7.2 is prepared for publish — the room grant, the
10
10
  > `attachments:write` scope `--attach` always needed, and actionable text on the
11
- > refusals you can fix. The GitHub Action pins 0.7.0, so publish before relying
11
+ > refusals you can fix. The GitHub Action pins 0.7.2, so publish before relying
12
12
  > on it.
13
13
 
14
14
  ## Install and first run
package/bin/pingroom.js CHANGED
@@ -41,13 +41,17 @@ import { join } from 'node:path';
41
41
  // Kept in lockstep with package.json / package-lock.json. The GitHub Action is
42
42
  // pinned independently to the latest version already published on npm; a test
43
43
  // makes that release gate explicit. `hook --print-config` emits this candidate.
44
- const VERSION = '0.7.0';
44
+ const VERSION = '0.7.2';
45
45
 
46
46
  const BUILTIN_API = 'https://api.pingroom.io';
47
47
  const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
48
48
  const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
49
49
 
50
- 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
51
55
 
52
56
  Usage:
53
57
  pingroom <command> [options]
@@ -69,9 +73,9 @@ Commands:
69
73
  Claude Desktop
70
74
  activate Retry Agent Inbox activation with the saved QR-paired credential
71
75
  config Read/write local settings (config list | get <key> | set <key> <val>)
72
- logout Forget the stored credential
76
+ logout Forget the stored credential`;
73
77
 
74
- ping options:
78
+ const HELP_PING = `ping options:
75
79
  -m, --message <text> Ping body text (required)
76
80
  -t, --title <text> Ping title (<= 40 chars)
77
81
  -a, --action <1-4> Quick-action slot to attribute the ping to
@@ -84,9 +88,9 @@ ping options:
84
88
  repeat for up to 4. Requires --token and a Pro account
85
89
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
86
90
  --token <token> Agent access token (or env PINGROOM_TOKEN)
87
- --room <code> Room invite code (used with --token)
91
+ --room <code> Room invite code (used with --token)`;
88
92
 
89
- ask options (agent token required):
93
+ const HELP_ASK = `ask options (agent token required):
90
94
  -p, --prompt <text> The question a human reads (required)
91
95
  -o, --option <v:label[:style]>
92
96
  An answer option (style: primary|danger|default);
@@ -102,12 +106,12 @@ ask options (agent token required):
102
106
  -d, --data <json> Structured data object echoed back on the answer
103
107
  --correlation-id <id> Opaque id echoed on every read of this question
104
108
  --reply-to <id> Id of the ping this question replies to
105
- --room <code> Room invite code (required for ask)
109
+ --room <code> Room invite code (required for ask)`;
106
110
 
107
- list options:
108
- --state <s> pending | answered | expired | cancelled | all
111
+ const HELP_LIST = `list options:
112
+ --state <s> pending | answered | expired | cancelled | all`;
109
113
 
110
- handoff options (agent token required; consent scope pingroom:handoffs:create):
114
+ const HELP_HANDOFF = `handoff options (agent token required; consent scope pingroom:handoffs:create):
111
115
  -m, --message <text> The prompt a human reads (required)
112
116
  --question Make it a question (else a simple acknowledge). Also
113
117
  implied whenever one or more --option is given.
@@ -121,19 +125,19 @@ handoff options (agent token required; consent scope pingroom:handoffs:create):
121
125
  -d, --data <json> Structured data object echoed on the handoff
122
126
  --wait Block until acked / answered / expired / cancelled
123
127
  --timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
124
- --github-output <path> Safely append handoff outputs for GitHub Actions
128
+ --github-output <path> Safely append handoff outputs for GitHub Actions`;
125
129
 
126
- handoffs options (agent token required; consent scope pingroom:handoffs:create):
127
- --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)`;
128
132
 
129
- listen options (agent token required; consent scope pingroom:notifications:read):
133
+ const HELP_LISTEN = `listen options (agent token required; consent scope pingroom:notifications:read):
130
134
  --timeout <sec> Per long-poll hold (0-30, default 25)
131
135
  --limit <n> Max pings per batch (1-100, default 50)
132
136
  --from <id> Start after this ping id instead of "now"
133
137
  --once Print one batch and exit instead of blocking forever
134
- --json One JSON object per line instead of a readable line
138
+ --json One JSON object per line instead of a readable line`;
135
139
 
136
- live <start|update|end|get> options (agent token, or a room webhook):
140
+ const HELP_LIVE = `live <start|update|end|get> options (agent token, or a room webhook):
137
141
  -c, --correlation-id <id> The stream key — reuse it for every ping (required)
138
142
  --template <name> start only: status | steps | progress | metrics |
139
143
  countdown | decision | matchup (fixed at creation;
@@ -163,38 +167,38 @@ live <start|update|end|get> options (agent token, or a room webhook):
163
167
  --require-ack Add an Acknowledge button
164
168
  --ack-timeout <s> Ack deadline in seconds
165
169
  --room <code> Room invite code (used with --token)
166
- -w, --webhook <url> Room webhook URL instead of a token
170
+ -w, --webhook <url> Room webhook URL instead of a token`;
167
171
 
168
- 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):
169
173
  --room <code> Room invite code (or env/config/paired room)
170
174
  --ttl <seconds> Approval-question expiry for PreToolUse (default 900)
171
175
  --quiet Suppress the informational stderr lines
172
- --print-config Print a ready-to-paste ~/.claude/settings.json block
176
+ --print-config Print a ready-to-paste ~/.claude/settings.json block`;
173
177
 
174
- mcp:
178
+ const HELP_MCP = `mcp:
175
179
  pingroom mcp Print the endpoint and client setup snippets
176
180
  pingroom mcp add claude-code Print the Claude Code setup command
177
- (output-only; does not change client config)
181
+ (output-only; does not change client config)`;
178
182
 
179
- activate:
183
+ const HELP_ACTIVATE = `activate:
180
184
  pingroom activate Send one test Question to your phone to prove the
181
185
  saved QR-paired credential works (optional —
182
- connecting no longer does this for you)
186
+ connecting no longer does this for you)`;
183
187
 
184
- config options:
188
+ const HELP_CONFIG = `config options:
185
189
  pingroom config list Print the stored settings
186
190
  pingroom config get <key> Print one setting
187
191
  pingroom config set <key> <val> Store a setting (an empty value clears it)
188
- Keys: default_room, api_url
192
+ Keys: default_room, api_url`;
189
193
 
190
- Shared:
194
+ const HELP_SHARED = `Shared:
191
195
  --token <token> Agent access token (or env PINGROOM_TOKEN)
192
196
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
193
197
  --json Print the raw JSON response
194
198
  -h, --help Show this help
195
- -v, --version Show the CLI version
199
+ -v, --version Show the CLI version`;
196
200
 
197
- Connecting:
201
+ const HELP_TAIL = `Connecting:
198
202
  Install globally, then run with no arguments:
199
203
  npm install --global @pingroom/cli
200
204
  pingroom
@@ -292,6 +296,54 @@ or the recipient was not ready (409 recipient_not_ready). A question answered
292
296
  with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
293
297
  human decision is not an infrastructure failure.`;
294
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
+
295
347
  const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
296
348
 
297
349
  function fail(message, code = EXIT.ERROR) {
@@ -319,8 +371,11 @@ const API_HINTS = {
319
371
  * thing that would fix it when we know one.
320
372
  */
321
373
  function apiDetail(res, json) {
322
- const base =
323
- (json && (json.message || json.error || json.code)) || `HTTP ${res ? res.status : 'error'}`;
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
+ );
324
379
  const hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
325
380
  return hint ? `${base}\n ${hint}` : base;
326
381
  }
@@ -514,16 +569,72 @@ function requireMaxLength(value, max, flag) {
514
569
  }
515
570
  }
516
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
+
517
585
  function stripControlChars(value) {
518
586
  // eslint-disable-next-line no-control-regex
519
587
  return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
520
588
  }
521
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
+
522
634
  // --- ping (unchanged wire behaviour) ---------------------------------------
523
635
 
524
- function parseArgs(argv) {
525
- const args = { _: [] };
526
- const alias = {
636
+ const parseArgs = makeParser({
637
+ aliases: {
527
638
  '-m': 'message', '--message': 'message',
528
639
  '-t': 'title', '--title': 'title',
529
640
  '-a': 'action', '--action': 'action',
@@ -539,40 +650,15 @@ function parseArgs(argv) {
539
650
  '--api': 'api',
540
651
  '--json': 'json',
541
652
  '-h': 'help', '--help': 'help',
542
- };
543
- const booleans = new Set(['require_ack', 'json', 'help']);
544
- const repeatable = new Set(['attach']);
545
-
546
- for (let i = 0; i < argv.length; i++) {
547
- const token = argv[i];
548
- // Object.hasOwn, not alias[token]: a bare lookup walks the prototype chain,
549
- // so `constructor` / `toString` / `__proto__` in flag position resolve to a
550
- // truthy inherited value, get treated as an option, and swallow the next
551
- // argument instead of failing as an unknown flag.
552
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
553
- if (key && booleans.has(key)) {
554
- args[key] = true;
555
- } else if (key) {
556
- const value = argv[++i];
557
- if (value === undefined) {
558
- fail(`option ${token} needs a value`, EXIT.USAGE);
559
- }
560
- if (repeatable.has(key)) (args[key] ||= []).push(value);
561
- else args[key] = value;
562
- } else if (token.startsWith('-')) {
563
- fail(`Unknown option: ${token}`, EXIT.USAGE);
564
- } else {
565
- args._.push(token);
566
- }
567
- }
568
- return args;
569
- }
653
+ },
654
+ booleans: ['require_ack', 'json', 'help'],
655
+ repeatable: ['attach'],
656
+ });
570
657
 
571
658
  // Parser for the question commands: supports repeatable --option and a trailing
572
659
  // positional (a question id). Unknown flags fail like the ping parser.
573
- function parseQArgs(argv) {
574
- const args = { _: [] };
575
- const alias = {
660
+ const parseQArgs = makeParser({
661
+ aliases: {
576
662
  '-p': 'prompt', '--prompt': 'prompt',
577
663
  '-o': 'option', '--option': 'option',
578
664
  '-c': 'context', '--context': 'context',
@@ -595,40 +681,16 @@ function parseQArgs(argv) {
595
681
  '--wait': 'wait',
596
682
  '--json': 'json',
597
683
  '-h': 'help', '--help': 'help',
598
- };
599
- const booleans = new Set(['wait', 'json', 'help', 'once']);
600
- const multi = new Set(['option']);
601
-
602
- for (let i = 0; i < argv.length; i++) {
603
- const token = argv[i];
604
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
605
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
606
- if (key && booleans.has(key)) {
607
- args[key] = true;
608
- } else if (key) {
609
- const value = argv[++i];
610
- if (value === undefined) {
611
- fail(`option ${token} needs a value`, EXIT.USAGE);
612
- }
613
- if (multi.has(key)) {
614
- (args[key] ||= []).push(value);
615
- } else {
616
- args[key] = value;
617
- }
618
- } else if (token.startsWith('-') && token !== '-') {
619
- fail(`Unknown option: ${token}`, EXIT.USAGE);
620
- } else {
621
- args._.push(token);
622
- }
623
- }
624
- return args;
625
- }
684
+ },
685
+ booleans: ['wait', 'json', 'help', 'once'],
686
+ repeatable: ['option'],
687
+ bareDashIsPositional: true,
688
+ });
626
689
 
627
690
  // Parser for `handoff`: --message plus repeatable --option, boolean --question,
628
691
  // and the handoff-specific flags. Unknown flags fail like the other parsers.
629
- function parseHandoffArgs(argv) {
630
- const args = { _: [] };
631
- const alias = {
692
+ const parseHandoffArgs = makeParser({
693
+ aliases: {
632
694
  '-m': 'message', '--message': 'message',
633
695
  '--question': 'question',
634
696
  '-o': 'option', '--option': 'option',
@@ -646,34 +708,11 @@ function parseHandoffArgs(argv) {
646
708
  '--wait': 'wait',
647
709
  '--json': 'json',
648
710
  '-h': 'help', '--help': 'help',
649
- };
650
- const booleans = new Set(['question', 'wait', 'json', 'help']);
651
- const multi = new Set(['option']);
652
-
653
- for (let i = 0; i < argv.length; i++) {
654
- const token = argv[i];
655
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
656
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
657
- if (key && booleans.has(key)) {
658
- args[key] = true;
659
- } else if (key) {
660
- const value = argv[++i];
661
- if (value === undefined) {
662
- fail(`option ${token} needs a value`, EXIT.USAGE);
663
- }
664
- if (multi.has(key)) {
665
- (args[key] ||= []).push(value);
666
- } else {
667
- args[key] = value;
668
- }
669
- } else if (token.startsWith('-') && token !== '-') {
670
- fail(`Unknown option: ${token}`, EXIT.USAGE);
671
- } else {
672
- args._.push(token);
673
- }
674
- }
675
- return args;
676
- }
711
+ },
712
+ booleans: ['question', 'wait', 'json', 'help'],
713
+ repeatable: ['option'],
714
+ bareDashIsPositional: true,
715
+ });
677
716
 
678
717
  // True when a URL is safe to attach a bearer token or webhook secret to: https,
679
718
  // or http on loopback so local dev against http://localhost still works.
@@ -838,7 +877,7 @@ async function uploadAttachments(paths, apiBase, token) {
838
877
  }
839
878
 
840
879
  async function ping(args) {
841
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
880
+ if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
842
881
 
843
882
  const message = args.message;
844
883
  if (!message) fail('a --message is required', EXIT.USAGE);
@@ -984,9 +1023,8 @@ const LIVE_TEMPLATE_NAMES = ['status', 'steps', 'progress', 'metrics', 'countdow
984
1023
 
985
1024
  // Parser for `live`: a leading subcommand (start|update|end|get) plus the
986
1025
  // live-status flags. Unknown flags fail like the other parsers.
987
- function parseLiveArgs(argv) {
988
- const args = { _: [] };
989
- const alias = {
1026
+ const parseLiveArgs = makeParser({
1027
+ aliases: {
990
1028
  '-c': 'correlation_id', '--correlation-id': 'correlation_id',
991
1029
  '-t': 'title', '--title': 'title',
992
1030
  '-m': 'message', '--message': 'message',
@@ -1015,29 +1053,10 @@ function parseLiveArgs(argv) {
1015
1053
  '--api': 'api',
1016
1054
  '--json': 'json',
1017
1055
  '-h': 'help', '--help': 'help',
1018
- };
1019
- const booleans = new Set(['require_ack', 'json', 'help', 'failed']);
1020
- const repeatable = new Set(['metric', 'option']);
1021
-
1022
- for (let i = 0; i < argv.length; i++) {
1023
- const token = argv[i];
1024
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1025
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
1026
- if (key && booleans.has(key)) {
1027
- args[key] = true;
1028
- } else if (key) {
1029
- const value = argv[++i];
1030
- if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
1031
- if (repeatable.has(key)) (args[key] ||= []).push(value);
1032
- else args[key] = value;
1033
- } else if (token.startsWith('-')) {
1034
- fail(`Unknown option: ${token}`, EXIT.USAGE);
1035
- } else {
1036
- args._.push(token);
1037
- }
1038
- }
1039
- return args;
1040
- }
1056
+ },
1057
+ booleans: ['require_ack', 'json', 'help', 'failed'],
1058
+ repeatable: ['metric', 'option'],
1059
+ });
1041
1060
 
1042
1061
  // "label:value" -> {label, value}. Only the first colon splits.
1043
1062
  function buildMetrics(list) {
@@ -1099,6 +1118,7 @@ function numberOption(raw, flag, { min, max, integer = false } = {}) {
1099
1118
  * (--webhook), which speak the same `live_status` contract.
1100
1119
  */
1101
1120
  async function live(args) {
1121
+ if (args.help) { process.stdout.write(`${commandHelp('live')}\n`); return EXIT.OK; }
1102
1122
  const sub = args._[0];
1103
1123
  const known = ['start', 'update', 'end', 'get'];
1104
1124
  if (!sub || !known.includes(sub)) {
@@ -1317,11 +1337,10 @@ function printResolution(q) {
1317
1337
  // and return the state's exit code. The server expires it at its ttl, so this
1318
1338
  // always terminates.
1319
1339
  async function waitForResolution(id, args, { token, apiBase }) {
1320
- let hold = args.timeout !== undefined ? Number(args.timeout) : 25;
1321
- if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
1322
- hold = Math.min(hold, 30);
1340
+ const hold = resolveWaitHold(args, { def: 25, cap: 30 });
1323
1341
 
1324
1342
  for (;;) {
1343
+ const started = Date.now();
1325
1344
  const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1326
1345
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1327
1346
  if (!res.ok) {
@@ -1333,12 +1352,16 @@ async function waitForResolution(id, args, { token, apiBase }) {
1333
1352
  else printResolution(json);
1334
1353
  return exitForState(json.state);
1335
1354
  }
1336
- // 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);
1337
1360
  }
1338
1361
  }
1339
1362
 
1340
1363
  async function ask(args) {
1341
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1364
+ if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
1342
1365
 
1343
1366
  const prompt = args.prompt;
1344
1367
  if (!prompt) fail('a --prompt is required', EXIT.USAGE);
@@ -1376,6 +1399,9 @@ async function ask(args) {
1376
1399
  }
1377
1400
  if (args.data !== undefined) body.data = parseDataObject(args.data);
1378
1401
 
1402
+ // Pre-flight: reject a bad --timeout before the question exists.
1403
+ if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
1404
+
1379
1405
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
1380
1406
  const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
1381
1407
  if (!res.ok) {
@@ -1393,7 +1419,7 @@ async function ask(args) {
1393
1419
  }
1394
1420
 
1395
1421
  async function watch(args) {
1396
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1422
+ if (args.help) { process.stdout.write(`${commandHelp('watch')}\n`); return EXIT.OK; }
1397
1423
  const id = args._[0];
1398
1424
  if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
1399
1425
  const { token, apiBase } = agentContext(args);
@@ -1401,7 +1427,7 @@ async function watch(args) {
1401
1427
  }
1402
1428
 
1403
1429
  async function cancel(args) {
1404
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1430
+ if (args.help) { process.stdout.write(`${commandHelp('cancel')}\n`); return EXIT.OK; }
1405
1431
  const id = args._[0];
1406
1432
  if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
1407
1433
  const { token, apiBase } = agentContext(args);
@@ -1417,7 +1443,7 @@ async function cancel(args) {
1417
1443
  }
1418
1444
 
1419
1445
  async function list(args) {
1420
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1446
+ if (args.help) { process.stdout.write(`${commandHelp('list')}\n`); return EXIT.OK; }
1421
1447
  const { token, apiBase } = agentContext(args);
1422
1448
  const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
1423
1449
  const url = `${apiBase}/api/agent/questions${qs}`;
@@ -1449,7 +1475,7 @@ async function list(args) {
1449
1475
 
1450
1476
  /** Cursor bookkeeping is the whole protocol: `after` in, `cursor` back. */
1451
1477
  async function listen(args) {
1452
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1478
+ if (args.help) { process.stdout.write(`${commandHelp('listen')}\n`); return EXIT.OK; }
1453
1479
 
1454
1480
  const { token, apiBase } = agentContext(args);
1455
1481
  const headers = { Authorization: `Bearer ${token}` };
@@ -1530,7 +1556,7 @@ function formatIncoming(item) {
1530
1556
  }
1531
1557
 
1532
1558
  async function listHandoffs(args) {
1533
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1559
+ if (args.help) { process.stdout.write(`${commandHelp('handoffs')}\n`); return EXIT.OK; }
1534
1560
  const { token, apiBase } = agentContext(args);
1535
1561
  const state = args.state || 'open';
1536
1562
  if (state !== 'open' && state !== 'all') {
@@ -1648,11 +1674,10 @@ function writeGitHubHandoffOutputs(path, h) {
1648
1674
  // Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
1649
1675
  // print it and return the state's exit code. Reuses the shared bounded hold.
1650
1676
  async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
1651
- let hold = args.timeout !== undefined ? Number(args.timeout) : 20;
1652
- if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
1653
- hold = Math.min(hold, 25);
1677
+ const hold = resolveWaitHold(args, { def: 20, cap: 25 });
1654
1678
 
1655
1679
  for (;;) {
1680
+ const started = Date.now();
1656
1681
  const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1657
1682
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1658
1683
  if (!res.ok) {
@@ -1671,12 +1696,15 @@ async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState
1671
1696
  else printHandoff(resolved);
1672
1697
  return exitForHandoffState(resolved.state);
1673
1698
  }
1674
- // 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);
1675
1703
  }
1676
1704
  }
1677
1705
 
1678
1706
  async function handoff(args) {
1679
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1707
+ if (args.help) { process.stdout.write(`${commandHelp('handoff')}\n`); return EXIT.OK; }
1680
1708
 
1681
1709
  const message = args.message;
1682
1710
  if (!message) fail('a --message is required', EXIT.USAGE);
@@ -1726,6 +1754,9 @@ async function handoff(args) {
1726
1754
  headers['Idempotency-Key'] = args.idempotency_key;
1727
1755
  }
1728
1756
 
1757
+ // Pre-flight: reject a bad --timeout before the handoff exists.
1758
+ if (args.wait) resolveWaitHold(args, { def: 20, cap: 25 });
1759
+
1729
1760
  const url = `${apiBase}/api/agent/handoffs`;
1730
1761
  const { res, text, json } = await httpJson('POST', url, { body, headers });
1731
1762
  if (!res.ok) {
@@ -1765,9 +1796,8 @@ async function handoff(args) {
1765
1796
  // normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
1766
1797
  // must not call fail() (a non-zero exit — 2 especially — would break the run).
1767
1798
 
1768
- function parseHookArgs(argv) {
1769
- const args = { _: [] };
1770
- const alias = {
1799
+ const parseHookArgs = makeParser({
1800
+ aliases: {
1771
1801
  '--room': 'room',
1772
1802
  '--ttl': 'ttl',
1773
1803
  '--quiet': 'quiet',
@@ -1776,27 +1806,10 @@ function parseHookArgs(argv) {
1776
1806
  '--api': 'api',
1777
1807
  '--json': 'json',
1778
1808
  '-h': 'help', '--help': 'help',
1779
- };
1780
- const booleans = new Set(['quiet', 'print_config', 'json', 'help']);
1781
-
1782
- for (let i = 0; i < argv.length; i++) {
1783
- const token = argv[i];
1784
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1785
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
1786
- if (key && booleans.has(key)) {
1787
- args[key] = true;
1788
- } else if (key) {
1789
- const value = argv[++i];
1790
- if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
1791
- args[key] = value;
1792
- } else if (token.startsWith('-') && token !== '-') {
1793
- fail(`Unknown option: ${token}`, EXIT.USAGE);
1794
- } else {
1795
- args._.push(token);
1796
- }
1797
- }
1798
- return args;
1799
- }
1809
+ },
1810
+ booleans: ['quiet', 'print_config', 'json', 'help'],
1811
+ bareDashIsPositional: true,
1812
+ });
1800
1813
 
1801
1814
  // Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
1802
1815
  // stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
@@ -2058,7 +2071,7 @@ ${JSON.stringify(config, null, 2)}
2058
2071
  }
2059
2072
 
2060
2073
  async function hook(args) {
2061
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2074
+ if (args.help) { process.stdout.write(`${commandHelp('hook')}\n`); return EXIT.OK; }
2062
2075
  if (args.print_config) { printHookConfig(); return EXIT.OK; }
2063
2076
 
2064
2077
  let event = {};
@@ -2574,7 +2587,7 @@ async function activateInboxAfterPairing(cred) {
2574
2587
 
2575
2588
  /** Retry activation only for the durable credential created by QR pairing. */
2576
2589
  async function activateStoredInbox(args) {
2577
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2590
+ if (args.help) { process.stdout.write(`${commandHelp('activate')}\n`); return EXIT.OK; }
2578
2591
  if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
2579
2592
  if (args.token !== undefined) {
2580
2593
  fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
@@ -2927,7 +2940,7 @@ const CONFIG_KEYS = {
2927
2940
  };
2928
2941
 
2929
2942
  async function config(args) {
2930
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2943
+ if (args.help) { process.stdout.write(`${commandHelp('config')}\n`); return EXIT.OK; }
2931
2944
 
2932
2945
  const sub = args._[0];
2933
2946
  const known = ['list', 'get', 'set'];
@@ -2985,7 +2998,7 @@ async function config(args) {
2985
2998
  // --- logout ----------------------------------------------------------------
2986
2999
 
2987
3000
  async function logout(args) {
2988
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
3001
+ if (args.help) { process.stdout.write(`${commandHelp('logout')}\n`); return EXIT.OK; }
2989
3002
 
2990
3003
  const path = credentialsPath();
2991
3004
  const stored = readStoredCredential();
@@ -3007,6 +3020,33 @@ async function logout(args) {
3007
3020
  return EXIT.OK;
3008
3021
  }
3009
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
+
3010
3050
  const COMMANDS = {
3011
3051
  ping: (rest) => ping(parseArgs(rest)),
3012
3052
  ask: (rest) => ask(parseQArgs(rest)),
@@ -3015,14 +3055,14 @@ const COMMANDS = {
3015
3055
  cancel: (rest) => cancel(parseQArgs(rest)),
3016
3056
  list: (rest) => list(parseQArgs(rest)),
3017
3057
  handoff: (rest) => handoff(parseHandoffArgs(rest)),
3018
- handoffs: (rest) => listHandoffs(parseQArgs(rest)),
3058
+ handoffs: (rest) => listHandoffs(parseHandoffsArgs(rest)),
3019
3059
  listen: (rest) => listen(parseQArgs(rest)),
3020
3060
  hook: (rest) => hook(parseHookArgs(rest)),
3021
3061
  mcp,
3022
3062
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
3023
3063
  live: (rest) => live(parseLiveArgs(rest)),
3024
- config: (rest) => config(parseQArgs(rest)),
3025
- logout: (rest) => logout(parseQArgs(rest)),
3064
+ config: (rest) => config(parseConfigArgs(rest)),
3065
+ logout: (rest) => logout(parseLogoutArgs(rest)),
3026
3066
  };
3027
3067
 
3028
3068
  function waitFrom(handler, rest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {