@pingroom/cli 0.6.2 → 0.7.0

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 +259 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,10 +6,10 @@ from CI, scripts, and agents. Delivered as push straight to your phone.
6
6
  One dependency (`qrcode-terminal`, used only to draw the pairing QR). Works
7
7
  anywhere Node ≥ 20 runs.
8
8
 
9
- > **Release status:** npm currently serves 0.6.1. The corrected attachment
10
- > limits are in the tested 0.6.2 release candidate on `main`; the public GitHub
11
- > Action remains pinned to 0.6.1 until 0.6.2 is published and clean-install
12
- > verified.
9
+ > **Release status:** 0.7.0 is prepared for publish — the room grant, the
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
12
+ > on it.
13
13
 
14
14
  ## Install and first run
15
15
 
@@ -27,8 +27,10 @@ npx --yes @pingroom/cli
27
27
  ```
28
28
 
29
29
  Either command starts the same connection prompt. QR pairing stores the selected
30
- account, credential, and delivery room in `~/.pingroom`; later commands reuse
30
+ account, credential, and room grant in `~/.pingroom`; later commands reuse
31
31
  them, so a local invocation needs neither `PINGROOM_TOKEN` nor `PINGROOM_ROOM`.
32
+ On the phone you grant one room, several, or every room on the account — the
33
+ first room you pick is the delivery room, where Questions and Handoffs land.
32
34
  The email fallback stores the credential only. Setting
33
35
  `pingroom config set default_room <invite-code>` enables room-addressed commands,
34
36
  but private Agent Inbox and Handoff delivery require a connection approved with
@@ -41,16 +43,17 @@ pingroom ping -m "Deploy succeeded ✅"
41
43
 
42
44
  Commands: `ping` (send), `ask` (ask a human), `watch` (block on an existing
43
45
  question), `list`, `cancel`, `handoff` (hand a decision to a specific human),
44
- `handoffs` (list open or recent Handoffs), `live` (lock-screen progress card),
45
- `hook` (Claude Code), `mcp` (client setup), `activate` (retry the Agent Inbox
46
- test), `config`, and `logout`.
46
+ `handoffs` (list open or recent Handoffs), `listen` (hear pings as they land),
47
+ `live` (lock-screen progress card),
48
+ `hook` (Claude Code), `mcp` (client setup), `activate` (send an optional test
49
+ Question), `config`, and `logout`.
47
50
  Run `pingroom --help` for the full reference.
48
51
 
49
52
  ## Connecting
50
53
 
51
54
  Run `pingroom` (global install) or `npx --yes @pingroom/cli` (no install) with no
52
- arguments. It prints a QR code — scan it with the PingRoom app and pick both the
53
- account and the room the agent delivers to — or take the emailed-code fallback.
55
+ arguments. It prints a QR code — scan it with the PingRoom app, pick the account,
56
+ and grant the rooms this agent may reach — or take the emailed-code fallback.
54
57
 
55
58
  ```
56
59
  $ pingroom
@@ -61,43 +64,55 @@ $ pingroom
61
64
 
62
65
  [QR]
63
66
  Or open: https://pingroom.io/app/agents/pair?token=…
64
- Waiting for approval… ✓ Connected as @agt_ab12cd34ef → #Project X
65
- Sending a test question to PingRoom…
66
- Answer “PingRoom connected. Can you answer this?” on your phone.
67
- Test question answered (Yes). Agent Inbox is ready.
68
- ```
69
-
70
- After QR approval, the CLI saves the active credential first, then sends one
71
- idempotent onboarding Question and observes it through the Handoff wait API.
72
- Short network and server failures are retried, and `Retry-After` is honored for
73
- rate limits within the two-minute overall deadline. An answer alone is not
74
- reported as success: the terminal response must also carry the server's exact
75
- `activation_completed: true` stamp. On supporting server and mobile builds,
76
- that stamp means the native phone returned the opaque proof carried in the push
77
- before the human answer, and this CLI then observed the result. An answered
78
- response whose stamp is false or missing is incomplete and is not retried as if
79
- history could be rewritten. If the Question is still pending when the local
80
- deadline elapses, or activation cannot start, the CLI prints the recovery
81
- command and exits with the connection still saved and usable. A terminal test
82
- without the stamp requires a current PingRoom app and a fresh numbered attempt;
83
- run `pingroom activate` again with the saved connection.
84
-
85
- Resume an open idempotent check with the saved QR credential:
67
+ Waiting for approval… ✓ Connected as @agt_ab12cd34ef → #Project X +2 more
68
+ ```
69
+
70
+ Approving on the phone is the whole ceremony — connecting sends nothing else to
71
+ your phone. The status line reflects the grant: `→ #Project X` for one room,
72
+ `→ #Project X +2 more` for several, `→ all rooms` when you granted every room.
73
+
74
+ ## Proving the round-trip (optional)
86
75
 
87
76
  ```bash
88
77
  pingroom activate
89
78
  ```
90
79
 
91
- An incomplete explicit retry exits `1`; it never deletes or replaces the saved
92
- credential. The command does not fall back to `PINGROOM_TOKEN`, an email-only
93
- credential, or a credential without `pingroom:handoffs:create` and a
94
- QR-selected delivery room. Email-code and other non-interactive credential
95
- flows do not run the phone-response loop automatically.
80
+ `activate` sends one idempotent onboarding Question and observes it through the
81
+ Handoff wait API. Short network and server failures are retried, and
82
+ `Retry-After` is honored for rate limits within the two-minute overall deadline.
83
+ An answer alone is not reported as success: the terminal response must also
84
+ carry the server's exact `activation_completed: true` stamp. On supporting
85
+ server and mobile builds, that stamp means the native phone returned the opaque
86
+ proof carried in the push before the human answer, and this CLI then observed
87
+ the result. An answered response whose stamp is false or missing is incomplete
88
+ and is not retried as if history could be rewritten.
89
+
90
+ An incomplete run exits `1`; it never deletes or replaces the saved credential.
91
+ The command does not fall back to `PINGROOM_TOKEN`, an email-only credential, or
92
+ a credential without `pingroom:handoffs:create` and a delivery room. A grant of
93
+ all rooms pins no delivery room — pick one under Connected Agents in the app
94
+ first.
96
95
 
97
96
  There is deliberately no `login` command: being unconnected is a state the tool
98
97
  resolves, not one you have to discover. Once connected, bare `pingroom` prints
99
98
  that status line followed by the usual help.
100
99
 
100
+ ### What the approval grants
101
+
102
+ Approving on the phone grants two separate things, and both are enforced:
103
+
104
+ - **Permissions** — the scopes this CLI asks for: `rooms:read`,
105
+ `broadcast:send`, `attachments:write`, `notifications:read`, `questions:ask`,
106
+ `handoffs:create`, `live:write`. Nothing widens them later; a command needing
107
+ one you did not approve returns `403 insufficient_scope`.
108
+ - **Rooms** — one room, several, or all of them. A room outside that grant
109
+ returns `403 room_not_granted` on any write, including pings, questions and
110
+ live streams. Widen it under Connected Agents in the app.
111
+
112
+ Both refusals print the fix, not just the code. A credential paired by an older
113
+ CLI carries the scope set that version asked for — reconnect to re-approve if a
114
+ command starts reporting `insufficient_scope`.
115
+
101
116
  The credential lands in `~/.pingroom/credentials.json` (mode `0600`, inside a
102
117
  `0700` directory). `PINGROOM_HOME` moves that directory; `pingroom logout`
103
118
  clears it.
@@ -206,12 +221,41 @@ on the Lock Screen (iOS Live Activity / Dynamic Island, Android live update, and
206
221
  a full inline card in the app). `start` opens it with one alert, `update` moves
207
222
  it **silently**, `end` closes it with one completion alert.
208
223
 
224
+ ## Hearing replies
225
+
226
+ Everything else here talks; `listen` is how an agent hears — replies to its own
227
+ structured pings, a human's ping in a room it belongs to, anything landing while
228
+ it works.
229
+
230
+ ```bash
231
+ pingroom listen # block, printing each ping as it lands
232
+ pingroom listen --once --json # one batch as JSON, then exit
233
+ pingroom listen --from "$LAST_ID" # catch up from a known ping id
234
+ ```
235
+
236
+ ```
237
+ --timeout <sec> Per long-poll hold (0-30, default 25)
238
+ --limit <n> Max pings per batch (1-100, default 50)
239
+ --from <id> Start after this ping id instead of "now"
240
+ --once Print one batch and exit instead of blocking forever
241
+ --json One JSON object per line instead of a readable line
242
+ ```
243
+
244
+ The server holds each request open until something arrives, so this is a
245
+ long-poll rather than a poll loop: an idle hour costs about 144 requests, not
246
+ one per second. With no `--from` it starts from *now* — the first call takes the
247
+ head cursor and returns nothing, so starting up never replays history. Transient
248
+ failures (429, 5xx, network) back off geometrically and keep listening;
249
+ `--once` is the form to use in a script.
250
+
209
251
  ```
210
252
  pingroom live <start|update|end|get> [options]
211
253
 
212
254
  -c, --correlation-id <id> The stream key — reuse it for every ping (required)
213
255
  --template <name> start only: status | steps | progress | metrics |
214
- countdown | question | matchup (fixed at creation)
256
+ countdown | decision | matchup (fixed at creation;
257
+ 'decision' is the app's name for the wire id
258
+ 'question', which is still accepted)
215
259
  --steps <a,b,c> start only: 2-8 comma-separated step labels
216
260
  -m, --message <text> The card's live message line
217
261
  --progress <0..1> Progress bar / Dynamic Island gauge
@@ -219,13 +263,14 @@ pingroom live <start|update|end|get> [options]
219
263
  --metric <label:value> Repeatable, up to 3 (metrics template)
220
264
  --deadline-at <epoch> Countdown target (countdown template)
221
265
  --eta-at <epoch> Live ETA (status/progress templates)
222
- --prompt <text> The ask (question template)
223
- --option <value:label> Repeatable, up to 4 (question template)
266
+ --prompt <text> The ask (decision template)
267
+ --option <value:label> Repeatable, up to 4 (decision template)
224
268
  --left <label:value> Left side (matchup template)
225
269
  --right <label:value> Right side (matchup template)
226
270
  --center <text> Center score/clock, <= 40 (matchup template)
227
271
  --accent-override <#rrggbb> Semantic accent for this frame
228
272
  --failed end only: finish as failed instead of done
273
+ -d, --data <json> Structured data object carried on this frame
229
274
  -t, --title <text> Card title (<= 40 chars)
230
275
  -a, --action <1-4> Quick-action slot supplying the icon and sound
231
276
  --require-ack Add an Acknowledge button
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,7 +41,7 @@ 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.0';
44
45
 
45
46
  const BUILTIN_API = 'https://api.pingroom.io';
46
47
  const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
@@ -60,6 +61,7 @@ Commands:
60
61
  handoff Hand a decision (ack or question) to a specific human; with --wait,
61
62
  block until they acknowledge or answer
62
63
  handoffs List the agent's open handoffs or bounded recent history
64
+ listen Block on pings arriving in your rooms and print them as they land
63
65
  live Drive a live progress card on the lock screen (Live Activity)
64
66
  hook Claude Code hook: ping on Stop/Notification, and route tool
65
67
  permission prompts to a PingRoom question you answer from your phone
@@ -124,10 +126,19 @@ handoff options (agent token required; consent scope pingroom:handoffs:create):
124
126
  handoffs options (agent token required; consent scope pingroom:handoffs:create):
125
127
  --state <s> open | all (default open)
126
128
 
129
+ listen options (agent token required; consent scope pingroom:notifications:read):
130
+ --timeout <sec> Per long-poll hold (0-30, default 25)
131
+ --limit <n> Max pings per batch (1-100, default 50)
132
+ --from <id> Start after this ping id instead of "now"
133
+ --once Print one batch and exit instead of blocking forever
134
+ --json One JSON object per line instead of a readable line
135
+
127
136
  live <start|update|end|get> options (agent token, or a room webhook):
128
137
  -c, --correlation-id <id> The stream key — reuse it for every ping (required)
129
138
  --template <name> start only: status | steps | progress | metrics |
130
- countdown | question | matchup (fixed at creation)
139
+ countdown | decision | matchup (fixed at creation;
140
+ 'decision' is the app's name for the wire id
141
+ 'question', which is still accepted)
131
142
  --category <name> start only: status | steps | alert. Legacy, but
132
143
  'alert' has no template equivalent and is the only
133
144
  way to start time-sensitive without --require-ack
@@ -138,14 +149,15 @@ live <start|update|end|get> options (agent token, or a room webhook):
138
149
  --metric <label:value> Repeatable, up to 3 (metrics template)
139
150
  --deadline-at <epoch> Countdown target (countdown template)
140
151
  --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
152
+ --prompt <text> The ask (decision template)
153
+ --option <value:label> Repeatable, up to 4 (decision template). A bare
143
154
  token is both value and label
144
155
  --left <label:value> Left side (matchup template)
145
156
  --right <label:value> Right side (matchup template)
146
157
  --center <text> Center score/clock, <= 40 (matchup template)
147
158
  --accent-override <#rrggbb> Semantic accent for this frame
148
159
  --failed end only: finish as failed instead of done
160
+ -d, --data <json> Structured data object carried on this frame
149
161
  -t, --title <text> Card title (<= 40 chars)
150
162
  -a, --action <1-4> Quick-action slot supplying the icon and sound
151
163
  --require-ack Add an Acknowledge button
@@ -165,8 +177,9 @@ mcp:
165
177
  (output-only; does not change client config)
166
178
 
167
179
  activate:
168
- pingroom activate Replay or create the next Agent Inbox test using
169
- the saved QR-paired credential
180
+ pingroom activate Send one test Question to your phone to prove the
181
+ saved QR-paired credential works (optional —
182
+ connecting no longer does this for you)
170
183
 
171
184
  config options:
172
185
  pingroom config list Print the stored settings
@@ -190,11 +203,10 @@ Connecting:
190
203
  npx --yes @pingroom/cli
191
204
 
192
205
  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.
206
+ the rooms it may reach there (one, several, or all of them). Once paired, it
207
+ saves the credential and you are done; connecting sends nothing to your phone.
208
+ Run "pingroom activate" if you want to prove the round-trip with one test
209
+ Question. The emailed-code fallback stores no server-side delivery room.
198
210
  "config set default_room" enables room-addressed commands, but private
199
211
  Inbox/Handoff delivery requires QR pairing.
200
212
  There is no "login" command: being unconnected is a state the tool resolves,
@@ -287,6 +299,32 @@ function fail(message, code = EXIT.ERROR) {
287
299
  process.exit(code);
288
300
  }
289
301
 
302
+ /**
303
+ * The fixes that live on THIS side of the wire. The server's message always
304
+ * leads; these are appended only for the codes where the operator would
305
+ * otherwise have no way to know what to do next, and where the answer is a
306
+ * local action rather than "try again".
307
+ */
308
+ const API_HINTS = {
309
+ room_not_granted:
310
+ '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.',
311
+ insufficient_scope:
312
+ 'This credential was approved before the command needed that permission. Run "pingroom" to reconnect and re-approve.',
313
+ no_room_configured:
314
+ 'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
315
+ };
316
+
317
+ /**
318
+ * What to print when an API call fails: the server's own wording, plus the one
319
+ * thing that would fix it when we know one.
320
+ */
321
+ function apiDetail(res, json) {
322
+ const base =
323
+ (json && (json.message || json.error || json.code)) || `HTTP ${res ? res.status : 'error'}`;
324
+ const hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
325
+ return hint ? `${base}\n ${hint}` : base;
326
+ }
327
+
290
328
  // --- local state (~/.pingroom) ---------------------------------------------
291
329
  //
292
330
  // Two files, both under a 0700 directory:
@@ -462,6 +500,20 @@ function sleep(ms) {
462
500
  // Drop C0/C1 control characters before echoing server-supplied text to the
463
501
  // terminal. Without this an attacker-controlled API base can smuggle ANSI
464
502
  // escapes into the output and repaint, erase or overwrite the lines around them.
503
+ /**
504
+ * Reject an over-long field here rather than letting it become a 422.
505
+ *
506
+ * Every bound mirrors a Laravel rule (StoreNotificationRequest,
507
+ * StoreQuestionRequest, LiveStatusRules) and is documented in --help, so a value
508
+ * past it was always going to be refused — locally it reads as the usage error
509
+ * it is, with the limit and the actual length named.
510
+ */
511
+ function requireMaxLength(value, max, flag) {
512
+ if (typeof value === 'string' && value.length > max) {
513
+ fail(`${flag} must be at most ${max} characters (got ${value.length})`, EXIT.USAGE);
514
+ }
515
+ }
516
+
465
517
  function stripControlChars(value) {
466
518
  // eslint-disable-next-line no-control-regex
467
519
  return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
@@ -534,6 +586,9 @@ function parseQArgs(argv) {
534
586
  '--text-max': 'text_max',
535
587
  '--timeout': 'timeout',
536
588
  '--state': 'state',
589
+ '--limit': 'limit',
590
+ '--from': 'from',
591
+ '--once': 'once',
537
592
  '--token': 'token',
538
593
  '--room': 'room',
539
594
  '--api': 'api',
@@ -541,7 +596,7 @@ function parseQArgs(argv) {
541
596
  '--json': 'json',
542
597
  '-h': 'help', '--help': 'help',
543
598
  };
544
- const booleans = new Set(['wait', 'json', 'help']);
599
+ const booleans = new Set(['wait', 'json', 'help', 'once']);
545
600
  const multi = new Set(['option']);
546
601
 
547
602
  for (let i = 0; i < argv.length; i++) {
@@ -772,7 +827,7 @@ async function uploadAttachments(paths, apiBase, token) {
772
827
  fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
773
828
  }
774
829
  if (!res.ok || !json?.attachment?.id) {
775
- const detail = json?.message || json?.error || `HTTP ${res.status}`;
830
+ const detail = apiDetail(res, json);
776
831
  fail(`upload failed for ${name}: ${detail}`);
777
832
  }
778
833
 
@@ -787,6 +842,8 @@ async function ping(args) {
787
842
 
788
843
  const message = args.message;
789
844
  if (!message) fail('a --message is required', EXIT.USAGE);
845
+ requireMaxLength(message, 500, '--message');
846
+ requireMaxLength(args.title, 40, '--title');
790
847
 
791
848
  if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
792
849
  fail('--action must be an integer 1–4', EXIT.USAGE);
@@ -890,7 +947,7 @@ async function ping(args) {
890
947
  const ok = res.ok && !(json && json.success === false);
891
948
 
892
949
  if (!ok) {
893
- const detail = (json && (json.message || json.error)) || `HTTP ${res.status}`;
950
+ const detail = apiDetail(res, json);
894
951
  fail(`delivery failed: ${detail}`);
895
952
  }
896
953
 
@@ -905,6 +962,26 @@ async function ping(args) {
905
962
  // --template line in HELP and with LIVE_ACTIVITY_TEMPLATES.md.
906
963
  const LIVE_TEMPLATES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'question', 'matchup'];
907
964
 
965
+ /**
966
+ * Names the API does not take, folded onto the wire id it does.
967
+ *
968
+ * The `question` template is labelled **Decision** everywhere a person sees it,
969
+ * so it is never confused with PingRoom's first-class Question protocol — that
970
+ * one is answered through `pingroom ask`, carries a real Question id, and this
971
+ * template does not. The wire id stayed `question`, so someone who reads
972
+ * "Decision" in the app and types it would otherwise get a usage error for
973
+ * using the only name they have been shown.
974
+ */
975
+ const LIVE_TEMPLATE_ALIASES = { decision: 'question' };
976
+
977
+ /** The wire id for a template name a human typed, or the name unchanged. */
978
+ function canonicalTemplate(name) {
979
+ return LIVE_TEMPLATE_ALIASES[name] ?? name;
980
+ }
981
+
982
+ /** What we offer in help and errors: the alias leads, since it is what the app shows. */
983
+ const LIVE_TEMPLATE_NAMES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'decision', 'matchup'];
984
+
908
985
  // Parser for `live`: a leading subcommand (start|update|end|get) plus the
909
986
  // live-status flags. Unknown flags fail like the other parsers.
910
987
  function parseLiveArgs(argv) {
@@ -1045,7 +1122,7 @@ async function live(args) {
1045
1122
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1046
1123
  if (args.json) process.stdout.write(`${text || '{}'}\n`);
1047
1124
  if (!res.ok) {
1048
- fail(`read failed: ${(json && (json.message || json.code)) || `HTTP ${res.status}`}`);
1125
+ fail(`read failed: ${apiDetail(res, json)}`);
1049
1126
  }
1050
1127
  if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
1051
1128
  return EXIT.OK;
@@ -1055,6 +1132,11 @@ async function live(args) {
1055
1132
  state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
1056
1133
  };
1057
1134
 
1135
+ // 256, not the 500 a ping body gets: this is the card's one live line.
1136
+ requireMaxLength(args.message, 256, '--message');
1137
+ requireMaxLength(args.title, 40, '--title');
1138
+ requireMaxLength(args.prompt, 256, '--prompt');
1139
+ requireMaxLength(args.center, 40, '--center');
1058
1140
  if (args.message !== undefined) liveStatus.message = args.message;
1059
1141
  if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
1060
1142
 
@@ -1095,10 +1177,11 @@ async function live(args) {
1095
1177
  // usage error, and letting it reach the server turns it into a 422 round
1096
1178
  // trip that reads like an outage.
1097
1179
  if (args.template) {
1098
- if (!LIVE_TEMPLATES.includes(args.template)) {
1099
- fail(`--template must be one of: ${LIVE_TEMPLATES.join(', ')}`, EXIT.USAGE);
1180
+ const template = canonicalTemplate(args.template);
1181
+ if (!LIVE_TEMPLATES.includes(template)) {
1182
+ fail(`--template must be one of: ${LIVE_TEMPLATE_NAMES.join(', ')}`, EXIT.USAGE);
1100
1183
  }
1101
- liveStatus.template = args.template;
1184
+ liveStatus.template = template;
1102
1185
  }
1103
1186
  // `alert` has no template equivalent and is the only way to start a stream
1104
1187
  // time-sensitive (breaking through Focus) without also demanding an ack.
@@ -1151,7 +1234,7 @@ async function live(args) {
1151
1234
  if (args.json) process.stdout.write(`${text || '{}'}\n`);
1152
1235
 
1153
1236
  if (!res.ok || (json && json.success === false)) {
1154
- const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
1237
+ const detail = apiDetail(res, json);
1155
1238
  fail(`live ${sub} failed: ${detail}`);
1156
1239
  }
1157
1240
 
@@ -1242,7 +1325,7 @@ async function waitForResolution(id, args, { token, apiBase }) {
1242
1325
  const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1243
1326
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1244
1327
  if (!res.ok) {
1245
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1328
+ const detail = apiDetail(res, json);
1246
1329
  fail(`wait failed: ${detail}`);
1247
1330
  }
1248
1331
  if (json && json.state && json.state !== 'pending') {
@@ -1259,6 +1342,8 @@ async function ask(args) {
1259
1342
 
1260
1343
  const prompt = args.prompt;
1261
1344
  if (!prompt) fail('a --prompt is required', EXIT.USAGE);
1345
+ requireMaxLength(prompt, 500, '--prompt');
1346
+ requireMaxLength(args.context, 40, '--context');
1262
1347
 
1263
1348
  const { token, apiBase, room } = agentContext(args, { needRoom: true });
1264
1349
 
@@ -1294,7 +1379,7 @@ async function ask(args) {
1294
1379
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
1295
1380
  const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
1296
1381
  if (!res.ok) {
1297
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1382
+ const detail = apiDetail(res, json);
1298
1383
  fail(`ask failed: ${detail}`);
1299
1384
  }
1300
1385
 
@@ -1323,7 +1408,7 @@ async function cancel(args) {
1323
1408
  const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
1324
1409
  const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
1325
1410
  if (!res.ok) {
1326
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1411
+ const detail = apiDetail(res, json);
1327
1412
  fail(`cancel failed: ${detail}`);
1328
1413
  }
1329
1414
  if (args.json) process.stdout.write(`${text}\n`);
@@ -1338,7 +1423,7 @@ async function list(args) {
1338
1423
  const url = `${apiBase}/api/agent/questions${qs}`;
1339
1424
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1340
1425
  if (!res.ok) {
1341
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1426
+ const detail = apiDetail(res, json);
1342
1427
  fail(`list failed: ${detail}`);
1343
1428
  }
1344
1429
  if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
@@ -1352,6 +1437,98 @@ async function list(args) {
1352
1437
  return EXIT.OK;
1353
1438
  }
1354
1439
 
1440
+ // --- listen ----------------------------------------------------------------
1441
+ //
1442
+ // The inbound half. Everything else here talks; this is how an agent hears —
1443
+ // replies to its own structured pings, a human's ping in a room it belongs to,
1444
+ // anything landing while it works.
1445
+ //
1446
+ // The server holds each request open until something arrives or the timeout
1447
+ // elapses, so this is a long-poll, not a poll loop: an idle hour costs ~144
1448
+ // requests, not one per second.
1449
+
1450
+ /** Cursor bookkeeping is the whole protocol: `after` in, `cursor` back. */
1451
+ async function listen(args) {
1452
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1453
+
1454
+ const { token, apiBase } = agentContext(args);
1455
+ const headers = { Authorization: `Bearer ${token}` };
1456
+
1457
+ const timeout = numberOption(args.timeout, '--timeout', { min: 0, max: 30, integer: true }) ?? 25;
1458
+ const limit = numberOption(args.limit, '--limit', { min: 1, max: 100, integer: true }) ?? 50;
1459
+
1460
+ // No cursor means "from now": the server answers an empty `after` with the
1461
+ // head id and no rows, so starting up never replays history the agent has
1462
+ // already seen. `--from` opts into catching up from a known id instead.
1463
+ let cursor = args.from;
1464
+ if (!cursor) {
1465
+ const { res, json } = await httpJson('GET', `${apiBase}/api/agent/notifications/wait`, {
1466
+ headers,
1467
+ soft: true,
1468
+ });
1469
+ if (!res?.ok) fail(`listen failed: ${apiDetail(res, json)}`);
1470
+ cursor = json && json.cursor;
1471
+ if (!cursor) {
1472
+ // A brand-new account with no pings at all has no head id. Nothing is
1473
+ // wrong; there is simply nothing to be after yet.
1474
+ cursor = '';
1475
+ }
1476
+ }
1477
+
1478
+ let transientRun = 0;
1479
+
1480
+ for (;;) {
1481
+ const query = new URLSearchParams({ timeout: String(timeout), limit: String(limit) });
1482
+ if (cursor) query.set('after', cursor);
1483
+
1484
+ const { res, json, error } = await httpJson(
1485
+ 'GET',
1486
+ `${apiBase}/api/agent/notifications/wait?${query}`,
1487
+ // The hold plus headroom: aborting at exactly the server's deadline would
1488
+ // race it and turn every quiet window into a client-side error.
1489
+ { headers, soft: true, signal: AbortSignal.timeout((timeout + 10) * 1000) },
1490
+ );
1491
+
1492
+ if (error || res.status === 429 || res.status >= 500) {
1493
+ transientRun += 1;
1494
+ const retryAfter = res?.status === 429 ? retryAfterMs(res) : null;
1495
+ // Geometric backoff so a real outage is not also a thundering herd. The
1496
+ // loop is unbounded by design — `listen` is a daemon, not a request.
1497
+ const backoff = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 30_000);
1498
+ await sleep(Math.max(0, retryAfter ?? backoff));
1499
+ continue;
1500
+ }
1501
+
1502
+ if (!res.ok) fail(`listen failed: ${apiDetail(res, json)}`);
1503
+ transientRun = 0;
1504
+
1505
+ const batch = Array.isArray(json?.notifications) ? json.notifications : [];
1506
+ for (const item of batch) {
1507
+ process.stdout.write(args.json ? `${JSON.stringify(item)}\n` : `${formatIncoming(item)}\n`);
1508
+ }
1509
+ // Advance only on a cursor the server actually returned, or a batch could be
1510
+ // replayed forever against a stale `after`.
1511
+ if (json && typeof json.cursor === 'string' && json.cursor) cursor = json.cursor;
1512
+
1513
+ if (args.once) return EXIT.OK;
1514
+ }
1515
+ }
1516
+
1517
+ /** One readable line per incoming ping. */
1518
+ function formatIncoming(item) {
1519
+ const room = item?.room?.name || item?.room?.code || '?';
1520
+ const body = stripControlChars(item?.message ?? '');
1521
+ const marks = [];
1522
+ if (item?.correlation_id) marks.push(`corr=${stripControlChars(item.correlation_id)}`);
1523
+ if (item?.reply_to) marks.push(`reply_to=${stripControlChars(item.reply_to)}`);
1524
+ if (item?.question) marks.push('question');
1525
+ if (Array.isArray(item?.attachments) && item.attachments.length) {
1526
+ marks.push(`${item.attachments.length} attachment${item.attachments.length === 1 ? '' : 's'}`);
1527
+ }
1528
+ const suffix = marks.length ? ` (${marks.join(' · ')})` : '';
1529
+ return `[${stripControlChars(room)}] ${body}${suffix}`;
1530
+ }
1531
+
1355
1532
  async function listHandoffs(args) {
1356
1533
  if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1357
1534
  const { token, apiBase } = agentContext(args);
@@ -1363,7 +1540,7 @@ async function listHandoffs(args) {
1363
1540
  const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
1364
1541
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1365
1542
  if (!res.ok) {
1366
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1543
+ const detail = apiDetail(res, json);
1367
1544
  fail(`handoffs list failed: ${detail}`);
1368
1545
  }
1369
1546
  if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
@@ -1479,7 +1656,7 @@ async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState
1479
1656
  const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1480
1657
  const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1481
1658
  if (!res.ok) {
1482
- const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
1659
+ const detail = apiDetail(res, json);
1483
1660
  fail(`wait failed: ${detail}`);
1484
1661
  }
1485
1662
  if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
@@ -1503,6 +1680,7 @@ async function handoff(args) {
1503
1680
 
1504
1681
  const message = args.message;
1505
1682
  if (!message) fail('a --message is required', EXIT.USAGE);
1683
+ requireMaxLength(message, 500, '--message');
1506
1684
 
1507
1685
  const { token, apiBase } = agentContext(args);
1508
1686
 
@@ -1552,7 +1730,7 @@ async function handoff(args) {
1552
1730
  const { res, text, json } = await httpJson('POST', url, { body, headers });
1553
1731
  if (!res.ok) {
1554
1732
  const code = json && json.code;
1555
- const detail = (json && (json.message || code)) || `HTTP ${res.status}`;
1733
+ const detail = apiDetail(res, json);
1556
1734
  // A recipient who isn't reachable yet is a distinct, retriable outcome (4),
1557
1735
  // not a generic error — CI may want to wait and retry rather than fail hard.
1558
1736
  if (res.status === 409 && code === 'recipient_not_ready') {
@@ -1655,7 +1833,7 @@ async function hookFetch(method, url, { body, token } = {}) {
1655
1833
  let json = null;
1656
1834
  try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
1657
1835
  if (!res.ok) {
1658
- throw new Error((json && (json.message || json.code)) || `HTTP ${res.status}`);
1836
+ throw new Error(apiDetail(res, json));
1659
1837
  }
1660
1838
  return json;
1661
1839
  }
@@ -1984,12 +2162,16 @@ This command only prints setup instructions and does not modify client config.
1984
2162
  const CLI_SCOPES = [
1985
2163
  'pingroom:rooms:read', // resolve/display the connected room
1986
2164
  'pingroom:broadcast:send', // ping
2165
+ 'pingroom:attachments:write', // ping --attach (the upload leg)
2166
+ 'pingroom:notifications:read',// listen
1987
2167
  'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
1988
2168
  'pingroom:handoffs:create', // handoff / handoffs
1989
2169
  'pingroom:live:write', // live start/update/end/get
1990
2170
  ];
1991
2171
 
1992
- const AGENT_LABEL = 'pingroom-cli';
2172
+ // What the human reads on the approval screen. A product name, not a package
2173
+ // id: the phone shows it verbatim ("PingRoom CLI wants to connect").
2174
+ const AGENT_LABEL = 'PingRoom CLI';
1993
2175
  // A connect command should prove the phone round-trip, but it must not hold a
1994
2176
  // terminal for the onboarding Question's full 24-hour server TTL. The Question
1995
2177
  // remains answerable after this local deadline and the credential is already
@@ -2117,19 +2299,24 @@ async function registerAnonymous(apiBase) {
2117
2299
  body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
2118
2300
  });
2119
2301
  if (!res.ok || !json || typeof json.credential !== 'string') {
2120
- const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
2302
+ const detail = apiDetail(res, json);
2121
2303
  fail(`could not start a connection: ${detail}`);
2122
2304
  }
2123
2305
  return json.credential;
2124
2306
  }
2125
2307
 
2126
2308
  /** Persist the active credential plus the bits the status line prints. */
2127
- function saveCredential({ token, handle, room, account, scopes, apiBase }) {
2309
+ function saveCredential({ token, handle, room, rooms, roomAccess, account, scopes, apiBase }) {
2128
2310
  writeJsonFile(credentialsPath(), {
2129
2311
  version: 1,
2130
2312
  token,
2131
2313
  handle: handle || null,
2314
+ // `room` is the delivery room — where handoffs and questions land. `rooms`
2315
+ // is the whole grant, which can be wider; `room_access: "all"` means the
2316
+ // human granted every room they are in, listing none.
2132
2317
  room: room || null,
2318
+ rooms: Array.isArray(rooms) ? rooms : [],
2319
+ room_access: roomAccess || null,
2133
2320
  account: account || null,
2134
2321
  scopes: scopes || [],
2135
2322
  api_url: apiBase,
@@ -2137,11 +2324,22 @@ function saveCredential({ token, handle, room, account, scopes, apiBase }) {
2137
2324
  });
2138
2325
  }
2139
2326
 
2140
- /** "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown. */
2327
+ /**
2328
+ * "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown,
2329
+ * and widened to "→ all rooms" / "→ #Project X +2 more" when the human granted
2330
+ * this agent more than the one delivery room.
2331
+ */
2141
2332
  function connectedLine(cred) {
2142
2333
  const who = cred.handle ? `@${cred.handle}` : 'this machine';
2143
2334
  const room = cred.room && (cred.room.name || cred.room.invite_code);
2144
- return `✓ Connected as ${who}${room ? ` → #${room}` : ''}`;
2335
+ const access = cred.room_access ?? cred.roomAccess;
2336
+
2337
+ if (access === 'all') return `✓ Connected as ${who} → all rooms`;
2338
+
2339
+ if (!room) return `✓ Connected as ${who}`;
2340
+
2341
+ const extra = Math.max(0, (Array.isArray(cred.rooms) ? cred.rooms.length : 0) - 1);
2342
+ return `✓ Connected as ${who} → #${room}${extra > 0 ? ` +${extra} more` : ''}`;
2145
2343
  }
2146
2344
 
2147
2345
  function activationFailureDetail(result) {
@@ -2391,7 +2589,15 @@ async function activateStoredInbox(args) {
2391
2589
  fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
2392
2590
  }
2393
2591
  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);
2592
+ // Granting every room is a valid answer that pins no destination, so the
2593
+ // fix there is picking one — not pairing again, which would only offer the
2594
+ // same choice back.
2595
+ fail(
2596
+ credential.room_access === 'all'
2597
+ ? 'this agent was granted all rooms but no delivery room; pick one in the PingRoom app under Connected Agents, then run "pingroom activate" again'
2598
+ : 'the saved credential has no QR-selected delivery room; reconnect with QR pairing before running "pingroom activate"',
2599
+ EXIT.USAGE,
2600
+ );
2395
2601
  }
2396
2602
  if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
2397
2603
  fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
@@ -2499,7 +2705,7 @@ async function connectByPairing(apiBase, ask) {
2499
2705
 
2500
2706
  if (!res.ok) {
2501
2707
  process.stdout.write('\n');
2502
- const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
2708
+ const detail = apiDetail(res, json);
2503
2709
  fail(`pairing failed: ${detail}`);
2504
2710
  }
2505
2711
  const status = json && json.status;
@@ -2516,13 +2722,19 @@ async function connectByPairing(apiBase, ask) {
2516
2722
  token: json.credential,
2517
2723
  handle: json.handle,
2518
2724
  room: json.room,
2725
+ rooms: Array.isArray(json.rooms) ? json.rooms : [],
2726
+ roomAccess: typeof json.room_access === 'string' ? json.room_access : null,
2519
2727
  account: json.account,
2520
2728
  scopes: json.scopes,
2521
2729
  apiBase,
2522
2730
  };
2523
2731
  saveCredential(cred);
2524
2732
  process.stdout.write(`${connectedLine(cred)}\n`);
2525
- await activateInboxAfterPairing(cred);
2733
+ // Connecting deliberately sends nothing to the human's phone. The
2734
+ // approval they just tapped IS the round-trip; a test Question on top of
2735
+ // it was one more thing to answer before the tool could be used, and it
2736
+ // made a healthy connection look broken whenever the answer was slow.
2737
+ // `pingroom activate` still sends one for anyone who wants the proof.
2526
2738
  return cred;
2527
2739
  }
2528
2740
  if (status === 'expired') break;
@@ -2804,6 +3016,7 @@ const COMMANDS = {
2804
3016
  list: (rest) => list(parseQArgs(rest)),
2805
3017
  handoff: (rest) => handoff(parseHandoffArgs(rest)),
2806
3018
  handoffs: (rest) => listHandoffs(parseQArgs(rest)),
3019
+ listen: (rest) => listen(parseQArgs(rest)),
2807
3020
  hook: (rest) => hook(parseHookArgs(rest)),
2808
3021
  mcp,
2809
3022
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
@@ -2847,4 +3060,13 @@ async function main() {
2847
3060
  process.exit(code);
2848
3061
  }
2849
3062
 
2850
- main();
3063
+ // Anything that escapes a handler is a bug in this tool, not a usage error, but
3064
+ // the operator still gets one clean line instead of a Node stack trace — and the
3065
+ // same exit 1 every other failure uses, so scripts branching on the code are
3066
+ // unaffected. PINGROOM_DEBUG keeps the stack for whoever is fixing it.
3067
+ main().catch((error) => {
3068
+ if (process.env.PINGROOM_DEBUG) {
3069
+ process.stderr.write(`${error?.stack ?? error}\n`);
3070
+ }
3071
+ fail(`unexpected error: ${stripControlChars(error?.message ?? String(error))}`);
3072
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {