@pingroom/cli 0.4.0 → 0.6.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 +158 -2
  2. package/bin/pingroom.js +1138 -28
  3. package/package.json +4 -1
package/bin/pingroom.js CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // @pingroom/cli — pings and human-in-the-loop questions for CI, scripts, agents.
3
- // Zero dependencies: uses Node's built-in fetch (Node >= 20).
3
+ // Node's built-in fetch (Node >= 20) plus one optional dependency,
4
+ // `qrcode-terminal`, used only to draw the pairing QR. Its absence degrades to
5
+ // printing the pair URL, so every non-interactive path stays dependency-free.
6
+ //
7
+ // Run bare (`pingroom`) it resolves its own auth: connected -> a status line and
8
+ // this help; not connected -> the pairing picker. There is deliberately no
9
+ // `login` subcommand.
4
10
  //
5
11
  // Commands:
6
12
  // ping Send a ping to a room. Webhook mode (a room URL carries its own
@@ -13,19 +19,29 @@
13
19
  // handoff Hand a decision to a specific human (ack or question) and, with
14
20
  // --wait, block until they acknowledge / answer.
15
21
  // handoffs List the agent's open handoffs or bounded recent history.
22
+ // live Drive a live progress card (iOS Live Activity / Android live
23
+ // update) on the room members' lock screen: start / update / end.
24
+ // config Read/write ~/.pingroom/config.json (default_room, api_url).
25
+ // logout Forget the credential in ~/.pingroom/credentials.json.
16
26
  //
17
27
  // Exit codes: 0 success/answered/acked · 1 error · 2 bad usage · 3 expired ·
18
28
  // 4 cancelled/recipient-not-ready.
19
29
 
20
30
  import { randomBytes } from 'node:crypto';
21
- import { appendFileSync, readFileSync } from 'node:fs';
31
+ import {
32
+ appendFileSync, chmodSync, closeSync, fchmodSync, mkdirSync, openSync,
33
+ readFileSync, renameSync, unlinkSync, writeFileSync,
34
+ } from 'node:fs';
35
+ import { homedir } from 'node:os';
36
+ import { join } from 'node:path';
22
37
 
23
38
  // Kept in lockstep with package.json / package-lock.json / action.yml (a test
24
39
  // asserts the GitHub Action pins this exact version). `hook --print-config`
25
40
  // emits an `npx @pingroom/cli@<VERSION>` command, so it must match too.
26
- const VERSION = '0.4.0';
41
+ const VERSION = '0.6.0';
27
42
 
28
- const DEFAULT_API = process.env.PINGROOM_API_URL || 'https://api.pingroom.io';
43
+ const BUILTIN_API = 'https://api.pingroom.io';
44
+ const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
29
45
 
30
46
  const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
31
47
 
@@ -41,14 +57,19 @@ Commands:
41
57
  handoff Hand a decision (ack or question) to a specific human; with --wait,
42
58
  block until they acknowledge or answer
43
59
  handoffs List the agent's open handoffs or bounded recent history
60
+ live Drive a live progress card on the lock screen (Live Activity)
44
61
  hook Claude Code hook: ping on Stop/Notification, and route tool
45
62
  permission prompts to a PingRoom question you answer from your phone
63
+ config Read/write local settings (config list | get <key> | set <key> <val>)
64
+ logout Forget the stored credential
46
65
 
47
66
  ping options:
48
67
  -m, --message <text> Ping body text (required)
49
68
  -t, --title <text> Ping title (<= 40 chars)
50
69
  -a, --action <1-4> Quick-action slot to attribute the ping to
51
70
  -d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
71
+ --url <https-url> Make the ping a tappable link (absolute http(s) URL)
72
+ --button-label <t> Link button text (<= 26 chars; requires --url)
52
73
  --require-ack Keep the ping open until an eligible recipient acknowledges it
53
74
  --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
54
75
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
@@ -57,15 +78,20 @@ ping options:
57
78
 
58
79
  ask options (agent token required):
59
80
  -p, --prompt <text> The question a human reads (required)
60
- -o, --option <v:label> An answer option; repeat for 2–4. Omit for Approve/Deny
81
+ -o, --option <v:label[:style]>
82
+ An answer option (style: primary|danger|default);
83
+ repeat for 2–4. Omit for Approve/Deny
61
84
  -c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
62
85
  --scope <s> Who answers: 'direct' (default) or 'room'
63
86
  --target <uuid> For --scope direct: a specific room member
64
87
  --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
88
+ --text-input <ph> Invite a short typed answer; <ph> is the placeholder
89
+ --text-max <n> Max typed-answer length (1..60)
65
90
  --wait Block until answered/expired/cancelled
66
91
  --timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
67
92
  -d, --data <json> Structured data object echoed back on the answer
68
93
  --correlation-id <id> Opaque id echoed on every read of this question
94
+ --reply-to <id> Id of the ping this question replies to
69
95
  --room <code> Room invite code (required for ask)
70
96
 
71
97
  list options:
@@ -90,22 +116,83 @@ handoff options (agent token required; consent scope pingroom:handoffs:create):
90
116
  handoffs options (agent token required; consent scope pingroom:handoffs:create):
91
117
  --state <s> open | all (default open)
92
118
 
119
+ live <start|update|end|get> options (agent token, or a room webhook):
120
+ -c, --correlation-id <id> The stream key — reuse it for every ping (required)
121
+ --template <name> start only: status | steps | progress | metrics |
122
+ countdown | question | matchup (fixed at creation)
123
+ --category <name> start only: status | steps | alert. Legacy, but
124
+ 'alert' has no template equivalent and is the only
125
+ way to start time-sensitive without --require-ack
126
+ --steps <a,b,c> start only: 2-8 comma-separated step labels
127
+ -m, --message <text> The card's live message line
128
+ --progress <0..1> Progress bar / Dynamic Island gauge
129
+ --step <n> Current step index (steps template)
130
+ --metric <label:value> Repeatable, up to 3 (metrics template)
131
+ --deadline-at <epoch> Countdown target (countdown template)
132
+ --eta-at <epoch> Live ETA (status/progress templates)
133
+ --prompt <text> The ask (question template)
134
+ --option <value:label> Repeatable, up to 4 (question template). A bare
135
+ token is both value and label
136
+ --left <label:value> Left side (matchup template)
137
+ --right <label:value> Right side (matchup template)
138
+ --center <text> Center score/clock, <= 40 (matchup template)
139
+ --accent-override <#rrggbb> Semantic accent for this frame
140
+ --failed end only: finish as failed instead of done
141
+ -t, --title <text> Card title (<= 40 chars)
142
+ -a, --action <1-4> Quick-action slot supplying the icon and sound
143
+ --require-ack Add an Acknowledge button
144
+ --ack-timeout <s> Ack deadline in seconds
145
+ --room <code> Room invite code (used with --token)
146
+ -w, --webhook <url> Room webhook URL instead of a token
147
+
93
148
  hook options (agent token required; reads a Claude Code hook event on stdin):
94
149
  --room <code> Room invite code (or env PINGROOM_ROOM)
95
150
  --ttl <seconds> Approval-question expiry for PreToolUse (default 900)
96
151
  --quiet Suppress the informational stderr lines
97
152
  --print-config Print a ready-to-paste ~/.claude/settings.json block
98
153
 
154
+ config options:
155
+ pingroom config list Print the stored settings
156
+ pingroom config get <key> Print one setting
157
+ pingroom config set <key> <val> Store a setting (an empty value clears it)
158
+ Keys: default_room, api_url
159
+
99
160
  Shared:
100
161
  --token <token> Agent access token (or env PINGROOM_TOKEN)
101
162
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
102
163
  --json Print the raw JSON response
103
164
  -h, --help Show this help
104
165
 
166
+ Connecting:
167
+ Run "pingroom" with no arguments to connect. It prints a QR code you scan with
168
+ the PingRoom app — you pick the account and the delivery room there — or you
169
+ can choose the emailed-code fallback. There is no "login" command: being
170
+ unconnected is a state the tool resolves, not one you have to discover.
171
+
172
+ The credential is written to ~/.pingroom/credentials.json (mode 0600, in a
173
+ 0700 directory). PINGROOM_HOME overrides that directory. PINGROOM_TOKEN in the
174
+ environment ALWAYS wins over the stored credential, so CI is unaffected.
175
+ "pingroom logout" forgets it.
176
+
177
+ Settings precedence, highest first:
178
+ explicit flag > env var > ~/.pingroom/config.json > the paired
179
+ credential > built-in default
180
+ So --room beats PINGROOM_ROOM beats "config set default_room", and --api beats
181
+ PINGROOM_API_URL beats "config set api_url" beats the host you paired against,
182
+ beats ${BUILTIN_API}. The credential layer is why a token minted by a
183
+ self-hosted server is never presented to ${BUILTIN_API}.
184
+
185
+ Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
186
+ PINGROOM_TOKEN there instead.
187
+
105
188
  Examples:
106
189
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
107
190
  pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
108
191
 
192
+ # Link ping — a tappable button that opens a URL:
193
+ pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
194
+ --url https://ci.example.com/builds/512 --button-label "Open build"
195
+
109
196
  # Gate a deploy on a human tap — the chosen value prints to stdout:
110
197
  if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
111
198
  -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
@@ -128,6 +215,16 @@ Examples:
128
215
 
129
216
  pingroom handoffs --token "$T" --state all # recent history (up to 200/kind)
130
217
 
218
+ # A live deploy card on everyone's lock screen — one stream, three calls:
219
+ pingroom live start --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
220
+ --template steps --steps "Build,Test,Stage,Ship" -t "Deploy 2.1.0"
221
+ pingroom live update --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
222
+ --step 2 -m "Smoke tests green"
223
+ pingroom live end --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
224
+ -m "Live on production"
225
+ # ...or end it as a failure, which still delivers one completion alert:
226
+ # pingroom live end ... --failed -m "Rollback triggered"
227
+
131
228
  # Connect Claude Code to your phone (prints the settings.json to paste):
132
229
  pingroom hook --print-config
133
230
 
@@ -150,6 +247,149 @@ function fail(message, code = EXIT.ERROR) {
150
247
  process.exit(code);
151
248
  }
152
249
 
250
+ // --- local state (~/.pingroom) ---------------------------------------------
251
+ //
252
+ // Two files, both under a 0700 directory:
253
+ // credentials.json the agent credential this machine paired (mode 0600)
254
+ // config.json user settings: default_room, api_url
255
+ //
256
+ // PINGROOM_HOME relocates the directory (tests, sandboxes, multi-account
257
+ // shells). Every lookup is layered: explicit flag > env var > config file >
258
+ // the paired credential > built-in default. PINGROOM_TOKEN is the one env var
259
+ // that also outranks the stored credential, which is what keeps CI working
260
+ // untouched.
261
+
262
+ function pingroomHome() {
263
+ return process.env.PINGROOM_HOME || join(homedir(), '.pingroom');
264
+ }
265
+
266
+ function credentialsPath() { return join(pingroomHome(), 'credentials.json'); }
267
+ function configPath() { return join(pingroomHome(), 'config.json'); }
268
+
269
+ // Read a JSON object, or null for anything unreadable/corrupt. Local state must
270
+ // never be able to crash a ping: a hand-edited file degrades to "not set".
271
+ function readJsonFile(path) {
272
+ let raw;
273
+ try { raw = readFileSync(path, 'utf8'); } catch { return null; }
274
+ let value;
275
+ try { value = JSON.parse(raw); } catch { return null; }
276
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
277
+ return value;
278
+ }
279
+
280
+ // Write JSON with restrictive permissions, atomically.
281
+ //
282
+ // Writing in place truncates first, so a crash or a full disk between truncate
283
+ // and write leaves a half-written file — and readJsonFile() degrades anything
284
+ // unparseable to {}, so the *next* `config set` would silently drop every other
285
+ // setting. Writing a sibling temp file and renaming over the target means a
286
+ // reader only ever sees the old file or the new one, never a torn one.
287
+ //
288
+ // The temp file is opened 'wx' with mode 0600 and fchmod'd before a single byte
289
+ // is written: `mode` on an existing file is ignored and a post-write chmod
290
+ // leaves a window where the credential is world-readable. rename() carries the
291
+ // 0600 over the target, so a pre-existing loose file is tightened too.
292
+ //
293
+ // mkdirSync(recursive) returns the first path it created, or undefined when the
294
+ // directory already existed. chmod'ing only on the former keeps this from
295
+ // narrowing a directory the user deliberately created at 0755.
296
+ function writeJsonFile(path, value) {
297
+ const dir = pingroomHome();
298
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
299
+ let fd;
300
+ try {
301
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
302
+ if (created !== undefined) chmodSync(dir, 0o700);
303
+
304
+ fd = openSync(tmp, 'wx', 0o600);
305
+ fchmodSync(fd, 0o600); // defeat a permissive umask masking the open mode
306
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
307
+ closeSync(fd);
308
+ fd = undefined;
309
+ renameSync(tmp, path);
310
+ } catch (err) {
311
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
312
+ try { unlinkSync(tmp); } catch { /* never created */ }
313
+ fail(`could not write ${path}: ${err.message}`);
314
+ }
315
+ }
316
+
317
+ function readStoredCredential() {
318
+ const cred = readJsonFile(credentialsPath());
319
+ if (!cred || typeof cred.token !== 'string' || cred.token === '') return null;
320
+ return cred;
321
+ }
322
+
323
+ function readConfigFile() {
324
+ return readJsonFile(configPath()) || {};
325
+ }
326
+
327
+ /** Agent token: --token > PINGROOM_TOKEN > the paired credential. */
328
+ function resolveToken(args) {
329
+ return args.token || process.env.PINGROOM_TOKEN || readStoredCredential()?.token || undefined;
330
+ }
331
+
332
+ /**
333
+ * API base: --api > PINGROOM_API_URL > config.api_url > the host the credential
334
+ * was paired against > built-in, no trailing slash.
335
+ *
336
+ * The credential layer is not optional. saveCredential() records `api_url`, and
337
+ * a token minted by a self-hosted / staging server is only valid there; without
338
+ * this layer the next command would present that bearer to api.pingroom.io —
339
+ * leaking it to a host it was never issued for. resolveRoom() already consults
340
+ * the credential last, so the two layerings now agree.
341
+ */
342
+ function resolveApiBase(args) {
343
+ const raw = args.api
344
+ || process.env.PINGROOM_API_URL
345
+ || readConfigFile().api_url
346
+ || readStoredCredential()?.api_url
347
+ || BUILTIN_API;
348
+ return String(raw).replace(/\/$/, '');
349
+ }
350
+
351
+ /**
352
+ * Room invite code: --room > PINGROOM_ROOM > config.default_room > the room the
353
+ * credential was paired to. The paired room is last because it is the weakest
354
+ * signal — it is where the agent was told to deliver, not necessarily where
355
+ * this invocation means to.
356
+ */
357
+ function resolveRoom(args) {
358
+ return args.room
359
+ || process.env.PINGROOM_ROOM
360
+ || readConfigFile().default_room
361
+ || readStoredCredential()?.room?.invite_code
362
+ || undefined;
363
+ }
364
+
365
+ /**
366
+ * True when it is safe to prompt / draw a QR. Both streams must be a TTY: a
367
+ * piped stdin cannot answer a prompt and a piped stdout would capture the QR as
368
+ * garbage.
369
+ *
370
+ * The override is deliberately double-locked (internal-looking name AND
371
+ * NODE_ENV=test) and not documented in --help. A single well-known env var
372
+ * shipping in the published binary is one stray `export` away from making a CI
373
+ * job prompt into the void and poll for the full 15-minute pairing window
374
+ * instead of failing in a second.
375
+ */
376
+ function isInteractive() {
377
+ if (process.env.PINGROOM_INTERNAL_TEST_TTY === '1' && process.env.NODE_ENV === 'test') return true;
378
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
379
+ }
380
+
381
+ function sleep(ms) {
382
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
383
+ }
384
+
385
+ // Drop C0/C1 control characters before echoing server-supplied text to the
386
+ // terminal. Without this an attacker-controlled API base can smuggle ANSI
387
+ // escapes into the output and repaint, erase or overwrite the lines around them.
388
+ function stripControlChars(value) {
389
+ // eslint-disable-next-line no-control-regex
390
+ return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
391
+ }
392
+
153
393
  // --- ping (unchanged wire behaviour) ---------------------------------------
154
394
 
155
395
  function parseArgs(argv) {
@@ -160,6 +400,8 @@ function parseArgs(argv) {
160
400
  '-a': 'action', '--action': 'action',
161
401
  '-d': 'data', '--data': 'data',
162
402
  '-w': 'webhook', '--webhook': 'webhook',
403
+ '--url': 'url',
404
+ '--button-label': 'button_label',
163
405
  '--require-ack': 'require_ack',
164
406
  '--ack-timeout': 'ack_timeout',
165
407
  '--token': 'token',
@@ -172,7 +414,11 @@ function parseArgs(argv) {
172
414
 
173
415
  for (let i = 0; i < argv.length; i++) {
174
416
  const token = argv[i];
175
- const key = alias[token];
417
+ // Object.hasOwn, not alias[token]: a bare lookup walks the prototype chain,
418
+ // so `constructor` / `toString` / `__proto__` in flag position resolve to a
419
+ // truthy inherited value, get treated as an option, and swallow the next
420
+ // argument instead of failing as an unknown flag.
421
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
176
422
  if (key && booleans.has(key)) {
177
423
  args[key] = true;
178
424
  } else if (key) {
@@ -203,6 +449,9 @@ function parseQArgs(argv) {
203
449
  '--ttl': 'ttl',
204
450
  '-d': 'data', '--data': 'data',
205
451
  '--correlation-id': 'correlation_id',
452
+ '--reply-to': 'reply_to',
453
+ '--text-input': 'text_input',
454
+ '--text-max': 'text_max',
206
455
  '--timeout': 'timeout',
207
456
  '--state': 'state',
208
457
  '--token': 'token',
@@ -217,7 +466,8 @@ function parseQArgs(argv) {
217
466
 
218
467
  for (let i = 0; i < argv.length; i++) {
219
468
  const token = argv[i];
220
- const key = alias[token];
469
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
470
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
221
471
  if (key && booleans.has(key)) {
222
472
  args[key] = true;
223
473
  } else if (key) {
@@ -267,7 +517,8 @@ function parseHandoffArgs(argv) {
267
517
 
268
518
  for (let i = 0; i < argv.length; i++) {
269
519
  const token = argv[i];
270
- const key = alias[token];
520
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
521
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
271
522
  if (key && booleans.has(key)) {
272
523
  args[key] = true;
273
524
  } else if (key) {
@@ -318,7 +569,11 @@ function parseDataObject(raw) {
318
569
  return data;
319
570
  }
320
571
 
321
- async function httpJson(method, url, { body, headers = {} } = {}) {
572
+ // `soft: true` returns { error } instead of exiting on a transport failure. Only
573
+ // the pairing poll passes it: there, a single DNS blip or dropped connection
574
+ // would otherwise kill a 15-minute wait the human is still walking towards their
575
+ // phone for. Every other caller keeps the hard exit.
576
+ async function httpJson(method, url, { body, headers = {}, soft = false } = {}) {
322
577
  let res;
323
578
  try {
324
579
  res = await fetch(url, {
@@ -331,10 +586,18 @@ async function httpJson(method, url, { body, headers = {} } = {}) {
331
586
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
332
587
  });
333
588
  } catch (err) {
589
+ if (soft) return { res: null, text: '', json: null, error: err };
334
590
  fail(`network error: ${err.message}`);
335
591
  }
336
592
 
337
- const text = await res.text();
593
+ let text;
594
+ try {
595
+ text = await res.text();
596
+ } catch (err) {
597
+ // A connection dropped mid-body throws here, not at fetch().
598
+ if (soft) return { res: null, text: '', json: null, error: err };
599
+ fail(`network error: ${err.message}`);
600
+ }
338
601
  let json = null;
339
602
  try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
340
603
 
@@ -367,9 +630,35 @@ async function ping(args) {
367
630
  data = parseDataObject(args.data);
368
631
  }
369
632
 
633
+ // Link ping: --url/--button-label fold into the structured data object
634
+ // (server contract: data.url = absolute http(s) <= 2048, data.button_label <= 26).
635
+ if (args.button_label !== undefined && args.url === undefined) {
636
+ fail('--button-label requires --url', EXIT.USAGE);
637
+ }
638
+ if (args.url !== undefined) {
639
+ let linkUrl;
640
+ try {
641
+ linkUrl = new URL(args.url);
642
+ } catch {
643
+ fail('--url is not a valid URL', EXIT.USAGE);
644
+ }
645
+ if (linkUrl.protocol !== 'https:' && linkUrl.protocol !== 'http:') {
646
+ fail('--url must be an absolute http(s) URL', EXIT.USAGE);
647
+ }
648
+ if (args.url.length > 2048) {
649
+ fail('--url must be at most 2048 characters', EXIT.USAGE);
650
+ }
651
+ if (args.button_label !== undefined && args.button_label.length > 26) {
652
+ fail('--button-label must be at most 26 characters', EXIT.USAGE);
653
+ }
654
+ data = { ...(data || {}), url: args.url };
655
+ if (args.button_label !== undefined) data.button_label = args.button_label;
656
+ }
657
+
370
658
  const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
371
- const token = args.token || process.env.PINGROOM_TOKEN;
372
- const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
659
+ const token = resolveToken(args);
660
+ const apiBase = resolveApiBase(args);
661
+ const room = resolveRoom(args);
373
662
 
374
663
  let result;
375
664
 
@@ -386,12 +675,12 @@ async function ping(args) {
386
675
  if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
387
676
  result = await httpJson('POST', webhook, { body });
388
677
  } else if (token) {
389
- if (!args.room) fail('--room is required when using --token', EXIT.USAGE);
678
+ if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
390
679
  if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
391
680
  fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
392
681
  }
393
682
  requireSafeUrl('--api', apiBase);
394
- const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(args.room)}/notifications`;
683
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`;
395
684
  const body = { message };
396
685
  if (args.title) body.title = args.title;
397
686
  if (args.action !== undefined) body.action_number = Number(args.action);
@@ -400,7 +689,7 @@ async function ping(args) {
400
689
  if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
401
690
  result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
402
691
  } else {
403
- fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
692
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
404
693
  }
405
694
 
406
695
  const { res, text, json } = result;
@@ -420,15 +709,275 @@ async function ping(args) {
420
709
  return EXIT.OK;
421
710
  }
422
711
 
712
+ // --- live status -----------------------------------------------------------
713
+
714
+ // Parser for `live`: a leading subcommand (start|update|end|get) plus the
715
+ // live-status flags. Unknown flags fail like the other parsers.
716
+ function parseLiveArgs(argv) {
717
+ const args = { _: [] };
718
+ const alias = {
719
+ '-c': 'correlation_id', '--correlation-id': 'correlation_id',
720
+ '-t': 'title', '--title': 'title',
721
+ '-m': 'message', '--message': 'message',
722
+ '--template': 'template',
723
+ '--category': 'category',
724
+ '--progress': 'progress',
725
+ '--step': 'step',
726
+ '--steps': 'steps',
727
+ '--metric': 'metric',
728
+ '--deadline-at': 'deadline_at',
729
+ '--eta-at': 'eta_at',
730
+ '--prompt': 'prompt',
731
+ '--option': 'option',
732
+ '--left': 'left',
733
+ '--right': 'right',
734
+ '--center': 'center',
735
+ '--accent-override': 'accent_override',
736
+ '--failed': 'failed',
737
+ '-a': 'action', '--action': 'action',
738
+ '-d': 'data', '--data': 'data',
739
+ '--require-ack': 'require_ack',
740
+ '--ack-timeout': 'ack_timeout',
741
+ '-w': 'webhook', '--webhook': 'webhook',
742
+ '--token': 'token',
743
+ '--room': 'room',
744
+ '--api': 'api',
745
+ '--json': 'json',
746
+ '-h': 'help', '--help': 'help',
747
+ };
748
+ const booleans = new Set(['require_ack', 'json', 'help', 'failed']);
749
+ const repeatable = new Set(['metric', 'option']);
750
+
751
+ for (let i = 0; i < argv.length; i++) {
752
+ const token = argv[i];
753
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
754
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
755
+ if (key && booleans.has(key)) {
756
+ args[key] = true;
757
+ } else if (key) {
758
+ const value = argv[++i];
759
+ if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
760
+ if (repeatable.has(key)) (args[key] ||= []).push(value);
761
+ else args[key] = value;
762
+ } else if (token.startsWith('-')) {
763
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
764
+ } else {
765
+ args._.push(token);
766
+ }
767
+ }
768
+ return args;
769
+ }
770
+
771
+ // "label:value" -> {label, value}. Only the first colon splits.
772
+ function buildMetrics(list) {
773
+ if (!list || list.length === 0) return undefined;
774
+ return list.map((spec) => {
775
+ const idx = spec.indexOf(':');
776
+ if (idx <= 0) fail(`--metric must be "label:value" (got "${spec}")`, EXIT.USAGE);
777
+ return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
778
+ });
779
+ }
780
+
781
+ // "value:label" -> {value, label}; a bare token is both. Matches the `ask`
782
+ // command's option syntax minus `style`, which live_status options don't carry.
783
+ function buildLiveOptions(list) {
784
+ if (!list || list.length === 0) return undefined;
785
+ return list.map((spec) => {
786
+ const idx = spec.indexOf(':');
787
+ if (idx < 0) return { value: spec, label: spec };
788
+ if (idx === 0) fail(`--option needs a value before the colon (got "${spec}")`, EXIT.USAGE);
789
+ return { value: spec.slice(0, idx), label: spec.slice(idx + 1) };
790
+ });
791
+ }
792
+
793
+ // "label:value" -> {label, value}, for --left / --right on the matchup template.
794
+ function buildSide(spec, flag) {
795
+ if (spec === undefined) return undefined;
796
+ const idx = spec.indexOf(':');
797
+ if (idx <= 0) fail(`${flag} must be "label:value" (got "${spec}")`, EXIT.USAGE);
798
+ return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
799
+ }
800
+
801
+ // The server accepts #rrggbb with or without the leading #; normalize to one
802
+ // form so a shell that ate the # (unquoted) still produces a valid payload.
803
+ function normalizeAccent(raw) {
804
+ if (raw === undefined) return undefined;
805
+ const hex = raw.trim().replace(/^#/, '');
806
+ if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
807
+ fail(`--accent-override must be a 6-digit hex color (got "${raw}")`, EXIT.USAGE);
808
+ }
809
+ return `#${hex.toLowerCase()}`;
810
+ }
811
+
812
+ function numberOption(raw, flag, { min, max, integer = false } = {}) {
813
+ if (raw === undefined) return undefined;
814
+ const value = Number(raw);
815
+ if (!Number.isFinite(value)) fail(`${flag} must be a number`, EXIT.USAGE);
816
+ if (integer && !Number.isInteger(value)) fail(`${flag} must be an integer`, EXIT.USAGE);
817
+ if (min !== undefined && value < min) fail(`${flag} must be at least ${min}`, EXIT.USAGE);
818
+ if (max !== undefined && value > max) fail(`${flag} must be at most ${max}`, EXIT.USAGE);
819
+ return value;
820
+ }
821
+
822
+ /**
823
+ * Drive a live progress card on the room members' lock screen.
824
+ *
825
+ * One correlation id = one stream: `start` opens it (one alert), `update` moves
826
+ * it silently, `end` closes it with one completion alert. Works with either an
827
+ * agent token (--token, needs pingroom:live:write) or a room's incoming webhook
828
+ * (--webhook), which speak the same `live_status` contract.
829
+ */
830
+ async function live(args) {
831
+ const sub = args._[0];
832
+ const known = ['start', 'update', 'end', 'get'];
833
+ if (!sub || !known.includes(sub)) {
834
+ fail(`live needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
835
+ }
836
+
837
+ const correlationId = args.correlation_id;
838
+ if (!correlationId) fail('--correlation-id is required', EXIT.USAGE);
839
+
840
+ const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
841
+ const token = resolveToken(args);
842
+ const apiBase = resolveApiBase(args);
843
+ const room = resolveRoom(args);
844
+
845
+ if (sub === 'get') {
846
+ if (!token) fail('live get requires an agent token (--token or PINGROOM_TOKEN)', EXIT.USAGE);
847
+ if (!room) fail('--room is required', EXIT.USAGE);
848
+ requireSafeUrl('--api', apiBase);
849
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live/${encodeURIComponent(correlationId)}`;
850
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
851
+ if (args.json) process.stdout.write(`${text || '{}'}\n`);
852
+ if (!res.ok) {
853
+ fail(`read failed: ${(json && (json.message || json.code)) || `HTTP ${res.status}`}`);
854
+ }
855
+ if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
856
+ return EXIT.OK;
857
+ }
858
+
859
+ const liveStatus = {
860
+ state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
861
+ };
862
+
863
+ if (args.message !== undefined) liveStatus.message = args.message;
864
+ if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
865
+
866
+ const progress = numberOption(args.progress, '--progress', { min: 0, max: 1 });
867
+ if (progress !== undefined) liveStatus.progress = progress;
868
+
869
+ const step = numberOption(args.step, '--step', { min: 0, max: 8, integer: true });
870
+ if (step !== undefined) liveStatus.current_step = step;
871
+
872
+ const deadlineAt = numberOption(args.deadline_at, '--deadline-at', { min: 0, integer: true });
873
+ if (deadlineAt !== undefined) liveStatus.deadline_at = deadlineAt;
874
+
875
+ const etaAt = numberOption(args.eta_at, '--eta-at', { min: 0, integer: true });
876
+ if (etaAt !== undefined) liveStatus.eta_at = etaAt;
877
+
878
+ const metrics = buildMetrics(args.metric);
879
+ if (metrics) liveStatus.metrics = metrics;
880
+
881
+ const options = buildLiveOptions(args.option);
882
+ if (options) {
883
+ if (options.length > 4) fail('--option accepts at most 4 choices', EXIT.USAGE);
884
+ liveStatus.options = options;
885
+ }
886
+
887
+ const left = buildSide(args.left, '--left');
888
+ if (left) liveStatus.left = left;
889
+ const right = buildSide(args.right, '--right');
890
+ if (right) liveStatus.right = right;
891
+ if (args.center !== undefined) liveStatus.center = args.center;
892
+
893
+ const accent = normalizeAccent(args.accent_override);
894
+ if (accent) liveStatus.accent_override = accent;
895
+
896
+ // Template, category and step labels are fixed when the stream is created;
897
+ // sending them on an update is a no-op server-side, so only `start` takes them.
898
+ if (sub === 'start') {
899
+ if (args.template) liveStatus.template = args.template;
900
+ // `alert` has no template equivalent and is the only way to start a stream
901
+ // time-sensitive (breaking through Focus) without also demanding an ack.
902
+ if (args.category) {
903
+ if (!['status', 'steps', 'alert'].includes(args.category)) {
904
+ fail('--category must be status, steps or alert', EXIT.USAGE);
905
+ }
906
+ liveStatus.category = args.category;
907
+ }
908
+ if (args.steps) {
909
+ const labels = args.steps.split(',').map((s) => s.trim()).filter(Boolean);
910
+ if (labels.length < 2 || labels.length > 8) {
911
+ fail('--steps needs between 2 and 8 comma-separated labels', EXIT.USAGE);
912
+ }
913
+ liveStatus.steps = labels;
914
+ }
915
+ } else if (args.template || args.steps || args.category) {
916
+ fail('--template, --category and --steps are fixed at stream creation; pass them to "live start"', EXIT.USAGE);
917
+ }
918
+
919
+ const body = { correlation_id: correlationId, live_status: liveStatus };
920
+ if (args.title) body.title = args.title;
921
+ if (args.action !== undefined) body.action = Number(args.action);
922
+ // Same object-shape guard ping/ask/handoff use. A bare JSON.parse also accepts
923
+ // an array, which the server then rejects — a wasted round trip for what is a
924
+ // local usage error.
925
+ // `!== undefined`, not truthiness: `-d ''` is a malformed value, and a
926
+ // truthiness test drops it on the floor and ships the ping without the data
927
+ // the caller believed they attached. ping/ask/handoff all reject it loudly.
928
+ if (args.data !== undefined) body.data = parseDataObject(args.data);
929
+ if (args.require_ack) body.requires_ack = true;
930
+ const ackTimeout = numberOption(args.ack_timeout, '--ack-timeout', { min: 1, max: 86_400, integer: true });
931
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
932
+
933
+ let result;
934
+ if (webhook) {
935
+ requireSafeUrl('--webhook', webhook);
936
+ result = await httpJson('POST', webhook, { body });
937
+ } else if (token) {
938
+ if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
939
+ requireSafeUrl('--api', apiBase);
940
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
941
+ result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
942
+ } else {
943
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
944
+ }
945
+
946
+ const { res, text, json } = result;
947
+ if (args.json) process.stdout.write(`${text || '{}'}\n`);
948
+
949
+ if (!res.ok || (json && json.success === false)) {
950
+ const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
951
+ fail(`live ${sub} failed: ${detail}`);
952
+ }
953
+
954
+ if (!args.json) {
955
+ const state = (json && (json.state || (json.live_status && json.live_status.state))) || sub;
956
+ process.stdout.write(`live ${sub} → ${state} ✅\n`);
957
+ }
958
+ return EXIT.OK;
959
+ }
960
+
423
961
  // --- questions -------------------------------------------------------------
424
962
 
963
+ // Resolve the credential + endpoint a token-only command needs. When nothing is
964
+ // available this is a usage error pointing at PINGROOM_TOKEN — never a prompt,
965
+ // so a CI job fails in a second instead of hanging on an invisible question.
425
966
  function agentContext(args, { needRoom = false } = {}) {
426
- const token = args.token || process.env.PINGROOM_TOKEN;
427
- if (!token) fail('an agent token is required (--token or PINGROOM_TOKEN)', EXIT.USAGE);
428
- const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
967
+ const token = resolveToken(args);
968
+ if (!token) {
969
+ fail(
970
+ 'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine; in CI set PINGROOM_TOKEN.',
971
+ EXIT.USAGE,
972
+ );
973
+ }
974
+ const apiBase = resolveApiBase(args);
429
975
  requireSafeUrl('--api', apiBase);
430
- if (needRoom && !args.room) fail('--room is required', EXIT.USAGE);
431
- return { token, apiBase, room: args.room };
976
+ const room = resolveRoom(args);
977
+ if (needRoom && !room) {
978
+ fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
979
+ }
980
+ return { token, apiBase, room };
432
981
  }
433
982
 
434
983
  // value:label -> {value, label}. Labels may contain colons (only the first
@@ -438,9 +987,20 @@ function buildOptions(list) {
438
987
  return list.map((spec) => {
439
988
  const idx = spec.indexOf(':');
440
989
  const value = idx === -1 ? spec : spec.slice(0, idx);
441
- const label = idx === -1 ? spec : spec.slice(idx + 1);
442
- if (!value) fail(`--option must be "value" or "value:label" (got "${spec}")`, EXIT.USAGE);
443
- return { value, label };
990
+ let label = idx === -1 ? spec : spec.slice(idx + 1);
991
+ if (!value) fail(`--option must be "value", "value:label" or "value:label:style" (got "${spec}")`, EXIT.USAGE);
992
+ // A trailing :primary|:danger|:default segment styles the button; any other
993
+ // trailing segment stays part of the label (labels may contain colons).
994
+ let style;
995
+ const lastColon = label.lastIndexOf(':');
996
+ if (lastColon !== -1) {
997
+ const candidate = label.slice(lastColon + 1);
998
+ if (candidate === 'primary' || candidate === 'danger' || candidate === 'default') {
999
+ style = candidate;
1000
+ label = label.slice(0, lastColon);
1001
+ }
1002
+ }
1003
+ return style ? { value, label, style } : { value, label };
444
1004
  });
445
1005
  }
446
1006
 
@@ -511,6 +1071,19 @@ async function ask(args) {
511
1071
  body.ttl = Number(args.ttl);
512
1072
  }
513
1073
  if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
1074
+ if (args.reply_to !== undefined) body.reply_to = args.reply_to;
1075
+ if (args.text_input !== undefined || args.text_max !== undefined) {
1076
+ const textInput = {};
1077
+ if (args.text_input) textInput.placeholder = String(args.text_input).slice(0, 60);
1078
+ if (args.text_max !== undefined) {
1079
+ const n = Number(args.text_max);
1080
+ if (!/^\d+$/.test(String(args.text_max)) || n < 1 || n > 60) {
1081
+ fail('--text-max must be an integer between 1 and 60', EXIT.USAGE);
1082
+ }
1083
+ textInput.max_length = n;
1084
+ }
1085
+ body.text_input = textInput;
1086
+ }
514
1087
  if (args.data !== undefined) body.data = parseDataObject(args.data);
515
1088
 
516
1089
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
@@ -825,7 +1398,8 @@ function parseHookArgs(argv) {
825
1398
 
826
1399
  for (let i = 0; i < argv.length; i++) {
827
1400
  const token = argv[i];
828
- const key = alias[token];
1401
+ // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1402
+ const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
829
1403
  if (key && booleans.has(key)) {
830
1404
  args[key] = true;
831
1405
  } else if (key) {
@@ -1105,9 +1679,11 @@ async function hook(args) {
1105
1679
  if (raw) { try { event = JSON.parse(raw); } catch { event = {}; } }
1106
1680
  const name = event.hook_event_name || '';
1107
1681
 
1108
- const token = args.token || process.env.PINGROOM_TOKEN;
1109
- const room = args.room || process.env.PINGROOM_ROOM;
1110
- const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
1682
+ // The hook fails open, so it reads the same layered config as everything else
1683
+ // but never complains about a missing piece — it just defers.
1684
+ const token = resolveToken(args);
1685
+ const room = resolveRoom(args);
1686
+ const apiBase = resolveApiBase(args);
1111
1687
 
1112
1688
  if (name === 'PreToolUse') {
1113
1689
  return hookPreToolUse(event, { token, room, apiBase, args });
@@ -1115,6 +1691,529 @@ async function hook(args) {
1115
1691
  return hookNotify(event, name, { token, room, apiBase, args });
1116
1692
  }
1117
1693
 
1694
+ // --- connecting (pairing + email fallback) ---------------------------------
1695
+ //
1696
+ // Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture —
1697
+ // scanning the QR is where the human picks BOTH the account and the delivery
1698
+ // room, so an agent can never end up connected with nobody's say-so about where
1699
+ // it pings. There is no `login` subcommand: `pingroom` resolves the state.
1700
+
1701
+ // The scopes this CLI can actually use, one per command surface. Requested at
1702
+ // registration so the approval screen shows exactly what it is granting; the
1703
+ // server intersects, so asking for less is always safe and asking for more than
1704
+ // the human approves is impossible.
1705
+ const CLI_SCOPES = [
1706
+ 'pingroom:rooms:read', // resolve/display the connected room
1707
+ 'pingroom:broadcast:send', // ping
1708
+ 'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
1709
+ 'pingroom:handoffs:create', // handoff / handoffs
1710
+ 'pingroom:live:write', // live start/update/end/get
1711
+ ];
1712
+
1713
+ const AGENT_LABEL = 'pingroom-cli';
1714
+
1715
+ // Widest QR we render (compact half-block form of a ~110-char pair URL is 39
1716
+ // columns). Anything narrower would wrap and become unscannable, so we print
1717
+ // the URL alone instead of a broken QR.
1718
+ const QR_MIN_COLUMNS = 41;
1719
+
1720
+ /**
1721
+ * Draw the pair URL as a scannable QR. Returns false when it could not — a too
1722
+ * narrow terminal, or the optional dependency being absent (someone vendored
1723
+ * just bin/) — and the caller falls back to the printed URL, which always works.
1724
+ */
1725
+ async function renderQr(url) {
1726
+ // A real terminal reports its width on the stream; COLUMNS covers the rest.
1727
+ // Unknown width is treated as wide enough — the URL is printed either way.
1728
+ const columns = Number(process.stdout.columns || process.env.COLUMNS || 0);
1729
+ if (columns > 0 && columns < QR_MIN_COLUMNS) return false;
1730
+
1731
+ let qr;
1732
+ try {
1733
+ const mod = await import('qrcode-terminal');
1734
+ qr = mod.default || mod;
1735
+ } catch { return false; }
1736
+ if (!qr || typeof qr.generate !== 'function') return false;
1737
+
1738
+ try {
1739
+ let art = '';
1740
+ // Call it as a method: qrcode-terminal reads its error-correction level off
1741
+ // `this`, so a detached `generate` reference silently builds a version-1
1742
+ // code and throws on anything longer than a few characters.
1743
+ // `small` is the half-block form: two module rows per text row, so the code
1744
+ // stays square-ish and fits an 80-column terminal.
1745
+ qr.generate(url, { small: true }, (rendered) => { art = rendered; });
1746
+ if (!art) return false;
1747
+ process.stdout.write(`\n${art}\n`);
1748
+ return true;
1749
+ } catch { return false; }
1750
+ }
1751
+
1752
+ /**
1753
+ * A line-at-a-time reader over stdin.
1754
+ *
1755
+ * Deliberately not node:readline: its Interface keeps consuming while we are
1756
+ * awaiting an HTTP round trip between two questions and drops the lines nobody
1757
+ * is listening for, which silently loses piped answers. This queues every line
1758
+ * instead, so the answers can arrive in one blob or one keystroke at a time.
1759
+ *
1760
+ * ask() resolves `null` — never a string — once the input is closed, so it can
1761
+ * never be confused with a real empty line. That distinction is load-bearing:
1762
+ * callers treat an empty line as "take the default", and a caller that reads EOF
1763
+ * as an empty line will take that default again on the next question, and the
1764
+ * next, forever, because nothing will ever arrive to change its mind. Callers
1765
+ * that genuinely want the empty-line behaviour opt in with `?? ''`.
1766
+ */
1767
+ function createPrompter() {
1768
+ const queued = [];
1769
+ const waiting = [];
1770
+ let buffer = '';
1771
+ let closed = false;
1772
+
1773
+ const deliver = (line) => {
1774
+ const waiter = waiting.shift();
1775
+ if (waiter) waiter(line);
1776
+ else queued.push(line);
1777
+ };
1778
+ const onData = (chunk) => {
1779
+ buffer += chunk;
1780
+ let idx;
1781
+ while ((idx = buffer.indexOf('\n')) !== -1) {
1782
+ deliver(buffer.slice(0, idx).replace(/\r$/, ''));
1783
+ buffer = buffer.slice(idx + 1);
1784
+ }
1785
+ };
1786
+ const onEnd = () => {
1787
+ if (closed) return;
1788
+ closed = true;
1789
+ if (buffer) { deliver(buffer); buffer = ''; }
1790
+ while (waiting.length) waiting.shift()(null);
1791
+ };
1792
+
1793
+ process.stdin.setEncoding('utf8');
1794
+ process.stdin.on('data', onData);
1795
+ process.stdin.once('end', onEnd);
1796
+ process.stdin.resume();
1797
+
1798
+ return {
1799
+ ask(question) {
1800
+ process.stdout.write(question);
1801
+ if (queued.length > 0) return Promise.resolve(queued.shift());
1802
+ if (closed) return Promise.resolve(null);
1803
+ return new Promise((resolve) => { waiting.push(resolve); });
1804
+ },
1805
+ close() {
1806
+ process.stdin.off('data', onData);
1807
+ process.stdin.off('end', onEnd);
1808
+ process.stdin.pause();
1809
+ },
1810
+ };
1811
+ }
1812
+
1813
+ /** POST /api/agent/auth — anonymous registration, yields the pre-claim credential. */
1814
+ async function registerAnonymous(apiBase) {
1815
+ const { res, json } = await httpJson('POST', `${apiBase}/api/agent/auth`, {
1816
+ body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
1817
+ });
1818
+ if (!res.ok || !json || typeof json.credential !== 'string') {
1819
+ const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
1820
+ fail(`could not start a connection: ${detail}`);
1821
+ }
1822
+ return json.credential;
1823
+ }
1824
+
1825
+ /** Persist the active credential plus the bits the status line prints. */
1826
+ function saveCredential({ token, handle, room, account, scopes, apiBase }) {
1827
+ writeJsonFile(credentialsPath(), {
1828
+ version: 1,
1829
+ token,
1830
+ handle: handle || null,
1831
+ room: room || null,
1832
+ account: account || null,
1833
+ scopes: scopes || [],
1834
+ api_url: apiBase,
1835
+ created_at: new Date().toISOString(),
1836
+ });
1837
+ }
1838
+
1839
+ /** "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown. */
1840
+ function connectedLine(cred) {
1841
+ const who = cred.handle ? `@${cred.handle}` : 'this machine';
1842
+ const room = cred.room && (cred.room.name || cred.room.invite_code);
1843
+ return `✓ Connected as ${who}${room ? ` → #${room}` : ''}`;
1844
+ }
1845
+
1846
+ /**
1847
+ * The QR path. Mints a pre-claim credential, asks the server for a pairing
1848
+ * token, renders it, then polls until the human approves. Returns a credential
1849
+ * object, or null when the pairing lapsed and the user declined a fresh one.
1850
+ */
1851
+ async function connectByPairing(apiBase, ask) {
1852
+ for (;;) {
1853
+ const preClaim = await registerAnonymous(apiBase);
1854
+ const headers = { Authorization: `Bearer ${preClaim}` };
1855
+
1856
+ const start = await httpJson('POST', `${apiBase}/api/agent/auth/pair/start`, {
1857
+ body: { scopes: CLI_SCOPES },
1858
+ headers,
1859
+ });
1860
+ if (!start.res.ok || !start.json || typeof start.json.pair_url !== 'string') {
1861
+ const detail = (start.json && (start.json.message || start.json.error || start.json.code))
1862
+ || `HTTP ${start.res.status}`;
1863
+ fail(`could not start pairing: ${detail}`);
1864
+ }
1865
+
1866
+ // The URL is server-controlled and goes straight to the terminal, so strip
1867
+ // C0/C1 controls: an --api / config api_url pointing at a hostile host could
1868
+ // otherwise emit ANSI escapes that repaint or hide the line the user is
1869
+ // about to trust with their account.
1870
+ const pairUrl = stripControlChars(start.json.pair_url);
1871
+ // 900s is the server's pre-claim lifetime; never poll past it, and clamp the
1872
+ // server's suggested interval so a bad value can't busy-loop or stall.
1873
+ // The 1000ms floor is not cosmetic: AGENT_PAIRING_SPEC.md throttles
1874
+ // pair/status at `60,1`, so a faster floor spends the pairing window
1875
+ // collecting 429s instead of the approval.
1876
+ const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
1877
+ const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
1878
+ const deadline = Date.now() + lifetimeMs;
1879
+
1880
+ const drew = await renderQr(pairUrl);
1881
+ process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
1882
+ process.stdout.write(' Waiting for approval… ');
1883
+
1884
+ // A transient failure must not end a wait the human is mid-way through.
1885
+ // Network errors, 5xx and 429 are the load balancer / rate limiter talking,
1886
+ // not the pairing being over; hard-failing on the first one throws away the
1887
+ // whole 15 minutes over a single blip. 401/403/404 still exit immediately —
1888
+ // those say the pre-claim is gone, and retrying can only spin.
1889
+ // The `Date.now() < deadline` bound is what keeps a *persistent* outage from
1890
+ // retrying forever: it ends at the same moment a clean poll would have.
1891
+ let transientRun = 0;
1892
+ let lastTransient = null;
1893
+ let warnedTransient = false;
1894
+
1895
+ while (Date.now() < deadline) {
1896
+ const { res, json, error } = await httpJson(
1897
+ 'GET', `${apiBase}/api/agent/auth/pair/status`, { headers, soft: true },
1898
+ );
1899
+
1900
+ if (error || res.status >= 500 || res.status === 429) {
1901
+ transientRun += 1;
1902
+ lastTransient = error
1903
+ ? error.message
1904
+ : `HTTP ${res.status}`;
1905
+ // Say something rather than sitting mute: a user watching a QR with no
1906
+ // output cannot tell a slow approval from a broken endpoint.
1907
+ if (transientRun === 3 && !warnedTransient) {
1908
+ warnedTransient = true;
1909
+ process.stdout.write(`\n (still trying — ${lastTransient}) `);
1910
+ }
1911
+ // Ride out a short blip at the normal cadence, then back off
1912
+ // geometrically so a real outage is not also a thundering herd. Never
1913
+ // sleep past the deadline this loop is bounded by.
1914
+ const backoff = Math.min(intervalMs * 2 ** Math.max(0, transientRun - 3), 30_000);
1915
+ await sleep(Math.max(0, Math.min(backoff, deadline - Date.now())));
1916
+ continue;
1917
+ }
1918
+
1919
+ transientRun = 0;
1920
+
1921
+ if (!res.ok) {
1922
+ process.stdout.write('\n');
1923
+ const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
1924
+ fail(`pairing failed: ${detail}`);
1925
+ }
1926
+ const status = json && json.status;
1927
+ if (status === 'active') {
1928
+ // A server that says "active" with no credential has not paired us.
1929
+ // Without this, `token: undefined` is written to credentials.json and
1930
+ // every later command reads a credential file that exists but cannot
1931
+ // authenticate — a far more confusing failure than stopping here.
1932
+ if (typeof json.credential !== 'string' || json.credential === '') {
1933
+ process.stdout.write('\n');
1934
+ fail('pairing succeeded but the server returned no credential');
1935
+ }
1936
+ const cred = {
1937
+ token: json.credential,
1938
+ handle: json.handle,
1939
+ room: json.room,
1940
+ account: json.account,
1941
+ scopes: json.scopes,
1942
+ apiBase,
1943
+ };
1944
+ saveCredential(cred);
1945
+ process.stdout.write(`${connectedLine(cred)}\n`);
1946
+ return cred;
1947
+ }
1948
+ if (status === 'expired') break;
1949
+ // `pending` (or anything unrecognized) — keep waiting.
1950
+ await sleep(intervalMs);
1951
+ }
1952
+
1953
+ if (transientRun > 0) {
1954
+ process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
1955
+ } else {
1956
+ process.stdout.write(`\n That code expired.\n`);
1957
+ }
1958
+
1959
+ // `null` means the input is closed, and that is the whole point of this
1960
+ // guard. Reading EOF as "" would fall through the y/yes test below (empty
1961
+ // means "take the default: yes"), restart the for(;;), mint another
1962
+ // anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
1963
+ // a single pairing attempt into thousands of registrations against the API.
1964
+ const again = await ask(' Show a fresh QR code? [Y/n]: ');
1965
+ if (again === null) { process.stdout.write('\n'); return null; }
1966
+ const answer = again.trim().toLowerCase();
1967
+ if (answer && answer !== 'y' && answer !== 'yes') return null;
1968
+ }
1969
+ }
1970
+
1971
+ /**
1972
+ * The email fallback, over the unchanged claim/* endpoints: the server mails a
1973
+ * link, the web page shows a 6-digit code, the user reads it back here.
1974
+ */
1975
+ async function connectByEmail(apiBase, ask) {
1976
+ const preClaim = await registerAnonymous(apiBase);
1977
+ const headers = { Authorization: `Bearer ${preClaim}` };
1978
+
1979
+ // `?? ''` preserves the old EOF behaviour deliberately: ask() now returns null
1980
+ // at EOF, and without the coalesce this would throw a TypeError on `.trim()`
1981
+ // instead of reaching the "this is required" error the user should see.
1982
+ const email = (await ask(' Your PingRoom email: ') ?? '').trim();
1983
+ if (!email) fail('an email address is required', EXIT.USAGE);
1984
+
1985
+ const start = await httpJson('POST', `${apiBase}/api/agent/auth/claim/start`, {
1986
+ body: { email },
1987
+ headers,
1988
+ });
1989
+ if (!start.res.ok) {
1990
+ const detail = (start.json && (start.json.message || start.json.error || start.json.code))
1991
+ || `HTTP ${start.res.status}`;
1992
+ fail(`could not send the email: ${detail}`);
1993
+ }
1994
+
1995
+ process.stdout.write(' Sent. Open the link in that email — the page shows a 6-digit code.\n');
1996
+
1997
+ // A mistyped code is the common case, so allow a few tries before giving up.
1998
+ // The server locks the registration out after its own attempt cap anyway.
1999
+ for (let attempt = 1; attempt <= 3; attempt++) {
2000
+ // Same reason as the email prompt: EOF stays an empty answer, which the
2001
+ // server rejects, rather than a TypeError on null.
2002
+ const otp = (await ask(' Code: ') ?? '').trim();
2003
+ const done = await httpJson('POST', `${apiBase}/api/agent/auth/claim/complete`, {
2004
+ body: { email, otp },
2005
+ headers,
2006
+ });
2007
+ if (done.res.ok && done.json && typeof done.json.credential === 'string') {
2008
+ const cred = {
2009
+ token: done.json.credential,
2010
+ handle: done.json.handle,
2011
+ // claim/complete carries no room — the email flow does not choose one.
2012
+ room: done.json.room,
2013
+ account: done.json.account,
2014
+ scopes: done.json.scopes,
2015
+ apiBase,
2016
+ };
2017
+ saveCredential(cred);
2018
+ process.stdout.write(`${connectedLine(cred)}\n`);
2019
+ if (!cred.room) {
2020
+ process.stdout.write(' Pick a delivery room with: pingroom config set default_room <invite code>\n');
2021
+ }
2022
+ return cred;
2023
+ }
2024
+ const detail = (done.json && (done.json.message || done.json.error || done.json.code))
2025
+ || `HTTP ${done.res.status}`;
2026
+ if (attempt === 3) fail(`could not connect: ${detail}`);
2027
+ process.stderr.write(`pingroom: ${detail}\n`);
2028
+ }
2029
+ return null;
2030
+ }
2031
+
2032
+ /**
2033
+ * Resolve the unconnected state interactively. Refuses outright when there is no
2034
+ * TTY — a hung prompt in CI is worse than a clean failure, and the fix there is
2035
+ * PINGROOM_TOKEN, not a QR nobody can scan.
2036
+ */
2037
+ async function connect(args) {
2038
+ if (!isInteractive()) {
2039
+ fail(
2040
+ 'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
2041
+ EXIT.USAGE,
2042
+ );
2043
+ }
2044
+
2045
+ const apiBase = resolveApiBase(args);
2046
+ requireSafeUrl('--api', apiBase);
2047
+
2048
+ const prompter = createPrompter();
2049
+ const ask = (question) => prompter.ask(question);
2050
+ try {
2051
+ process.stdout.write(' Not connected. How do you want to connect?\n');
2052
+ process.stdout.write(' 1) Scan a QR code with the PingRoom app\n');
2053
+ process.stdout.write(' 2) Email me a code\n');
2054
+ // EOF here means "no answer", which is what the default already covers, so
2055
+ // coalesce rather than crash on null — the pairing branch below is the one
2056
+ // that must distinguish EOF, and it does.
2057
+ const choice = (await ask(' Choose [1]: ') ?? '').trim();
2058
+ if (choice && choice !== '1' && choice !== '2') {
2059
+ process.stderr.write('pingroom: choose 1 or 2\n');
2060
+ return EXIT.USAGE;
2061
+ }
2062
+
2063
+ const cred = choice === '2'
2064
+ ? await connectByEmail(apiBase, ask)
2065
+ : await connectByPairing(apiBase, ask);
2066
+
2067
+ return cred ? EXIT.OK : EXIT.EXPIRED;
2068
+ } finally {
2069
+ prompter.close();
2070
+ }
2071
+ }
2072
+
2073
+ // --- status / bare invocation ----------------------------------------------
2074
+
2075
+ /**
2076
+ * `pingroom` with no arguments. Connected -> one status line then the usual
2077
+ * help. Not connected -> pair (interactive) or, in a pipe/CI, say so on stderr
2078
+ * and still print the help rather than prompting into the void.
2079
+ */
2080
+ async function bare(args) {
2081
+ const envToken = process.env.PINGROOM_TOKEN;
2082
+ const stored = readStoredCredential();
2083
+
2084
+ if (envToken) {
2085
+ process.stdout.write('Using the agent token from PINGROOM_TOKEN.\n');
2086
+ if (stored) process.stdout.write(`(the stored credential in ${credentialsPath()} is ignored while it is set)\n`);
2087
+ const room = resolveRoom(args);
2088
+ if (room) process.stdout.write(`Default room: ${room}\n`);
2089
+ process.stdout.write(`\n${HELP}\n`);
2090
+ return EXIT.OK;
2091
+ }
2092
+
2093
+ if (stored) {
2094
+ process.stdout.write(`${connectedLine(stored)}\n`);
2095
+ const room = resolveRoom(args);
2096
+ if (room) process.stdout.write(`Default room: ${room}\n`);
2097
+ process.stdout.write(`\n${HELP}\n`);
2098
+ return EXIT.OK;
2099
+ }
2100
+
2101
+ if (!isInteractive()) {
2102
+ process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
2103
+ process.stdout.write(`${HELP}\n`);
2104
+ return EXIT.OK;
2105
+ }
2106
+
2107
+ return connect(args);
2108
+ }
2109
+
2110
+ // --- config ----------------------------------------------------------------
2111
+
2112
+ // Only these keys are storable. An unknown key is a usage error rather than a
2113
+ // silently-ignored setting the user then blames the tool for not honouring.
2114
+ const CONFIG_KEYS = {
2115
+ default_room: {
2116
+ describe: 'Room invite code used when --room / PINGROOM_ROOM is absent',
2117
+ validate: (value) => {
2118
+ if (/\s/.test(value) || value.length > 64) return 'default_room must be an invite code (no spaces, <= 64 chars)';
2119
+ return null;
2120
+ },
2121
+ },
2122
+ api_url: {
2123
+ describe: `API base URL (default ${BUILTIN_API})`,
2124
+ validate: (value) => {
2125
+ let u;
2126
+ try { u = new URL(value); } catch { return 'api_url must be a valid URL'; }
2127
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
2128
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
2129
+ return 'api_url must use https (refusing to send credentials over cleartext)';
2130
+ }
2131
+ return null;
2132
+ },
2133
+ },
2134
+ };
2135
+
2136
+ async function config(args) {
2137
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2138
+
2139
+ const sub = args._[0];
2140
+ const known = ['list', 'get', 'set'];
2141
+ if (!sub || !known.includes(sub)) {
2142
+ fail(`config needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
2143
+ }
2144
+
2145
+ const stored = readConfigFile();
2146
+
2147
+ if (sub === 'list') {
2148
+ if (args.json) { process.stdout.write(`${JSON.stringify(stored)}\n`); return EXIT.OK; }
2149
+ const keys = Object.keys(CONFIG_KEYS).filter((k) => stored[k] !== undefined && stored[k] !== '');
2150
+ if (keys.length === 0) {
2151
+ process.stdout.write(`no settings stored in ${configPath()}\n`);
2152
+ return EXIT.OK;
2153
+ }
2154
+ for (const key of keys) process.stdout.write(`${key}=${stored[key]}\n`);
2155
+ return EXIT.OK;
2156
+ }
2157
+
2158
+ const key = args._[1];
2159
+ if (!key) fail(`config ${sub} needs a key (${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
2160
+ if (!Object.hasOwn(CONFIG_KEYS, key)) {
2161
+ fail(`unknown config key: ${key} (known keys: ${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
2162
+ }
2163
+
2164
+ if (sub === 'get') {
2165
+ const value = stored[key];
2166
+ if (value === undefined || value === '') return EXIT.OK; // unset: print nothing, exit 0
2167
+ process.stdout.write(`${value}\n`);
2168
+ return EXIT.OK;
2169
+ }
2170
+
2171
+ // set
2172
+ const raw = args._[2];
2173
+ if (raw === undefined) fail(`config set needs a value (pass "" to clear ${key})`, EXIT.USAGE);
2174
+ const value = String(raw).trim();
2175
+
2176
+ if (value === '') {
2177
+ delete stored[key];
2178
+ writeJsonFile(configPath(), stored);
2179
+ process.stdout.write(`${key} cleared\n`);
2180
+ return EXIT.OK;
2181
+ }
2182
+
2183
+ const problem = CONFIG_KEYS[key].validate(value);
2184
+ if (problem) fail(problem, EXIT.USAGE);
2185
+
2186
+ stored[key] = value;
2187
+ writeJsonFile(configPath(), stored);
2188
+ process.stdout.write(`${key}=${value}\n`);
2189
+ return EXIT.OK;
2190
+ }
2191
+
2192
+ // --- logout ----------------------------------------------------------------
2193
+
2194
+ async function logout(args) {
2195
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2196
+
2197
+ const path = credentialsPath();
2198
+ const stored = readStoredCredential();
2199
+ try {
2200
+ unlinkSync(path);
2201
+ } catch (err) {
2202
+ if (err.code === 'ENOENT') {
2203
+ process.stdout.write('not connected — there was no stored credential to clear\n');
2204
+ return EXIT.OK;
2205
+ }
2206
+ fail(`could not clear ${path}: ${err.message}`);
2207
+ }
2208
+
2209
+ const who = stored && stored.handle ? ` (@${stored.handle})` : '';
2210
+ process.stdout.write(`logged out${who} — cleared ${path}\n`);
2211
+ if (process.env.PINGROOM_TOKEN) {
2212
+ process.stdout.write('note: PINGROOM_TOKEN is still set in this environment and will keep being used\n');
2213
+ }
2214
+ return EXIT.OK;
2215
+ }
2216
+
1118
2217
  const COMMANDS = {
1119
2218
  ping: (rest) => ping(parseArgs(rest)),
1120
2219
  ask: (rest) => ask(parseQArgs(rest)),
@@ -1125,6 +2224,9 @@ const COMMANDS = {
1125
2224
  handoff: (rest) => handoff(parseHandoffArgs(rest)),
1126
2225
  handoffs: (rest) => listHandoffs(parseQArgs(rest)),
1127
2226
  hook: (rest) => hook(parseHookArgs(rest)),
2227
+ live: (rest) => live(parseLiveArgs(rest)),
2228
+ config: (rest) => config(parseQArgs(rest)),
2229
+ logout: (rest) => logout(parseQArgs(rest)),
1128
2230
  };
1129
2231
 
1130
2232
  function waitFrom(handler, rest) {
@@ -1135,11 +2237,19 @@ async function main() {
1135
2237
  const argv = process.argv.slice(2);
1136
2238
  const command = argv[0];
1137
2239
 
1138
- if (!command || command === '-h' || command === '--help' || command === 'help') {
2240
+ if (command === '-h' || command === '--help' || command === 'help') {
1139
2241
  process.stdout.write(`${HELP}\n`);
1140
2242
  process.exit(EXIT.OK);
1141
2243
  }
1142
2244
 
2245
+ // Bare `pingroom` resolves the auth state instead of only printing help:
2246
+ // connected -> status + help; not connected -> pair (interactive only).
2247
+ // A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
2248
+ // configures the connect attempt rather than naming a command.
2249
+ if (!command || command.startsWith('-')) {
2250
+ process.exit(await bare(parseQArgs(argv)));
2251
+ }
2252
+
1143
2253
  const handler = COMMANDS[command];
1144
2254
  if (!handler) {
1145
2255
  fail(`unknown command: ${command}\nRun "pingroom --help".`, EXIT.USAGE);