@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.
- package/README.md +85 -40
- package/bin/pingroom.js +259 -37
- 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:**
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
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
|
|
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), `
|
|
45
|
-
`
|
|
46
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 |
|
|
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 (
|
|
223
|
-
--option <value:label> Repeatable, up to 4 (
|
|
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
|
|
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.
|
|
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 |
|
|
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 (
|
|
142
|
-
--option <value:label> Repeatable, up to 4 (
|
|
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
|
|
169
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 =
|
|
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 = (
|
|
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: ${(
|
|
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
|
-
|
|
1099
|
-
|
|
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 =
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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((
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|