@pingroom/cli 0.7.0 → 0.7.3

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/bin/pingroom.js CHANGED
@@ -29,2983 +29,29 @@
29
29
  //
30
30
  // Exit codes: 0 success/answered/acked · 1 error · 2 bad usage · 3 expired ·
31
31
  // 4 cancelled/recipient-not-ready.
32
-
33
- import { randomBytes } from 'node:crypto';
34
- import {
35
- appendFileSync, chmodSync, closeSync, fchmodSync, mkdirSync, openSync,
36
- readFileSync, renameSync, unlinkSync, writeFileSync,
37
- } from 'node:fs';
38
- import { homedir } from 'node:os';
39
- import { join } from 'node:path';
40
-
41
- // Kept in lockstep with package.json / package-lock.json. The GitHub Action is
42
- // pinned independently to the latest version already published on npm; a test
43
- // makes that release gate explicit. `hook --print-config` emits this candidate.
44
- const VERSION = '0.7.0';
45
-
46
- const BUILTIN_API = 'https://api.pingroom.io';
47
- const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
48
- const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
49
-
50
- const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
51
-
52
- Usage:
53
- pingroom <command> [options]
54
-
55
- Commands:
56
- ping Send a ping to a room (webhook URL, or agent token + room)
57
- ask Ask a human a question; with --wait, block until they answer
58
- watch Block until a question resolves and print the outcome
59
- list List the agent's questions by state
60
- cancel Withdraw a pending question
61
- handoff Hand a decision (ack or question) to a specific human; with --wait,
62
- block until they acknowledge or answer
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
65
- live Drive a live progress card on the lock screen (Live Activity)
66
- hook Claude Code hook: ping on Stop/Notification, and route tool
67
- permission prompts to a PingRoom question you answer from your phone
68
- mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
69
- Claude Desktop
70
- activate Retry Agent Inbox activation with the saved QR-paired credential
71
- config Read/write local settings (config list | get <key> | set <key> <val>)
72
- logout Forget the stored credential
73
-
74
- ping options:
75
- -m, --message <text> Ping body text (required)
76
- -t, --title <text> Ping title (<= 40 chars)
77
- -a, --action <1-4> Quick-action slot to attribute the ping to
78
- -d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
79
- --url <https-url> Make the ping a tappable link (absolute http(s) URL)
80
- --button-label <t> Link button text (<= 26 chars; requires --url)
81
- --require-ack Keep the ping open until an eligible recipient acknowledges it
82
- --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
83
- --attach <path> Attach a file (md/pdf/html/txt/jpg/jpeg/png, <= 5 MiB);
84
- repeat for up to 4. Requires --token and a Pro account
85
- -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
86
- --token <token> Agent access token (or env PINGROOM_TOKEN)
87
- --room <code> Room invite code (used with --token)
88
-
89
- ask options (agent token required):
90
- -p, --prompt <text> The question a human reads (required)
91
- -o, --option <v:label[:style]>
92
- An answer option (style: primary|danger|default);
93
- repeat for 2–4. Omit for Approve/Deny
94
- -c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
95
- --scope <s> Who answers: 'direct' (default) or 'room'
96
- --target <uuid> For --scope direct: a specific room member
97
- --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
98
- --text-input <ph> Invite a short typed answer; <ph> is the placeholder
99
- --text-max <n> Max typed-answer length (1..60)
100
- --wait Block until answered/expired/cancelled
101
- --timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
102
- -d, --data <json> Structured data object echoed back on the answer
103
- --correlation-id <id> Opaque id echoed on every read of this question
104
- --reply-to <id> Id of the ping this question replies to
105
- --room <code> Room invite code (required for ask)
106
-
107
- list options:
108
- --state <s> pending | answered | expired | cancelled | all
109
-
110
- handoff options (agent token required; consent scope pingroom:handoffs:create):
111
- -m, --message <text> The prompt a human reads (required)
112
- --question Make it a question (else a simple acknowledge). Also
113
- implied whenever one or more --option is given.
114
- -o, --option <v:label> A question option; repeat for 2–4. Requires --question.
115
- --target <id> Recipient: 'me' (default) or a specific user uuid
116
- --expires-in <s> Expiry in seconds (120..86400, default 900)
117
- --urgency <u> 'active' (default) or 'passive'
118
- --idempotency-key <key> Dedupe key; retries reuse it (Idempotency-Key)
119
- --correlation-id <id> Opaque id echoed on every read of this handoff
120
- --reply-to <id> Opaque reply-to id echoed back
121
- -d, --data <json> Structured data object echoed on the handoff
122
- --wait Block until acked / answered / expired / cancelled
123
- --timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
124
- --github-output <path> Safely append handoff outputs for GitHub Actions
125
-
126
- handoffs options (agent token required; consent scope pingroom:handoffs:create):
127
- --state <s> open | all (default open)
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
-
136
- live <start|update|end|get> options (agent token, or a room webhook):
137
- -c, --correlation-id <id> The stream key — reuse it for every ping (required)
138
- --template <name> start only: status | steps | progress | metrics |
139
- countdown | decision | matchup (fixed at creation;
140
- 'decision' is the app's name for the wire id
141
- 'question', which is still accepted)
142
- --category <name> start only: status | steps | alert. Legacy, but
143
- 'alert' has no template equivalent and is the only
144
- way to start time-sensitive without --require-ack
145
- --steps <a,b,c> start only: 2-8 comma-separated step labels
146
- -m, --message <text> The card's live message line
147
- --progress <0..1> Progress bar / Dynamic Island gauge
148
- --step <n> Current step index (steps template)
149
- --metric <label:value> Repeatable, up to 3 (metrics template)
150
- --deadline-at <epoch> Countdown target (countdown template)
151
- --eta-at <epoch> Live ETA (status/progress templates)
152
- --prompt <text> The ask (decision template)
153
- --option <value:label> Repeatable, up to 4 (decision template). A bare
154
- token is both value and label
155
- --left <label:value> Left side (matchup template)
156
- --right <label:value> Right side (matchup template)
157
- --center <text> Center score/clock, <= 40 (matchup template)
158
- --accent-override <#rrggbb> Semantic accent for this frame
159
- --failed end only: finish as failed instead of done
160
- -d, --data <json> Structured data object carried on this frame
161
- -t, --title <text> Card title (<= 40 chars)
162
- -a, --action <1-4> Quick-action slot supplying the icon and sound
163
- --require-ack Add an Acknowledge button
164
- --ack-timeout <s> Ack deadline in seconds
165
- --room <code> Room invite code (used with --token)
166
- -w, --webhook <url> Room webhook URL instead of a token
167
-
168
- hook options (reads a Claude Code event; defaults to stored credentials/config):
169
- --room <code> Room invite code (or env/config/paired room)
170
- --ttl <seconds> Approval-question expiry for PreToolUse (default 900)
171
- --quiet Suppress the informational stderr lines
172
- --print-config Print a ready-to-paste ~/.claude/settings.json block
173
-
174
- mcp:
175
- pingroom mcp Print the endpoint and client setup snippets
176
- pingroom mcp add claude-code Print the Claude Code setup command
177
- (output-only; does not change client config)
178
-
179
- activate:
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)
183
-
184
- config options:
185
- pingroom config list Print the stored settings
186
- pingroom config get <key> Print one setting
187
- pingroom config set <key> <val> Store a setting (an empty value clears it)
188
- Keys: default_room, api_url
189
-
190
- Shared:
191
- --token <token> Agent access token (or env PINGROOM_TOKEN)
192
- --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
193
- --json Print the raw JSON response
194
- -h, --help Show this help
195
- -v, --version Show the CLI version
196
-
197
- Connecting:
198
- Install globally, then run with no arguments:
199
- npm install --global @pingroom/cli
200
- pingroom
201
-
202
- Or connect without installing globally:
203
- npx --yes @pingroom/cli
204
-
205
- It prints a QR code you scan with the PingRoom app — you pick the account and
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.
210
- "config set default_room" enables room-addressed commands, but private
211
- Inbox/Handoff delivery requires QR pairing.
212
- There is no "login" command: being unconnected is a state the tool resolves,
213
- not one you have to discover.
214
-
215
- The credential is written to ~/.pingroom/credentials.json (mode 0600, in a
216
- 0700 directory). PINGROOM_HOME overrides that directory. PINGROOM_TOKEN in the
217
- environment ALWAYS wins over the stored credential, so CI is unaffected.
218
- "pingroom logout" forgets it.
219
-
220
- Settings precedence, highest first:
221
- explicit flag > env var > ~/.pingroom/config.json > the paired
222
- credential > built-in default
223
- So --room beats PINGROOM_ROOM beats "config set default_room", and --api beats
224
- PINGROOM_API_URL beats "config set api_url" beats the host you paired against,
225
- beats ${BUILTIN_API}. A stored credential is bound to the origin it was paired
226
- against: an API override may change the path on that origin, but a different
227
- origin is refused before the token is sent. To target another origin
228
- intentionally, provide that host's token with --token or PINGROOM_TOKEN.
229
-
230
- Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
231
- PINGROOM_TOKEN there instead.
232
-
233
- Examples:
234
- pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
235
- pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
236
-
237
- # Link ping — a tappable button that opens a URL:
238
- pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
239
- --url https://ci.example.com/builds/512 --button-label "Open build"
240
-
241
- # Gate a deploy on a human tap — the chosen value prints to stdout:
242
- if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
243
- -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
244
-
245
- # Multi-option question, blocking:
246
- pingroom ask --token "$T" --room ab12cd --scope room --wait \\
247
- -p 'Which environment?' -o prod:Production -o staging:Staging
248
-
249
- pingroom list --token "$T" --state pending
250
- pingroom watch --token "$T" q_01H... # block on an existing question
251
- pingroom cancel --token "$T" q_01H...
252
-
253
- # Hand a deploy decision to yourself and block on the acknowledgement:
254
- pingroom handoff --token "$T" -m "Prod deploy 1.4.0 — ack to proceed" --wait
255
-
256
- # A blocking question handed to a specific human; branch in CI on exit code:
257
- pingroom handoff --token "$T" -m "Ship 1.4.0?" --question \\
258
- -o deploy:Deploy -o hold:Hold --wait
259
- # -> exit 0 (answered, any value incl. 'hold'); 3 expired; 4 recipient-not-ready
260
-
261
- pingroom handoffs --token "$T" --state all # recent history (up to 200/kind)
262
-
263
- # A live deploy card on everyone's lock screen — one stream, three calls:
264
- pingroom live start --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
265
- --template steps --steps "Build,Test,Stage,Ship" -t "Deploy 2.1.0"
266
- pingroom live update --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
267
- --step 2 -m "Smoke tests green"
268
- pingroom live end --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
269
- -m "Live on production"
270
- # ...or end it as a failure, which still delivers one completion alert:
271
- # pingroom live end ... --failed -m "Rollback triggered"
272
-
273
- # Connect Claude Code hooks to your paired credential (no env vars needed):
274
- pingroom hook --print-config
275
-
276
- # Connect an MCP client through browser OAuth (no API key needed):
277
- pingroom mcp
278
-
279
- Security:
280
- Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
281
- secrets as --webhook / --token flags: argv is visible to other users via the
282
- process table (ps) and may be captured in shell history. URLs must use https
283
- (loopback http is allowed for local dev).
284
-
285
- A paired credential is only sent to its recorded API origin. --api,
286
- PINGROOM_API_URL and config.api_url cannot redirect that stored bearer to a
287
- different origin; provide an explicit --token or PINGROOM_TOKEN to override.
288
-
289
- Exit codes: 0 on success (answered / acked), 1 on error (network/auth/5xx),
290
- 2 on bad usage, 3 when a handoff or question expired, 4 when it was cancelled
291
- or the recipient was not ready (409 recipient_not_ready). A question answered
292
- with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
293
- human decision is not an infrastructure failure.`;
294
-
295
- const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
296
-
297
- function fail(message, code = EXIT.ERROR) {
298
- process.stderr.write(`pingroom: ${message}\n`);
299
- process.exit(code);
300
- }
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
-
328
- // --- local state (~/.pingroom) ---------------------------------------------
329
- //
330
- // Two files, both under a 0700 directory:
331
- // credentials.json the agent credential this machine paired (mode 0600)
332
- // config.json user settings: default_room, api_url
333
- //
334
- // PINGROOM_HOME relocates the directory (tests, sandboxes, multi-account
335
- // shells). Every lookup is layered: explicit flag > env var > config file >
336
- // the paired credential > built-in default. PINGROOM_TOKEN is the one env var
337
- // that also outranks the stored credential, which is what keeps CI working
338
- // untouched.
339
-
340
- function pingroomHome() {
341
- return process.env.PINGROOM_HOME || join(homedir(), '.pingroom');
342
- }
343
-
344
- function credentialsPath() { return join(pingroomHome(), 'credentials.json'); }
345
- function configPath() { return join(pingroomHome(), 'config.json'); }
346
-
347
- // Read a JSON object, or null for anything unreadable/corrupt. Local state must
348
- // never be able to crash a ping: a hand-edited file degrades to "not set".
349
- function readJsonFile(path) {
350
- let raw;
351
- try { raw = readFileSync(path, 'utf8'); } catch { return null; }
352
- let value;
353
- try { value = JSON.parse(raw); } catch { return null; }
354
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
355
- return value;
356
- }
357
-
358
- // Write JSON with restrictive permissions, atomically.
359
- //
360
- // Writing in place truncates first, so a crash or a full disk between truncate
361
- // and write leaves a half-written file — and readJsonFile() degrades anything
362
- // unparseable to {}, so the *next* `config set` would silently drop every other
363
- // setting. Writing a sibling temp file and renaming over the target means a
364
- // reader only ever sees the old file or the new one, never a torn one.
365
- //
366
- // The temp file is opened 'wx' with mode 0600 and fchmod'd before a single byte
367
- // is written: `mode` on an existing file is ignored and a post-write chmod
368
- // leaves a window where the credential is world-readable. rename() carries the
369
- // 0600 over the target, so a pre-existing loose file is tightened too.
370
- //
371
- // mkdirSync(recursive) returns the first path it created, or undefined when the
372
- // directory already existed. chmod'ing only on the former keeps this from
373
- // narrowing a directory the user deliberately created at 0755.
374
- function writeJsonFile(path, value) {
375
- const dir = pingroomHome();
376
- const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
377
- let fd;
378
- try {
379
- const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
380
- if (created !== undefined) chmodSync(dir, 0o700);
381
-
382
- fd = openSync(tmp, 'wx', 0o600);
383
- fchmodSync(fd, 0o600); // defeat a permissive umask masking the open mode
384
- writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
385
- closeSync(fd);
386
- fd = undefined;
387
- renameSync(tmp, path);
388
- } catch (err) {
389
- if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
390
- try { unlinkSync(tmp); } catch { /* never created */ }
391
- fail(`could not write ${path}: ${err.message}`);
392
- }
393
- }
394
-
395
- function readStoredCredential() {
396
- const cred = readJsonFile(credentialsPath());
397
- if (!cred || typeof cred.token !== 'string' || cred.token === '') return null;
398
- return cred;
399
- }
400
-
401
- function readConfigFile() {
402
- return readJsonFile(configPath()) || {};
403
- }
404
-
405
- /** Agent token: --token > PINGROOM_TOKEN > the paired credential. */
406
- function resolveToken(args) {
407
- return args.token || process.env.PINGROOM_TOKEN || readStoredCredential()?.token || undefined;
408
- }
409
-
410
- /**
411
- * API base: --api > PINGROOM_API_URL > config.api_url > the host the credential
412
- * was paired against > built-in, no trailing slash.
413
- *
414
- * The credential layer is not optional. saveCredential() records `api_url`, and
415
- * a token minted by a self-hosted / staging server is only valid there; without
416
- * this layer the next command would present that bearer to api.pingroom.io —
417
- * leaking it to a host it was never issued for. resolveRoom() already consults
418
- * the credential last, so the two layerings now agree.
419
- *
420
- * It is also an issuer boundary when resolveToken() falls through to the stored
421
- * credential. Overrides may change the path on the same origin, but
422
- * requireStoredCredentialOrigin() refuses a different origin unless the caller
423
- * supplies an explicit --token or PINGROOM_TOKEN for that host.
424
- */
425
- function resolveApiBase(args) {
426
- const raw = args.api
427
- || process.env.PINGROOM_API_URL
428
- || readConfigFile().api_url
429
- || readStoredCredential()?.api_url
430
- || BUILTIN_API;
431
- return String(raw).replace(/\/$/, '');
432
- }
433
-
434
- /**
435
- * A paired bearer belongs to the API origin that minted it. API settings still
436
- * resolve independently so callers can select a path or an intentional custom
437
- * host, but a stored token may only follow them within its recorded origin.
438
- * Supplying --token / PINGROOM_TOKEN makes the token source explicit and opts
439
- * out of this stored-credential binding.
440
- */
441
- function storedCredentialOriginError(args, apiBase) {
442
- if (args.token || process.env.PINGROOM_TOKEN) return null;
443
-
444
- const credential = readStoredCredential();
445
- if (!credential || typeof credential.api_url !== 'string' || credential.api_url === '') return null;
446
-
447
- let credentialOrigin;
448
- let targetOrigin;
449
- try {
450
- credentialOrigin = new URL(credential.api_url).origin;
451
- targetOrigin = new URL(apiBase).origin;
452
- } catch {
453
- // URL validation owns malformed values. This guard only compares origins.
454
- return null;
455
- }
456
-
457
- if (credentialOrigin === targetOrigin) return null;
458
- return `stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}. Provide --token or PINGROOM_TOKEN for an intentional API origin override`;
459
- }
460
-
461
- function requireStoredCredentialOrigin(args, apiBase) {
462
- const error = storedCredentialOriginError(args, apiBase);
463
- if (error) fail(error, EXIT.USAGE);
464
- }
465
-
466
- /**
467
- * Room invite code: --room > PINGROOM_ROOM > config.default_room > the room the
468
- * credential was paired to. The paired room is last because it is the weakest
469
- * signal — it is where the agent was told to deliver, not necessarily where
470
- * this invocation means to.
471
- */
472
- function resolveRoom(args) {
473
- return args.room
474
- || process.env.PINGROOM_ROOM
475
- || readConfigFile().default_room
476
- || readStoredCredential()?.room?.invite_code
477
- || undefined;
478
- }
479
-
480
- /**
481
- * True when it is safe to prompt / draw a QR. Both streams must be a TTY: a
482
- * piped stdin cannot answer a prompt and a piped stdout would capture the QR as
483
- * garbage.
484
- *
485
- * The override is deliberately double-locked (internal-looking name AND
486
- * NODE_ENV=test) and not documented in --help. A single well-known env var
487
- * shipping in the published binary is one stray `export` away from making a CI
488
- * job prompt into the void and poll for the full 15-minute pairing window
489
- * instead of failing in a second.
490
- */
491
- function isInteractive() {
492
- if (process.env.PINGROOM_INTERNAL_TEST_TTY === '1' && process.env.NODE_ENV === 'test') return true;
493
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
494
- }
495
-
496
- function sleep(ms) {
497
- return new Promise((resolve) => { setTimeout(resolve, ms); });
498
- }
499
-
500
- // Drop C0/C1 control characters before echoing server-supplied text to the
501
- // terminal. Without this an attacker-controlled API base can smuggle ANSI
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
-
517
- function stripControlChars(value) {
518
- // eslint-disable-next-line no-control-regex
519
- return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
520
- }
521
-
522
- // --- ping (unchanged wire behaviour) ---------------------------------------
523
-
524
- function parseArgs(argv) {
525
- const args = { _: [] };
526
- const alias = {
527
- '-m': 'message', '--message': 'message',
528
- '-t': 'title', '--title': 'title',
529
- '-a': 'action', '--action': 'action',
530
- '-d': 'data', '--data': 'data',
531
- '-w': 'webhook', '--webhook': 'webhook',
532
- '--url': 'url',
533
- '--button-label': 'button_label',
534
- '--require-ack': 'require_ack',
535
- '--ack-timeout': 'ack_timeout',
536
- '--attach': 'attach',
537
- '--token': 'token',
538
- '--room': 'room',
539
- '--api': 'api',
540
- '--json': 'json',
541
- '-h': 'help', '--help': 'help',
542
- };
543
- const booleans = new Set(['require_ack', 'json', 'help']);
544
- const repeatable = new Set(['attach']);
545
-
546
- for (let i = 0; i < argv.length; i++) {
547
- const token = argv[i];
548
- // Object.hasOwn, not alias[token]: a bare lookup walks the prototype chain,
549
- // so `constructor` / `toString` / `__proto__` in flag position resolve to a
550
- // truthy inherited value, get treated as an option, and swallow the next
551
- // argument instead of failing as an unknown flag.
552
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
553
- if (key && booleans.has(key)) {
554
- args[key] = true;
555
- } else if (key) {
556
- const value = argv[++i];
557
- if (value === undefined) {
558
- fail(`option ${token} needs a value`, EXIT.USAGE);
559
- }
560
- if (repeatable.has(key)) (args[key] ||= []).push(value);
561
- else args[key] = value;
562
- } else if (token.startsWith('-')) {
563
- fail(`Unknown option: ${token}`, EXIT.USAGE);
564
- } else {
565
- args._.push(token);
566
- }
567
- }
568
- return args;
569
- }
570
-
571
- // Parser for the question commands: supports repeatable --option and a trailing
572
- // positional (a question id). Unknown flags fail like the ping parser.
573
- function parseQArgs(argv) {
574
- const args = { _: [] };
575
- const alias = {
576
- '-p': 'prompt', '--prompt': 'prompt',
577
- '-o': 'option', '--option': 'option',
578
- '-c': 'context', '--context': 'context',
579
- '--scope': 'scope',
580
- '--target': 'target',
581
- '--ttl': 'ttl',
582
- '-d': 'data', '--data': 'data',
583
- '--correlation-id': 'correlation_id',
584
- '--reply-to': 'reply_to',
585
- '--text-input': 'text_input',
586
- '--text-max': 'text_max',
587
- '--timeout': 'timeout',
588
- '--state': 'state',
589
- '--limit': 'limit',
590
- '--from': 'from',
591
- '--once': 'once',
592
- '--token': 'token',
593
- '--room': 'room',
594
- '--api': 'api',
595
- '--wait': 'wait',
596
- '--json': 'json',
597
- '-h': 'help', '--help': 'help',
598
- };
599
- const booleans = new Set(['wait', 'json', 'help', 'once']);
600
- const multi = new Set(['option']);
601
-
602
- for (let i = 0; i < argv.length; i++) {
603
- const token = argv[i];
604
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
605
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
606
- if (key && booleans.has(key)) {
607
- args[key] = true;
608
- } else if (key) {
609
- const value = argv[++i];
610
- if (value === undefined) {
611
- fail(`option ${token} needs a value`, EXIT.USAGE);
612
- }
613
- if (multi.has(key)) {
614
- (args[key] ||= []).push(value);
615
- } else {
616
- args[key] = value;
617
- }
618
- } else if (token.startsWith('-') && token !== '-') {
619
- fail(`Unknown option: ${token}`, EXIT.USAGE);
620
- } else {
621
- args._.push(token);
622
- }
623
- }
624
- return args;
625
- }
626
-
627
- // Parser for `handoff`: --message plus repeatable --option, boolean --question,
628
- // and the handoff-specific flags. Unknown flags fail like the other parsers.
629
- function parseHandoffArgs(argv) {
630
- const args = { _: [] };
631
- const alias = {
632
- '-m': 'message', '--message': 'message',
633
- '--question': 'question',
634
- '-o': 'option', '--option': 'option',
635
- '--target': 'target',
636
- '--expires-in': 'expires_in',
637
- '--urgency': 'urgency',
638
- '--idempotency-key': 'idempotency_key',
639
- '--correlation-id': 'correlation_id',
640
- '--reply-to': 'reply_to',
641
- '-d': 'data', '--data': 'data',
642
- '--timeout': 'timeout',
643
- '--github-output': 'github_output',
644
- '--token': 'token',
645
- '--api': 'api',
646
- '--wait': 'wait',
647
- '--json': 'json',
648
- '-h': 'help', '--help': 'help',
649
- };
650
- const booleans = new Set(['question', 'wait', 'json', 'help']);
651
- const multi = new Set(['option']);
652
-
653
- for (let i = 0; i < argv.length; i++) {
654
- const token = argv[i];
655
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
656
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
657
- if (key && booleans.has(key)) {
658
- args[key] = true;
659
- } else if (key) {
660
- const value = argv[++i];
661
- if (value === undefined) {
662
- fail(`option ${token} needs a value`, EXIT.USAGE);
663
- }
664
- if (multi.has(key)) {
665
- (args[key] ||= []).push(value);
666
- } else {
667
- args[key] = value;
668
- }
669
- } else if (token.startsWith('-') && token !== '-') {
670
- fail(`Unknown option: ${token}`, EXIT.USAGE);
671
- } else {
672
- args._.push(token);
673
- }
674
- }
675
- return args;
676
- }
677
-
678
- // True when a URL is safe to attach a bearer token or webhook secret to: https,
679
- // or http on loopback so local dev against http://localhost still works.
680
- // Split out of requireSafeUrl for the `hook` command, which must apply the same
681
- // rule but fails open (it defers instead of exiting — see hook()).
682
- function isSafeUrl(raw) {
683
- let u;
684
- try {
685
- u = new URL(raw);
686
- } catch {
687
- return false;
688
- }
689
- const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
690
- return u.protocol === 'https:' || (u.protocol === 'http:' && isLoopback);
691
- }
692
-
693
- // Refuse to send a bearer token or webhook secret over cleartext http. A
694
- // loopback host is allowed so local dev against http://localhost still works.
695
- function requireSafeUrl(kind, raw) {
696
- try {
697
- new URL(raw);
698
- } catch {
699
- fail(`${kind} is not a valid URL`, EXIT.USAGE);
700
- }
701
- if (!isSafeUrl(raw)) {
702
- fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
703
- }
704
- return raw;
705
- }
706
-
707
- function parseDataObject(raw) {
708
- let data;
709
- try {
710
- data = JSON.parse(raw);
711
- } catch {
712
- fail('--data must be valid JSON', EXIT.USAGE);
713
- }
714
- if (typeof data !== 'object' || Array.isArray(data) || data === null) {
715
- fail('--data must be a JSON object', EXIT.USAGE);
716
- }
717
- return data;
718
- }
719
-
720
- // `soft: true` returns { error } instead of exiting on a transport failure. The
721
- // bounded pairing and activation loops use it so a single DNS blip or dropped
722
- // connection does not discard an otherwise recoverable human workflow. Every
723
- // other caller keeps the hard exit.
724
- async function httpJson(method, url, { body, headers = {}, soft = false, signal } = {}) {
725
- let res;
726
- try {
727
- res = await fetch(url, {
728
- method,
729
- headers: {
730
- Accept: 'application/json',
731
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
732
- ...headers,
733
- },
734
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
735
- ...(signal ? { signal } : {}),
736
- });
737
- } catch (err) {
738
- if (soft) return { res: null, text: '', json: null, error: err };
739
- fail(`network error: ${err.message}`);
740
- }
741
-
742
- let text;
743
- try {
744
- text = await res.text();
745
- } catch (err) {
746
- // A connection dropped mid-body throws here, not at fetch().
747
- if (soft) return { res: null, text: '', json: null, error: err };
748
- fail(`network error: ${err.message}`);
749
- }
750
- let json = null;
751
- try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
752
-
753
- return { res, text, json };
754
- }
755
-
756
- // The extensions the attachment endpoint accepts. Mirrored here so a typo is a
757
- // local usage error instead of a 422 after the bytes have already been sent.
758
- // Keep in lockstep with laravel config/attachments.php `allowed_extensions`.
759
- const ATTACHMENT_EXTENSIONS = ['md', 'pdf', 'html', 'txt', 'jpg', 'jpeg', 'png'];
760
- const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
761
- const ATTACHMENT_MAX_COUNT = 4;
762
- const ATTACHMENT_MIME = {
763
- md: 'text/markdown',
764
- pdf: 'application/pdf',
765
- html: 'text/html',
766
- txt: 'text/plain',
767
- jpg: 'image/jpeg',
768
- jpeg: 'image/jpeg',
769
- png: 'image/png',
770
- };
771
-
772
- /**
773
- * Upload each --attach path and return the ids in flag order. Bytes go up as
774
- * multipart; only the resulting ids ride the ping body. An id we never manage
775
- * to attach expires server-side after 24h, so a mid-run failure leaks nothing
776
- * permanent.
777
- */
778
- async function uploadAttachments(paths, apiBase, token) {
779
- if (paths.length > ATTACHMENT_MAX_COUNT) {
780
- fail(`--attach accepts at most ${ATTACHMENT_MAX_COUNT} files`, EXIT.USAGE);
781
- }
782
-
783
- const { readFile, stat } = await import('node:fs/promises');
784
- const { basename, extname } = await import('node:path');
785
- const ids = [];
786
-
787
- for (const path of paths) {
788
- const name = basename(path);
789
- const ext = extname(name).slice(1).toLowerCase();
790
- if (!ATTACHMENT_EXTENSIONS.includes(ext)) {
791
- fail(`--attach ${name}: only ${ATTACHMENT_EXTENSIONS.join(', ')} files are supported`, EXIT.USAGE);
792
- }
793
-
794
- let info;
795
- try {
796
- info = await stat(path);
797
- } catch {
798
- fail(`--attach ${path}: file not found`, EXIT.USAGE);
799
- }
800
- if (!info.isFile()) fail(`--attach ${path}: not a file`, EXIT.USAGE);
801
- if (info.size < 1) fail(`--attach ${name}: file is empty`, EXIT.USAGE);
802
- if (info.size > ATTACHMENT_MAX_BYTES) {
803
- fail(`--attach ${name}: file exceeds the 5 MiB limit`, EXIT.USAGE);
804
- }
805
-
806
- const body = new FormData();
807
- body.append('file', new Blob([await readFile(path)], { type: ATTACHMENT_MIME[ext] }), name);
808
-
809
- let res;
810
- try {
811
- // Not httpJson: that helper JSON-encodes the body and would strip the
812
- // multipart boundary the runtime generates for us.
813
- res = await fetch(`${apiBase}/api/agent/attachments`, {
814
- method: 'POST',
815
- headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
816
- body,
817
- });
818
- } catch (err) {
819
- fail(`network error uploading ${name}: ${err.message}`);
820
- }
821
-
822
- const text = await res.text().catch(() => '');
823
- let json = null;
824
- try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
825
-
826
- if (res.status === 402) {
827
- fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
828
- }
829
- if (!res.ok || !json?.attachment?.id) {
830
- const detail = apiDetail(res, json);
831
- fail(`upload failed for ${name}: ${detail}`);
832
- }
833
-
834
- ids.push(json.attachment.id);
835
- }
836
-
837
- return ids;
838
- }
839
-
840
- async function ping(args) {
841
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
842
-
843
- const message = args.message;
844
- if (!message) fail('a --message is required', EXIT.USAGE);
845
- requireMaxLength(message, 500, '--message');
846
- requireMaxLength(args.title, 40, '--title');
847
-
848
- if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
849
- fail('--action must be an integer 1–4', EXIT.USAGE);
850
- }
851
-
852
- let ackTimeout;
853
- if (args.ack_timeout !== undefined) {
854
- if (!args.require_ack) {
855
- fail('--ack-timeout requires --require-ack', EXIT.USAGE);
856
- }
857
- if (!/^\d+$/.test(String(args.ack_timeout))) {
858
- fail('--ack-timeout must be an integer number of seconds', EXIT.USAGE);
859
- }
860
- ackTimeout = Number(args.ack_timeout);
861
- }
862
-
863
- let data;
864
- if (args.data !== undefined) {
865
- data = parseDataObject(args.data);
866
- }
867
-
868
- // Link ping: --url/--button-label fold into the structured data object
869
- // (server contract: data.url = absolute http(s) <= 2048, data.button_label <= 26).
870
- if (args.button_label !== undefined && args.url === undefined) {
871
- fail('--button-label requires --url', EXIT.USAGE);
872
- }
873
- if (args.url !== undefined) {
874
- let linkUrl;
875
- try {
876
- linkUrl = new URL(args.url);
877
- } catch {
878
- fail('--url is not a valid URL', EXIT.USAGE);
879
- }
880
- if (linkUrl.protocol !== 'https:' && linkUrl.protocol !== 'http:') {
881
- fail('--url must be an absolute http(s) URL', EXIT.USAGE);
882
- }
883
- if (args.url.length > 2048) {
884
- fail('--url must be at most 2048 characters', EXIT.USAGE);
885
- }
886
- if (args.button_label !== undefined && args.button_label.length > 26) {
887
- fail('--button-label must be at most 26 characters', EXIT.USAGE);
888
- }
889
- data = { ...(data || {}), url: args.url };
890
- if (args.button_label !== undefined) data.button_label = args.button_label;
891
- }
892
-
893
- const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
894
- const token = resolveToken(args);
895
- const apiBase = resolveApiBase(args);
896
- const room = resolveRoom(args);
897
-
898
- let result;
899
-
900
- // Attachments exist only on the agent-token path: an incoming webhook has no
901
- // uploader identity to bind private files to, so the API takes no ids there.
902
- const attachPaths = args.attach ?? [];
903
- if (attachPaths.length && (webhook || !token)) {
904
- fail('--attach requires an agent token (--token / PINGROOM_TOKEN), not a webhook ping', EXIT.USAGE);
905
- }
906
-
907
- if (webhook) {
908
- if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
909
- fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
910
- }
911
- requireSafeUrl('--webhook', webhook);
912
- const body = { message };
913
- if (args.title) body.title = args.title;
914
- if (args.action !== undefined) body.action = Number(args.action);
915
- if (data) body.data = data;
916
- if (args.require_ack) body.requires_ack = true;
917
- if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
918
- result = await httpJson('POST', webhook, { body });
919
- } else if (token) {
920
- requireStoredCredentialOrigin(args, apiBase);
921
- if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
922
- if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
923
- fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
924
- }
925
- requireSafeUrl('--api', apiBase);
926
- const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`;
927
- const body = { message };
928
- if (args.title) body.title = args.title;
929
- if (args.action !== undefined) body.action_number = Number(args.action);
930
- if (data) body.data = data;
931
- if (args.require_ack) body.requires_ack = true;
932
- if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
933
- if (attachPaths.length) {
934
- body.attachment_ids = await uploadAttachments(attachPaths, apiBase, token);
935
- }
936
- result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
937
- } else {
938
- fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
939
- }
940
-
941
- const { res, text, json } = result;
942
-
943
- if (args.json) {
944
- process.stdout.write(`${text || '{}'}\n`);
945
- }
946
-
947
- const ok = res.ok && !(json && json.success === false);
948
-
949
- if (!ok) {
950
- const detail = apiDetail(res, json);
951
- fail(`delivery failed: ${detail}`);
952
- }
953
-
954
- if (!args.json) process.stdout.write('ping sent ✅\n');
955
- return EXIT.OK;
956
- }
957
-
958
- // --- live status -----------------------------------------------------------
959
-
960
- // The templates the server accepts on `live start`. Mirrored here so a typo is
961
- // a local usage error instead of a 422 from the API. Keep in lockstep with the
962
- // --template line in HELP and with LIVE_ACTIVITY_TEMPLATES.md.
963
- const LIVE_TEMPLATES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'question', 'matchup'];
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
-
985
- // Parser for `live`: a leading subcommand (start|update|end|get) plus the
986
- // live-status flags. Unknown flags fail like the other parsers.
987
- function parseLiveArgs(argv) {
988
- const args = { _: [] };
989
- const alias = {
990
- '-c': 'correlation_id', '--correlation-id': 'correlation_id',
991
- '-t': 'title', '--title': 'title',
992
- '-m': 'message', '--message': 'message',
993
- '--template': 'template',
994
- '--category': 'category',
995
- '--progress': 'progress',
996
- '--step': 'step',
997
- '--steps': 'steps',
998
- '--metric': 'metric',
999
- '--deadline-at': 'deadline_at',
1000
- '--eta-at': 'eta_at',
1001
- '--prompt': 'prompt',
1002
- '--option': 'option',
1003
- '--left': 'left',
1004
- '--right': 'right',
1005
- '--center': 'center',
1006
- '--accent-override': 'accent_override',
1007
- '--failed': 'failed',
1008
- '-a': 'action', '--action': 'action',
1009
- '-d': 'data', '--data': 'data',
1010
- '--require-ack': 'require_ack',
1011
- '--ack-timeout': 'ack_timeout',
1012
- '-w': 'webhook', '--webhook': 'webhook',
1013
- '--token': 'token',
1014
- '--room': 'room',
1015
- '--api': 'api',
1016
- '--json': 'json',
1017
- '-h': 'help', '--help': 'help',
1018
- };
1019
- const booleans = new Set(['require_ack', 'json', 'help', 'failed']);
1020
- const repeatable = new Set(['metric', 'option']);
1021
-
1022
- for (let i = 0; i < argv.length; i++) {
1023
- const token = argv[i];
1024
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1025
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
1026
- if (key && booleans.has(key)) {
1027
- args[key] = true;
1028
- } else if (key) {
1029
- const value = argv[++i];
1030
- if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
1031
- if (repeatable.has(key)) (args[key] ||= []).push(value);
1032
- else args[key] = value;
1033
- } else if (token.startsWith('-')) {
1034
- fail(`Unknown option: ${token}`, EXIT.USAGE);
1035
- } else {
1036
- args._.push(token);
1037
- }
1038
- }
1039
- return args;
1040
- }
1041
-
1042
- // "label:value" -> {label, value}. Only the first colon splits.
1043
- function buildMetrics(list) {
1044
- if (!list || list.length === 0) return undefined;
1045
- return list.map((spec) => {
1046
- const idx = spec.indexOf(':');
1047
- if (idx <= 0) fail(`--metric must be "label:value" (got "${spec}")`, EXIT.USAGE);
1048
- return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
1049
- });
1050
- }
1051
-
1052
- // "value:label" -> {value, label}; a bare token is both. Matches the `ask`
1053
- // command's option syntax minus `style`, which live_status options don't carry.
1054
- function buildLiveOptions(list) {
1055
- if (!list || list.length === 0) return undefined;
1056
- return list.map((spec) => {
1057
- const idx = spec.indexOf(':');
1058
- if (idx < 0) return { value: spec, label: spec };
1059
- if (idx === 0) fail(`--option needs a value before the colon (got "${spec}")`, EXIT.USAGE);
1060
- return { value: spec.slice(0, idx), label: spec.slice(idx + 1) };
1061
- });
1062
- }
1063
-
1064
- // "label:value" -> {label, value}, for --left / --right on the matchup template.
1065
- function buildSide(spec, flag) {
1066
- if (spec === undefined) return undefined;
1067
- const idx = spec.indexOf(':');
1068
- if (idx <= 0) fail(`${flag} must be "label:value" (got "${spec}")`, EXIT.USAGE);
1069
- return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
1070
- }
1071
-
1072
- // The server accepts #rrggbb with or without the leading #; normalize to one
1073
- // form so a shell that ate the # (unquoted) still produces a valid payload.
1074
- function normalizeAccent(raw) {
1075
- if (raw === undefined) return undefined;
1076
- const hex = raw.trim().replace(/^#/, '');
1077
- if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
1078
- fail(`--accent-override must be a 6-digit hex color (got "${raw}")`, EXIT.USAGE);
1079
- }
1080
- return `#${hex.toLowerCase()}`;
1081
- }
1082
-
1083
- function numberOption(raw, flag, { min, max, integer = false } = {}) {
1084
- if (raw === undefined) return undefined;
1085
- const value = Number(raw);
1086
- if (!Number.isFinite(value)) fail(`${flag} must be a number`, EXIT.USAGE);
1087
- if (integer && !Number.isInteger(value)) fail(`${flag} must be an integer`, EXIT.USAGE);
1088
- if (min !== undefined && value < min) fail(`${flag} must be at least ${min}`, EXIT.USAGE);
1089
- if (max !== undefined && value > max) fail(`${flag} must be at most ${max}`, EXIT.USAGE);
1090
- return value;
1091
- }
1092
-
1093
- /**
1094
- * Drive a live progress card on the room members' lock screen.
1095
- *
1096
- * One correlation id = one stream: `start` opens it (one alert), `update` moves
1097
- * it silently, `end` closes it with one completion alert. Works with either an
1098
- * agent token (--token, needs pingroom:live:write) or a room's incoming webhook
1099
- * (--webhook), which speak the same `live_status` contract.
1100
- */
1101
- async function live(args) {
1102
- const sub = args._[0];
1103
- const known = ['start', 'update', 'end', 'get'];
1104
- if (!sub || !known.includes(sub)) {
1105
- fail(`live needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
1106
- }
1107
-
1108
- const correlationId = args.correlation_id;
1109
- if (!correlationId) fail('--correlation-id is required', EXIT.USAGE);
1110
-
1111
- const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
1112
- const token = resolveToken(args);
1113
- const apiBase = resolveApiBase(args);
1114
- const room = resolveRoom(args);
1115
-
1116
- if (sub === 'get') {
1117
- if (!token) fail('live get requires an agent token (--token or PINGROOM_TOKEN)', EXIT.USAGE);
1118
- requireStoredCredentialOrigin(args, apiBase);
1119
- if (!room) fail('--room is required', EXIT.USAGE);
1120
- requireSafeUrl('--api', apiBase);
1121
- const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live/${encodeURIComponent(correlationId)}`;
1122
- const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1123
- if (args.json) process.stdout.write(`${text || '{}'}\n`);
1124
- if (!res.ok) {
1125
- fail(`read failed: ${apiDetail(res, json)}`);
1126
- }
1127
- if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
1128
- return EXIT.OK;
1129
- }
1130
-
1131
- const liveStatus = {
1132
- state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
1133
- };
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');
1140
- if (args.message !== undefined) liveStatus.message = args.message;
1141
- if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
1142
-
1143
- const progress = numberOption(args.progress, '--progress', { min: 0, max: 1 });
1144
- if (progress !== undefined) liveStatus.progress = progress;
1145
-
1146
- const step = numberOption(args.step, '--step', { min: 0, max: 8, integer: true });
1147
- if (step !== undefined) liveStatus.current_step = step;
1148
-
1149
- const deadlineAt = numberOption(args.deadline_at, '--deadline-at', { min: 0, integer: true });
1150
- if (deadlineAt !== undefined) liveStatus.deadline_at = deadlineAt;
1151
-
1152
- const etaAt = numberOption(args.eta_at, '--eta-at', { min: 0, integer: true });
1153
- if (etaAt !== undefined) liveStatus.eta_at = etaAt;
1154
-
1155
- const metrics = buildMetrics(args.metric);
1156
- if (metrics) liveStatus.metrics = metrics;
1157
-
1158
- const options = buildLiveOptions(args.option);
1159
- if (options) {
1160
- if (options.length > 4) fail('--option accepts at most 4 choices', EXIT.USAGE);
1161
- liveStatus.options = options;
1162
- }
1163
-
1164
- const left = buildSide(args.left, '--left');
1165
- if (left) liveStatus.left = left;
1166
- const right = buildSide(args.right, '--right');
1167
- if (right) liveStatus.right = right;
1168
- if (args.center !== undefined) liveStatus.center = args.center;
1169
-
1170
- const accent = normalizeAccent(args.accent_override);
1171
- if (accent) liveStatus.accent_override = accent;
1172
-
1173
- // Template, category and step labels are fixed when the stream is created;
1174
- // sending them on an update is a no-op server-side, so only `start` takes them.
1175
- if (sub === 'start') {
1176
- // Validated locally for the same reason --category is: a typo'd name is a
1177
- // usage error, and letting it reach the server turns it into a 422 round
1178
- // trip that reads like an outage.
1179
- if (args.template) {
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);
1183
- }
1184
- liveStatus.template = template;
1185
- }
1186
- // `alert` has no template equivalent and is the only way to start a stream
1187
- // time-sensitive (breaking through Focus) without also demanding an ack.
1188
- if (args.category) {
1189
- if (!['status', 'steps', 'alert'].includes(args.category)) {
1190
- fail('--category must be status, steps or alert', EXIT.USAGE);
1191
- }
1192
- liveStatus.category = args.category;
1193
- }
1194
- if (args.steps) {
1195
- const labels = args.steps.split(',').map((s) => s.trim()).filter(Boolean);
1196
- if (labels.length < 2 || labels.length > 8) {
1197
- fail('--steps needs between 2 and 8 comma-separated labels', EXIT.USAGE);
1198
- }
1199
- liveStatus.steps = labels;
1200
- }
1201
- } else if (args.template || args.steps || args.category) {
1202
- fail('--template, --category and --steps are fixed at stream creation; pass them to "live start"', EXIT.USAGE);
1203
- }
1204
-
1205
- const body = { correlation_id: correlationId, live_status: liveStatus };
1206
- if (args.title) body.title = args.title;
1207
- if (args.action !== undefined) body.action = Number(args.action);
1208
- // Same object-shape guard ping/ask/handoff use. A bare JSON.parse also accepts
1209
- // an array, which the server then rejects — a wasted round trip for what is a
1210
- // local usage error.
1211
- // `!== undefined`, not truthiness: `-d ''` is a malformed value, and a
1212
- // truthiness test drops it on the floor and ships the ping without the data
1213
- // the caller believed they attached. ping/ask/handoff all reject it loudly.
1214
- if (args.data !== undefined) body.data = parseDataObject(args.data);
1215
- if (args.require_ack) body.requires_ack = true;
1216
- const ackTimeout = numberOption(args.ack_timeout, '--ack-timeout', { min: 1, max: 86_400, integer: true });
1217
- if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
1218
-
1219
- let result;
1220
- if (webhook) {
1221
- requireSafeUrl('--webhook', webhook);
1222
- result = await httpJson('POST', webhook, { body });
1223
- } else if (token) {
1224
- requireStoredCredentialOrigin(args, apiBase);
1225
- if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
1226
- requireSafeUrl('--api', apiBase);
1227
- const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
1228
- result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
1229
- } else {
1230
- fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
1231
- }
1232
-
1233
- const { res, text, json } = result;
1234
- if (args.json) process.stdout.write(`${text || '{}'}\n`);
1235
-
1236
- if (!res.ok || (json && json.success === false)) {
1237
- const detail = apiDetail(res, json);
1238
- fail(`live ${sub} failed: ${detail}`);
1239
- }
1240
-
1241
- if (!args.json) {
1242
- const state = (json && (json.state || (json.live_status && json.live_status.state))) || sub;
1243
- process.stdout.write(`live ${sub} → ${state} ✅\n`);
1244
- }
1245
- return EXIT.OK;
1246
- }
1247
-
1248
- // --- questions -------------------------------------------------------------
1249
-
1250
- // Resolve the credential + endpoint a token-only command needs. When nothing is
1251
- // available this is a usage error pointing at PINGROOM_TOKEN — never a prompt,
1252
- // so a CI job fails in a second instead of hanging on an invisible question.
1253
- function agentContext(args, { needRoom = false } = {}) {
1254
- const token = resolveToken(args);
1255
- if (!token) {
1256
- fail(
1257
- 'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine; in CI set PINGROOM_TOKEN.',
1258
- EXIT.USAGE,
1259
- );
1260
- }
1261
- const apiBase = resolveApiBase(args);
1262
- requireStoredCredentialOrigin(args, apiBase);
1263
- requireSafeUrl('--api', apiBase);
1264
- const room = resolveRoom(args);
1265
- if (needRoom && !room) {
1266
- fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
1267
- }
1268
- return { token, apiBase, room };
1269
- }
1270
-
1271
- // value:label -> {value, label}. Labels may contain colons (only the first
1272
- // splits). A bare token is both value and label. Omit all for Approve/Deny.
1273
- function buildOptions(list) {
1274
- if (!list || list.length === 0) return undefined;
1275
- return list.map((spec) => {
1276
- const idx = spec.indexOf(':');
1277
- const value = idx === -1 ? spec : spec.slice(0, idx);
1278
- let label = idx === -1 ? spec : spec.slice(idx + 1);
1279
- if (!value) fail(`--option must be "value", "value:label" or "value:label:style" (got "${spec}")`, EXIT.USAGE);
1280
- // A trailing :primary|:danger|:default segment styles the button; any other
1281
- // trailing segment stays part of the label (labels may contain colons).
1282
- let style;
1283
- const lastColon = label.lastIndexOf(':');
1284
- if (lastColon !== -1) {
1285
- const candidate = label.slice(lastColon + 1);
1286
- if (candidate === 'primary' || candidate === 'danger' || candidate === 'default') {
1287
- style = candidate;
1288
- label = label.slice(0, lastColon);
1289
- }
1290
- }
1291
- return style ? { value, label, style } : { value, label };
1292
- });
1293
- }
1294
-
1295
- function exitForState(state) {
1296
- switch (state) {
1297
- case 'answered': return EXIT.OK;
1298
- case 'expired': return EXIT.EXPIRED;
1299
- case 'cancelled': return EXIT.CANCELLED;
1300
- default: return EXIT.ERROR;
1301
- }
1302
- }
1303
-
1304
- // Print the outcome. On `answered`, the chosen value (or typed text) goes to
1305
- // stdout so `$(pingroom ask --wait ...)` captures it; other outcomes report to
1306
- // stderr and leave stdout empty.
1307
- function printResolution(q) {
1308
- if (q.state === 'answered') {
1309
- const out = q.answer && (q.answer.text || q.answer.value) || '';
1310
- process.stdout.write(`${out}\n`);
1311
- } else {
1312
- process.stderr.write(`pingroom: question ${q.state}\n`);
1313
- }
1314
- }
1315
-
1316
- // Long-poll the wait endpoint until the question leaves `pending`, then print
1317
- // and return the state's exit code. The server expires it at its ttl, so this
1318
- // always terminates.
1319
- async function waitForResolution(id, args, { token, apiBase }) {
1320
- let hold = args.timeout !== undefined ? Number(args.timeout) : 25;
1321
- if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
1322
- hold = Math.min(hold, 30);
1323
-
1324
- for (;;) {
1325
- const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1326
- const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1327
- if (!res.ok) {
1328
- const detail = apiDetail(res, json);
1329
- fail(`wait failed: ${detail}`);
1330
- }
1331
- if (json && json.state && json.state !== 'pending') {
1332
- if (args.json) process.stdout.write(`${text}\n`);
1333
- else printResolution(json);
1334
- return exitForState(json.state);
1335
- }
1336
- // Still pending at the hold timeout — poll again.
1337
- }
1338
- }
1339
-
1340
- async function ask(args) {
1341
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1342
-
1343
- const prompt = args.prompt;
1344
- if (!prompt) fail('a --prompt is required', EXIT.USAGE);
1345
- requireMaxLength(prompt, 500, '--prompt');
1346
- requireMaxLength(args.context, 40, '--context');
1347
-
1348
- const { token, apiBase, room } = agentContext(args, { needRoom: true });
1349
-
1350
- const body = { prompt };
1351
- const options = buildOptions(args.option);
1352
- if (options) body.options = options;
1353
- if (args.context) body.context = args.context;
1354
- if (args.scope !== undefined) {
1355
- if (args.scope !== 'direct' && args.scope !== 'room') fail("--scope must be 'direct' or 'room'", EXIT.USAGE);
1356
- body.responder_scope = args.scope;
1357
- }
1358
- if (args.target !== undefined) body.target_user_id = args.target;
1359
- if (args.ttl !== undefined) {
1360
- if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
1361
- body.ttl = Number(args.ttl);
1362
- }
1363
- if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
1364
- if (args.reply_to !== undefined) body.reply_to = args.reply_to;
1365
- if (args.text_input !== undefined || args.text_max !== undefined) {
1366
- const textInput = {};
1367
- if (args.text_input) textInput.placeholder = String(args.text_input).slice(0, 60);
1368
- if (args.text_max !== undefined) {
1369
- const n = Number(args.text_max);
1370
- if (!/^\d+$/.test(String(args.text_max)) || n < 1 || n > 60) {
1371
- fail('--text-max must be an integer between 1 and 60', EXIT.USAGE);
1372
- }
1373
- textInput.max_length = n;
1374
- }
1375
- body.text_input = textInput;
1376
- }
1377
- if (args.data !== undefined) body.data = parseDataObject(args.data);
1378
-
1379
- const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
1380
- const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
1381
- if (!res.ok) {
1382
- const detail = apiDetail(res, json);
1383
- fail(`ask failed: ${detail}`);
1384
- }
1385
-
1386
- if (!args.wait) {
1387
- if (args.json) process.stdout.write(`${text}\n`);
1388
- else process.stdout.write(`${json.id}\n`);
1389
- return EXIT.OK;
1390
- }
1391
-
1392
- return waitForResolution(json.id, args, { token, apiBase });
1393
- }
1394
-
1395
- async function watch(args) {
1396
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1397
- const id = args._[0];
1398
- if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
1399
- const { token, apiBase } = agentContext(args);
1400
- return waitForResolution(id, args, { token, apiBase });
1401
- }
1402
-
1403
- async function cancel(args) {
1404
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1405
- const id = args._[0];
1406
- if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
1407
- const { token, apiBase } = agentContext(args);
1408
- const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
1409
- const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
1410
- if (!res.ok) {
1411
- const detail = apiDetail(res, json);
1412
- fail(`cancel failed: ${detail}`);
1413
- }
1414
- if (args.json) process.stdout.write(`${text}\n`);
1415
- else process.stdout.write(`cancelled (${json && json.state})\n`);
1416
- return EXIT.OK;
1417
- }
1418
-
1419
- async function list(args) {
1420
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1421
- const { token, apiBase } = agentContext(args);
1422
- const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
1423
- const url = `${apiBase}/api/agent/questions${qs}`;
1424
- const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1425
- if (!res.ok) {
1426
- const detail = apiDetail(res, json);
1427
- fail(`list failed: ${detail}`);
1428
- }
1429
- if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
1430
-
1431
- const questions = (json && json.questions) || [];
1432
- if (questions.length === 0) { process.stdout.write('no questions\n'); return EXIT.OK; }
1433
- for (const q of questions) {
1434
- const answer = q.answer && q.answer.value ? ` → ${q.answer.value}` : '';
1435
- process.stdout.write(`${q.id} ${String(q.state).padEnd(9)} ${q.prompt}${answer}\n`);
1436
- }
1437
- return EXIT.OK;
1438
- }
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
-
1532
- async function listHandoffs(args) {
1533
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1534
- const { token, apiBase } = agentContext(args);
1535
- const state = args.state || 'open';
1536
- if (state !== 'open' && state !== 'all') {
1537
- fail("--state must be 'open' or 'all' for handoffs", EXIT.USAGE);
1538
- }
1539
-
1540
- const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
1541
- const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1542
- if (!res.ok) {
1543
- const detail = apiDetail(res, json);
1544
- fail(`handoffs list failed: ${detail}`);
1545
- }
1546
- if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
1547
-
1548
- const handoffs = (json && json.handoffs) || [];
1549
- if (handoffs.length === 0) { process.stdout.write('no handoffs\n'); return EXIT.OK; }
1550
- for (const h of handoffs) {
1551
- const answer = h.answer && (h.answer.value ?? h.answer.text);
1552
- const outcome = answer !== undefined && answer !== null ? ` → ${answer}` : '';
1553
- process.stdout.write(
1554
- `${h.id} ${String(h.kind || '').padEnd(8)} ${String(h.state || '').padEnd(9)} ${h.prompt || ''}${outcome}\n`,
1555
- );
1556
- }
1557
- return EXIT.OK;
1558
- }
1559
-
1560
- // --- handoff ---------------------------------------------------------------
1561
-
1562
- // Terminal wire states across both kinds. ack: open→acked|expired.
1563
- // question: pending→answered|expired|cancelled. `open`/`pending` are the only
1564
- // non-terminal states, so a wait loop against these always terminates.
1565
- const HANDOFF_PENDING = new Set(['open', 'pending']);
1566
-
1567
- // Map a terminal handoff state to an exit code. A `question` answered with ANY
1568
- // value is a success (0) — a negative human decision ('hold'/'deny') is NOT an
1569
- // infra failure. `acked` is likewise 0. `expired` is a distinct 3 so CI can
1570
- // branch; `cancelled` shares 4 with recipient_not_ready.
1571
- function exitForHandoffState(state) {
1572
- switch (state) {
1573
- case 'acked': return EXIT.OK;
1574
- case 'answered': return EXIT.OK;
1575
- case 'expired': return EXIT.EXPIRED;
1576
- case 'cancelled': return EXIT.CANCELLED;
1577
- default: return EXIT.ERROR;
1578
- }
1579
- }
1580
-
1581
- // Print a machine-readable summary of a handoff: id, state, delivery-state, and
1582
- // the answer value / acked-by when present, one `key=value` per line to stdout.
1583
- function printHandoff(h) {
1584
- const lines = [`id=${h.id ?? ''}`, `state=${h.state ?? ''}`];
1585
- if (h.delivery_state != null) lines.push(`delivery-state=${h.delivery_state}`);
1586
- if (h.correlation_id) lines.push(`correlation-id=${h.correlation_id}`);
1587
- if (h.state === 'answered') {
1588
- const value = h.answer && (h.answer.value ?? h.answer.text) || '';
1589
- lines.push(`answer=${value}`);
1590
- }
1591
- if (h.state === 'acked') {
1592
- // The Handoff API returns a privacy-aware actor object. Only expose its id
1593
- // in the machine-readable CLI/GitHub Action output; a redacted actor yields
1594
- // an empty value instead of the unhelpful "[object Object]" string.
1595
- const ackerId = h.acked_by && typeof h.acked_by === 'object'
1596
- ? h.acked_by.id
1597
- : h.acked_by;
1598
- lines.push(`acked-by=${ackerId ?? ''}`);
1599
- if (h.acked_at) lines.push(`acked-at=${h.acked_at}`);
1600
- }
1601
- process.stdout.write(`${lines.join('\n')}\n`);
1602
- }
1603
-
1604
- /**
1605
- * Append the composite Action's declared outputs without interpreting stdout.
1606
- * Values use GitHub's multiline protocol with a fresh random delimiter. Output
1607
- * names are a fixed allowlist; untrusted answer text can never create a key.
1608
- */
1609
- function writeGitHubHandoffOutputs(path, h) {
1610
- if (typeof path !== 'string' || path.length === 0) {
1611
- fail('--github-output must be a non-empty path', EXIT.USAGE);
1612
- }
1613
-
1614
- const ackerId = h.acked_by && typeof h.acked_by === 'object'
1615
- ? h.acked_by.id
1616
- : h.acked_by;
1617
- const fields = [
1618
- ['handoff-id', h.id ?? ''],
1619
- ['state', h.state ?? ''],
1620
- ];
1621
- if (h.delivery_state != null) fields.push(['delivery-state', h.delivery_state]);
1622
- if (h.state === 'answered') {
1623
- fields.push(['answer', h.answer && (h.answer.value ?? h.answer.text) || '']);
1624
- }
1625
- if (h.state === 'acked') fields.push(['acknowledged-by', ackerId ?? '']);
1626
-
1627
- const blocks = fields.map(([name, rawValue]) => {
1628
- const value = String(rawValue ?? '');
1629
- let delimiter;
1630
- do {
1631
- delimiter = `pingroom_${randomBytes(24).toString('hex')}`;
1632
- } while (value.includes(delimiter));
1633
- // Keep the collision check next to serialization: a delimiter must never
1634
- // occur in an untrusted value, even though a 192-bit collision is remote.
1635
- if (value.includes(delimiter)) {
1636
- fail('could not create a safe GitHub output delimiter');
1637
- }
1638
- return `${name}<<${delimiter}\n${value}\n${delimiter}\n`;
1639
- });
1640
-
1641
- try {
1642
- appendFileSync(path, blocks.join(''), { encoding: 'utf8' });
1643
- } catch {
1644
- fail('could not write GitHub outputs');
1645
- }
1646
- }
1647
-
1648
- // Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
1649
- // print it and return the state's exit code. Reuses the shared bounded hold.
1650
- async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
1651
- let hold = args.timeout !== undefined ? Number(args.timeout) : 20;
1652
- if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
1653
- hold = Math.min(hold, 25);
1654
-
1655
- for (;;) {
1656
- const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
1657
- const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
1658
- if (!res.ok) {
1659
- const detail = apiDetail(res, json);
1660
- fail(`wait failed: ${detail}`);
1661
- }
1662
- if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
1663
- // Read/wait responses intentionally carry delivery_state=null. Preserve
1664
- // the create response's durable delivery result so --wait callers and
1665
- // the GitHub Action do not lose it at the terminal read boundary.
1666
- const resolved = json.delivery_state == null && initialDeliveryState != null
1667
- ? { ...json, delivery_state: initialDeliveryState }
1668
- : json;
1669
- if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, resolved);
1670
- if (args.json) process.stdout.write(`${text}\n`);
1671
- else printHandoff(resolved);
1672
- return exitForHandoffState(resolved.state);
1673
- }
1674
- // Still open/pending at the hold timeout — poll again.
1675
- }
1676
- }
1677
-
1678
- async function handoff(args) {
1679
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1680
-
1681
- const message = args.message;
1682
- if (!message) fail('a --message is required', EXIT.USAGE);
1683
- requireMaxLength(message, 500, '--message');
1684
-
1685
- const { token, apiBase } = agentContext(args);
1686
-
1687
- const options = buildOptions(args.option);
1688
- // Any --option (or an explicit --question) makes this a question handoff.
1689
- const isQuestion = Boolean(args.question) || Boolean(options);
1690
- if (isQuestion && (!options || options.length < 2)) {
1691
- fail('a question handoff needs at least 2 --option values', EXIT.USAGE);
1692
- }
1693
- if (isQuestion && options && options.length > 4) {
1694
- fail('a question handoff accepts at most 4 --option values', EXIT.USAGE);
1695
- }
1696
- if (!isQuestion && options) {
1697
- fail('--option requires --question', EXIT.USAGE);
1698
- }
1699
-
1700
- const body = { kind: isQuestion ? 'question' : 'ack', prompt: message };
1701
-
1702
- const target = args.target || 'me';
1703
- body.audience = { type: 'direct', user_id: target };
1704
-
1705
- if (options) body.options = options;
1706
-
1707
- if (args.expires_in !== undefined) {
1708
- if (!/^\d+$/.test(String(args.expires_in))) fail('--expires-in must be an integer number of seconds', EXIT.USAGE);
1709
- const secs = Number(args.expires_in);
1710
- if (secs < 120 || secs > 86_400) fail('--expires-in must be between 120 and 86400 seconds', EXIT.USAGE);
1711
- body.expires_in = secs;
1712
- }
1713
- if (args.urgency !== undefined) {
1714
- if (args.urgency !== 'active' && args.urgency !== 'passive') fail("--urgency must be 'active' or 'passive'", EXIT.USAGE);
1715
- body.urgency = args.urgency;
1716
- }
1717
- if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
1718
- if (args.reply_to !== undefined) body.reply_to = args.reply_to;
1719
- if (args.data !== undefined) body.data = parseDataObject(args.data);
1720
-
1721
- const headers = { Authorization: `Bearer ${token}` };
1722
- // A stable Idempotency-Key lets network retries collapse to one resource; the
1723
- // server returns the same handoff for a matching key+hash (409 on conflict).
1724
- if (args.idempotency_key !== undefined) {
1725
- if (!args.idempotency_key) fail('--idempotency-key must be non-empty', EXIT.USAGE);
1726
- headers['Idempotency-Key'] = args.idempotency_key;
1727
- }
1728
-
1729
- const url = `${apiBase}/api/agent/handoffs`;
1730
- const { res, text, json } = await httpJson('POST', url, { body, headers });
1731
- if (!res.ok) {
1732
- const code = json && json.code;
1733
- const detail = apiDetail(res, json);
1734
- // A recipient who isn't reachable yet is a distinct, retriable outcome (4),
1735
- // not a generic error — CI may want to wait and retry rather than fail hard.
1736
- if (res.status === 409 && code === 'recipient_not_ready') {
1737
- if (args.json) process.stdout.write(`${text}\n`);
1738
- else process.stderr.write(`pingroom: recipient not ready\n`);
1739
- return EXIT.CANCELLED;
1740
- }
1741
- fail(`handoff failed: ${detail}`);
1742
- }
1743
-
1744
- if (!args.wait) {
1745
- if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, json);
1746
- if (args.json) process.stdout.write(`${text}\n`);
1747
- else printHandoff(json);
1748
- return EXIT.OK;
1749
- }
1750
-
1751
- return waitForHandoff(json.id, args, { token, apiBase }, json.delivery_state);
1752
- }
1753
-
1754
- // --- hook (Claude Code integration) ----------------------------------------
1755
- //
1756
- // A single command wired into several Claude Code hook events. It reads the
1757
- // hook's JSON payload on stdin and switches on `hook_event_name`:
1758
- // Stop / SubagentStop / SessionEnd -> ping the room ("Claude finished")
1759
- // Notification -> ping the room (idle / needs-input)
1760
- // PreToolUse -> ask a PingRoom question and gate the
1761
- // tool call on the phone's Approve/Deny.
1762
- //
1763
- // Safety: the hook FAILS OPEN. It never blocks the agent and never
1764
- // auto-approves. Any missing config / network error / non-answer defers to the
1765
- // normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
1766
- // must not call fail() (a non-zero exit — 2 especially — would break the run).
1767
-
1768
- function parseHookArgs(argv) {
1769
- const args = { _: [] };
1770
- const alias = {
1771
- '--room': 'room',
1772
- '--ttl': 'ttl',
1773
- '--quiet': 'quiet',
1774
- '--print-config': 'print_config',
1775
- '--token': 'token',
1776
- '--api': 'api',
1777
- '--json': 'json',
1778
- '-h': 'help', '--help': 'help',
1779
- };
1780
- const booleans = new Set(['quiet', 'print_config', 'json', 'help']);
1781
-
1782
- for (let i = 0; i < argv.length; i++) {
1783
- const token = argv[i];
1784
- // hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
1785
- const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
1786
- if (key && booleans.has(key)) {
1787
- args[key] = true;
1788
- } else if (key) {
1789
- const value = argv[++i];
1790
- if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
1791
- args[key] = value;
1792
- } else if (token.startsWith('-') && token !== '-') {
1793
- fail(`Unknown option: ${token}`, EXIT.USAGE);
1794
- } else {
1795
- args._.push(token);
1796
- }
1797
- }
1798
- return args;
1799
- }
1800
-
1801
- // Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
1802
- // stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
1803
- function readStdin() {
1804
- return new Promise((resolve) => {
1805
- if (process.stdin.isTTY) { resolve(''); return; }
1806
- let data = '';
1807
- process.stdin.setEncoding('utf8');
1808
- process.stdin.on('data', (chunk) => { data += chunk; });
1809
- process.stdin.on('end', () => resolve(data));
1810
- process.stdin.on('error', () => resolve(data));
1811
- });
1812
- }
1813
-
1814
- function truncate(value, max) {
1815
- const str = String(value ?? '');
1816
- return str.length <= max ? str : `${str.slice(0, max - 1)}…`;
1817
- }
1818
-
1819
- // A minimal HTTP helper for the hook path that THROWS instead of calling fail(),
1820
- // so every failure funnels into a fail-open decision. Mirrors httpJson's header
1821
- // handling but leaves control flow to the caller.
1822
- async function hookFetch(method, url, { body, token } = {}) {
1823
- const res = await fetch(url, {
1824
- method,
1825
- headers: {
1826
- Accept: 'application/json',
1827
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
1828
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
1829
- },
1830
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1831
- });
1832
- const text = await res.text();
1833
- let json = null;
1834
- try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
1835
- if (!res.ok) {
1836
- throw new Error(apiDetail(res, json));
1837
- }
1838
- return json;
1839
- }
1840
-
1841
- // Pull the readable text out of a Claude transcript message's content, which is
1842
- // either a plain string or an array of typed blocks.
1843
- function extractAssistantText(content) {
1844
- if (typeof content === 'string') return content;
1845
- if (Array.isArray(content)) {
1846
- return content
1847
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
1848
- .map((b) => b.text)
1849
- .join(' ');
1850
- }
1851
- return '';
1852
- }
1853
-
1854
- // Tail a Claude Code transcript (JSONL) and return the last assistant message as
1855
- // a single truncated line. Best-effort: any read/parse failure yields ''.
1856
- function summarizeTranscript(path) {
1857
- if (!path || typeof path !== 'string') return '';
1858
- let content;
1859
- try { content = readFileSync(path, 'utf8'); } catch { return ''; }
1860
- const lines = content.split('\n');
1861
- for (let i = lines.length - 1; i >= 0; i--) {
1862
- const line = lines[i].trim();
1863
- if (!line) continue;
1864
- let entry;
1865
- try { entry = JSON.parse(line); } catch { continue; }
1866
- const msg = entry && entry.message;
1867
- if (!msg || msg.role !== 'assistant') continue;
1868
- const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
1869
- if (text) return truncate(text, 500);
1870
- }
1871
- return '';
1872
- }
1873
-
1874
- // A short, single-line description of the tool call for the question prompt.
1875
- // Never emits more than a truncated line, and strips whitespace/newlines so an
1876
- // untrusted command can't reshape the message.
1877
- function summarizeToolInput(input) {
1878
- if (!input || typeof input !== 'object') return '';
1879
- let raw = '';
1880
- if (typeof input.command === 'string') raw = input.command; // Bash
1881
- else if (typeof input.file_path === 'string') raw = input.file_path; // Read/Write/Edit
1882
- else if (typeof input.path === 'string') raw = input.path;
1883
- else if (typeof input.url === 'string') raw = input.url; // WebFetch
1884
- else if (typeof input.pattern === 'string') raw = input.pattern; // Grep/Glob
1885
- else { try { raw = JSON.stringify(input); } catch { raw = ''; } }
1886
- return truncate(String(raw).replace(/\s+/g, ' ').trim(), 160);
1887
- }
1888
-
1889
- function emitPreToolUseDecision(decision, reason) {
1890
- process.stdout.write(`${JSON.stringify({
1891
- hookSpecificOutput: {
1892
- hookEventName: 'PreToolUse',
1893
- permissionDecision: decision,
1894
- permissionDecisionReason: reason,
1895
- },
1896
- })}\n`);
1897
- }
1898
-
1899
- // Long-poll the wait endpoint until the question leaves `pending`. The server
1900
- // expires it at its ttl, so this always terminates; a mid-poll throw propagates
1901
- // to the caller's fail-open handler.
1902
- async function hookWaitForAnswer(id, { token, apiBase }) {
1903
- for (;;) {
1904
- const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=25`;
1905
- const json = await hookFetch('GET', url, { token });
1906
- if (json && json.state && json.state !== 'pending') return json;
1907
- }
1908
- }
1909
-
1910
- async function hookPreToolUse(event, { token, room, apiBase, args }) {
1911
- if (!token || !room) {
1912
- emitPreToolUseDecision('ask', 'PingRoom not configured (pair by QR, or configure both a token and room)');
1913
- return EXIT.OK;
1914
- }
1915
-
1916
- const toolName = event.tool_name || 'a tool';
1917
- const summary = summarizeToolInput(event.tool_input);
1918
- const prompt = truncate(`Run ${toolName}${summary ? `: ${summary}` : ''}?`, 500);
1919
-
1920
- let ttl = 900;
1921
- if (args.ttl !== undefined && /^\d+$/.test(String(args.ttl))) ttl = Number(args.ttl);
1922
-
1923
- let questionId;
1924
- let cancelled = false;
1925
- const cancelQuestion = async () => {
1926
- if (!questionId || cancelled) return;
1927
- cancelled = true;
1928
- try {
1929
- await hookFetch('POST', `${apiBase}/api/agent/questions/${encodeURIComponent(questionId)}/cancel`, { body: {}, token });
1930
- } catch { /* best-effort — a leftover question expires on its own ttl */ }
1931
- };
1932
- // If the agent aborts the tool call, withdraw the question so it doesn't linger
1933
- // on the phone. Exit 0 so the abort itself isn't reported as a hook failure.
1934
- const onSignal = () => { cancelQuestion().finally(() => process.exit(EXIT.OK)); };
1935
- process.on('SIGINT', onSignal);
1936
- process.on('SIGTERM', onSignal);
1937
-
1938
- try {
1939
- const data = { tool_name: String(toolName) };
1940
- if (event.cwd) data.cwd = String(event.cwd);
1941
- const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
1942
- token,
1943
- body: {
1944
- prompt,
1945
- context: 'Claude Code',
1946
- options: [
1947
- { value: 'allow', label: 'Approve', style: 'primary' },
1948
- { value: 'deny', label: 'Deny', style: 'danger' },
1949
- ],
1950
- ttl,
1951
- data,
1952
- ...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
1953
- },
1954
- });
1955
- questionId = created && created.id;
1956
- if (!questionId) {
1957
- emitPreToolUseDecision('ask', 'PingRoom did not return a question — deferring to local prompt');
1958
- return EXIT.OK;
1959
- }
1960
-
1961
- const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
1962
- if (resolved.state === 'answered') {
1963
- const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
1964
- if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
1965
- if (value === 'deny') { emitPreToolUseDecision('deny', 'Denied via PingRoom'); return EXIT.OK; }
1966
- emitPreToolUseDecision('ask', `PingRoom answer "${value}" — deferring to local prompt`);
1967
- return EXIT.OK;
1968
- }
1969
- emitPreToolUseDecision('ask', `PingRoom question ${resolved.state} — deferring to local prompt`);
1970
- return EXIT.OK;
1971
- } catch (err) {
1972
- emitPreToolUseDecision('ask', `PingRoom unavailable (${err.message}) — deferring to local prompt`);
1973
- return EXIT.OK;
1974
- } finally {
1975
- process.removeListener('SIGINT', onSignal);
1976
- process.removeListener('SIGTERM', onSignal);
1977
- }
1978
- }
1979
-
1980
- async function hookNotify(event, name, { token, room, apiBase, args }) {
1981
- if (!token || !room) {
1982
- if (!args.quiet) process.stderr.write('pingroom: hook skipped (pair by QR, or configure both a token and room)\n');
1983
- return EXIT.OK;
1984
- }
1985
-
1986
- let title;
1987
- let message;
1988
- if (name === 'Stop' || name === 'SubagentStop') {
1989
- title = 'Claude finished';
1990
- message = summarizeTranscript(event.transcript_path) || 'Session finished — waiting for you.';
1991
- } else if (name === 'Notification') {
1992
- message = truncate(event.message || 'Claude is waiting for your input.', 500);
1993
- // A PreToolUse hook already turns permission prompts into a question; skip
1994
- // the duplicate "needs your permission" Notification so you aren't paged twice.
1995
- if (/permission/i.test(message)) return EXIT.OK;
1996
- title = 'Claude needs you';
1997
- } else if (name === 'SessionEnd') {
1998
- if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
1999
- title = 'Session ended';
2000
- message = `Claude Code session ended (${event.reason || 'unknown'}).`;
2001
- } else {
2002
- return EXIT.OK; // unknown event — stay silent rather than send noise
2003
- }
2004
-
2005
- const data = { event: name };
2006
- if (event.session_id) data.session_id = String(event.session_id);
2007
- if (event.cwd) data.cwd = String(event.cwd);
2008
-
2009
- try {
2010
- await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
2011
- token,
2012
- body: {
2013
- message,
2014
- title,
2015
- data,
2016
- ...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
2017
- },
2018
- });
2019
- if (!args.quiet) process.stderr.write('pingroom: pinged ✅\n');
2020
- } catch (err) {
2021
- // A broken ping must never break the agent — report to stderr and exit 0.
2022
- if (!args.quiet) process.stderr.write(`pingroom: hook ping failed (${err.message})\n`);
2023
- }
2024
- return EXIT.OK;
2025
- }
2026
-
2027
- function printHookConfig() {
2028
- const command = `npx --yes @pingroom/cli@${VERSION} hook`;
2029
- const config = {
2030
- hooks: {
2031
- Stop: [{ hooks: [{ type: 'command', command }] }],
2032
- Notification: [{ hooks: [{ type: 'command', command }] }],
2033
- PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command, timeout: 960 }] }],
2034
- },
2035
- };
2036
- process.stdout.write(
2037
- `# PingRoom × Claude Code — merge this into ~/.claude/settings.json
2038
- #
2039
- # 1. Connect once and choose a delivery room when you scan the QR:
2040
- # npm install --global @pingroom/cli && pingroom
2041
- # Or, without a global install:
2042
- # npx --yes @pingroom/cli@${VERSION}
2043
- # The hook reads that stored credential and paired room automatically; you do
2044
- # not need to export PINGROOM_TOKEN or PINGROOM_ROOM for a local setup.
2045
- #
2046
- # 2. Merge the "hooks" block below into ~/.claude/settings.json.
2047
- # Stop / Notification -> ping your phone.
2048
- # PreToolUse (Bash) -> ask a question you Approve/Deny from the lock
2049
- # screen before the command runs. Add or change the
2050
- # matcher to gate other tools.
2051
- #
2052
- # If PingRoom is unreachable the hook defers to the normal local prompt — it
2053
- # never auto-approves and never blocks the agent.
2054
- # PINGROOM_TOKEN / PINGROOM_ROOM remain supported for CI and headless shells.
2055
-
2056
- ${JSON.stringify(config, null, 2)}
2057
- `);
2058
- }
2059
-
2060
- async function hook(args) {
2061
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2062
- if (args.print_config) { printHookConfig(); return EXIT.OK; }
2063
-
2064
- let event = {};
2065
- const raw = await readStdin();
2066
- if (raw) { try { event = JSON.parse(raw); } catch { event = {}; } }
2067
- const name = event.hook_event_name || '';
2068
-
2069
- // The hook fails open, so it reads the same layered config as everything else
2070
- // but never complains about a missing piece — it just defers.
2071
- const token = resolveToken(args);
2072
- const room = resolveRoom(args);
2073
- const apiBase = resolveApiBase(args);
2074
-
2075
- const originError = storedCredentialOriginError(args, apiBase);
2076
- if (originError) {
2077
- if (name === 'PreToolUse') {
2078
- emitPreToolUseDecision('ask', `${originError}; deferring to local prompt`);
2079
- } else if (!args.quiet) {
2080
- process.stderr.write(`pingroom: hook skipped (${originError})\n`);
2081
- }
2082
- return EXIT.OK;
2083
- }
2084
-
2085
- // Every other command that attaches a bearer gates its base through
2086
- // requireSafeUrl first; the hook was the one that didn't, so a config or env
2087
- // pointing at plain http shipped `Authorization: Bearer …` in the clear with
2088
- // nothing on screen. Same rule here — but enforced by deferring, not by
2089
- // exiting: the hook's whole contract is that it never blocks the agent, so a
2090
- // hard failure would trade a credential leak for a broken session.
2091
- if (!isSafeUrl(apiBase)) {
2092
- const why = `${apiBase} is not https — refusing to send credentials over cleartext`;
2093
- if (name === 'PreToolUse') {
2094
- emitPreToolUseDecision('ask', `PingRoom API base ${why}; deferring to local prompt`);
2095
- } else if (!args.quiet) {
2096
- process.stderr.write(`pingroom: hook skipped (API base ${why})\n`);
2097
- }
2098
- return EXIT.OK;
2099
- }
2100
-
2101
- if (name === 'PreToolUse') {
2102
- return hookPreToolUse(event, { token, room, apiBase, args });
2103
- }
2104
- return hookNotify(event, name, { token, room, apiBase, args });
2105
- }
2106
-
2107
- // --- MCP client setup ------------------------------------------------------
2108
-
2109
- function mcp(rest) {
2110
- const claudeCommand = `claude mcp add --transport http pingroom ${MCP_ENDPOINT}`;
2111
-
2112
- if (rest.length === 0 || (rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help'))) {
2113
- const config = {
2114
- mcpServers: {
2115
- pingroom: { url: MCP_ENDPOINT },
2116
- },
2117
- };
2118
- process.stdout.write(
2119
- `PingRoom MCP endpoint:
2120
- ${MCP_ENDPOINT}
2121
-
2122
- Claude Code:
2123
- ${claudeCommand}
2124
-
2125
- Cursor JSON (~/.cursor/mcp.json):
2126
- ${JSON.stringify(config, null, 2)}
2127
-
2128
- Claude Desktop:
2129
- Customize > Connectors > Add custom connector
2130
- Name: PingRoom
2131
- URL: ${MCP_ENDPOINT}
2132
-
2133
- After adding the server, use your client's MCP controls to authenticate in the
2134
- browser. No API key is needed.
2135
- This command only prints setup instructions and does not modify client config.
2136
- `);
2137
- return EXIT.OK;
2138
- }
2139
-
2140
- if (rest.length === 2 && rest[0] === 'add' && rest[1] === 'claude-code') {
2141
- process.stdout.write(
2142
- `No client configuration was changed. Copy and run:
2143
- ${claudeCommand}
2144
- `);
2145
- return EXIT.OK;
2146
- }
2147
-
2148
- fail('usage: pingroom mcp [add claude-code]', EXIT.USAGE);
2149
- }
2150
-
2151
- // --- connecting (pairing + email fallback) ---------------------------------
2152
32
  //
2153
- // Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture
2154
- // scanning the QR is where the human picks BOTH the account and the delivery
2155
- // room, so an agent can never end up connected with nobody's say-so about where
2156
- // it pings. There is no `login` subcommand: `pingroom` resolves the state.
2157
-
2158
- // The scopes this CLI can actually use, one per command surface. Requested at
2159
- // registration so the approval screen shows exactly what it is granting; the
2160
- // server intersects, so asking for less is always safe and asking for more than
2161
- // the human approves is impossible.
2162
- const CLI_SCOPES = [
2163
- 'pingroom:rooms:read', // resolve/display the connected room
2164
- 'pingroom:broadcast:send', // ping
2165
- 'pingroom:attachments:write', // ping --attach (the upload leg)
2166
- 'pingroom:notifications:read',// listen
2167
- 'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
2168
- 'pingroom:handoffs:create', // handoff / handoffs
2169
- 'pingroom:live:write', // live start/update/end/get
2170
- ];
2171
-
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';
2175
- // A connect command should prove the phone round-trip, but it must not hold a
2176
- // terminal for the onboarding Question's full 24-hour server TTL. The Question
2177
- // remains answerable after this local deadline and the credential is already
2178
- // durable before the wait begins.
2179
- const ACTIVATION_MAX_WAIT_MS = 2 * 60 * 1000;
2180
- // The wait route is limited to 30 requests/minute. Keep immediate pending or
2181
- // answered-without-completion observations safely below that ceiling while a
2182
- // mixed-version or commit-propagation race is still being reconciled.
2183
- const ACTIVATION_MIN_POLL_INTERVAL_MS = 2100;
2184
-
2185
- function activationMaxWaitMs() {
2186
- // Keep production fixed at two minutes. The guarded override lets the real
2187
- // subprocess tests exercise deadline behavior without holding the suite for
2188
- // two minutes; it is ignored outside NODE_ENV=test.
2189
- if (process.env.NODE_ENV === 'test') {
2190
- const testValue = Number(process.env.PINGROOM_INTERNAL_ACTIVATION_TIMEOUT_MS);
2191
- if (Number.isInteger(testValue) && testValue > 0 && testValue <= ACTIVATION_MAX_WAIT_MS) {
2192
- return testValue;
2193
- }
2194
- }
2195
- return ACTIVATION_MAX_WAIT_MS;
2196
- }
2197
-
2198
- // Widest QR we render (compact half-block form of a ~110-char pair URL is 39
2199
- // columns). Anything narrower would wrap and become unscannable, so we print
2200
- // the URL alone instead of a broken QR.
2201
- const QR_MIN_COLUMNS = 41;
2202
-
2203
- /**
2204
- * Draw the pair URL as a scannable QR. Returns false when it could not — a too
2205
- * narrow terminal, or the optional dependency being absent (someone vendored
2206
- * just bin/) — and the caller falls back to the printed URL, which always works.
2207
- */
2208
- async function renderQr(url) {
2209
- // A real terminal reports its width on the stream; COLUMNS covers the rest.
2210
- // Unknown width is treated as wide enough — the URL is printed either way.
2211
- const columns = Number(process.stdout.columns || process.env.COLUMNS || 0);
2212
- if (columns > 0 && columns < QR_MIN_COLUMNS) return false;
2213
-
2214
- let qr;
2215
- try {
2216
- const mod = await import('qrcode-terminal');
2217
- qr = mod.default || mod;
2218
- } catch { return false; }
2219
- if (!qr || typeof qr.generate !== 'function') return false;
2220
-
2221
- try {
2222
- let art = '';
2223
- // Call it as a method: qrcode-terminal reads its error-correction level off
2224
- // `this`, so a detached `generate` reference silently builds a version-1
2225
- // code and throws on anything longer than a few characters.
2226
- // `small` is the half-block form: two module rows per text row, so the code
2227
- // stays square-ish and fits an 80-column terminal.
2228
- qr.generate(url, { small: true }, (rendered) => { art = rendered; });
2229
- if (!art) return false;
2230
- process.stdout.write(`\n${art}\n`);
2231
- return true;
2232
- } catch { return false; }
2233
- }
2234
-
2235
- /**
2236
- * A line-at-a-time reader over stdin.
2237
- *
2238
- * Deliberately not node:readline: its Interface keeps consuming while we are
2239
- * awaiting an HTTP round trip between two questions and drops the lines nobody
2240
- * is listening for, which silently loses piped answers. This queues every line
2241
- * instead, so the answers can arrive in one blob or one keystroke at a time.
2242
- *
2243
- * ask() resolves `null` — never a string — once the input is closed, so it can
2244
- * never be confused with a real empty line. That distinction is load-bearing:
2245
- * callers treat an empty line as "take the default", and a caller that reads EOF
2246
- * as an empty line will take that default again on the next question, and the
2247
- * next, forever, because nothing will ever arrive to change its mind. Callers
2248
- * that genuinely want the empty-line behaviour opt in with `?? ''`.
2249
- */
2250
- function createPrompter() {
2251
- const queued = [];
2252
- const waiting = [];
2253
- let buffer = '';
2254
- let closed = false;
2255
-
2256
- const deliver = (line) => {
2257
- const waiter = waiting.shift();
2258
- if (waiter) waiter(line);
2259
- else queued.push(line);
2260
- };
2261
- const onData = (chunk) => {
2262
- buffer += chunk;
2263
- let idx;
2264
- while ((idx = buffer.indexOf('\n')) !== -1) {
2265
- deliver(buffer.slice(0, idx).replace(/\r$/, ''));
2266
- buffer = buffer.slice(idx + 1);
2267
- }
2268
- };
2269
- const onEnd = () => {
2270
- if (closed) return;
2271
- closed = true;
2272
- if (buffer) { deliver(buffer); buffer = ''; }
2273
- while (waiting.length) waiting.shift()(null);
2274
- };
2275
-
2276
- process.stdin.setEncoding('utf8');
2277
- process.stdin.on('data', onData);
2278
- process.stdin.once('end', onEnd);
2279
- process.stdin.resume();
2280
-
2281
- return {
2282
- ask(question) {
2283
- process.stdout.write(question);
2284
- if (queued.length > 0) return Promise.resolve(queued.shift());
2285
- if (closed) return Promise.resolve(null);
2286
- return new Promise((resolve) => { waiting.push(resolve); });
2287
- },
2288
- close() {
2289
- process.stdin.off('data', onData);
2290
- process.stdin.off('end', onEnd);
2291
- process.stdin.pause();
2292
- },
2293
- };
2294
- }
2295
-
2296
- /** POST /api/agent/auth — anonymous registration, yields the pre-claim credential. */
2297
- async function registerAnonymous(apiBase) {
2298
- const { res, json } = await httpJson('POST', `${apiBase}/api/agent/auth`, {
2299
- body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
2300
- });
2301
- if (!res.ok || !json || typeof json.credential !== 'string') {
2302
- const detail = apiDetail(res, json);
2303
- fail(`could not start a connection: ${detail}`);
2304
- }
2305
- return json.credential;
2306
- }
2307
-
2308
- /** Persist the active credential plus the bits the status line prints. */
2309
- function saveCredential({ token, handle, room, rooms, roomAccess, account, scopes, apiBase }) {
2310
- writeJsonFile(credentialsPath(), {
2311
- version: 1,
2312
- token,
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.
2317
- room: room || null,
2318
- rooms: Array.isArray(rooms) ? rooms : [],
2319
- room_access: roomAccess || null,
2320
- account: account || null,
2321
- scopes: scopes || [],
2322
- api_url: apiBase,
2323
- created_at: new Date().toISOString(),
2324
- });
2325
- }
2326
-
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
- */
2332
- function connectedLine(cred) {
2333
- const who = cred.handle ? `@${cred.handle}` : 'this machine';
2334
- const room = cred.room && (cred.room.name || cred.room.invite_code);
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` : ''}`;
2343
- }
2344
-
2345
- function activationFailureDetail(result) {
2346
- if (result.error) return result.error.message;
2347
- const status = result.res ? `HTTP ${result.res.status}` : 'request failed';
2348
- return (result.json && (result.json.message || result.json.error || result.json.code)) || status;
2349
- }
2350
-
2351
- function isJsonObject(value) {
2352
- return value !== null && typeof value === 'object' && !Array.isArray(value);
2353
- }
2354
-
2355
- function isNonEmptyString(value) {
2356
- return typeof value === 'string' && value.trim() !== '';
2357
- }
2358
-
2359
- function isNullableString(value) {
2360
- return value === null || typeof value === 'string';
2361
- }
2362
-
2363
- function validateActivationEnsure(json) {
2364
- const room = json?.room;
2365
- const question = json?.question;
2366
- const validState = question?.state === 'pending'
2367
- || question?.state === 'answered'
2368
- || question?.state === 'expired'
2369
- || question?.state === 'cancelled';
2370
- if (
2371
- !isJsonObject(json)
2372
- || json.onboarded !== true
2373
- || typeof json.replayed !== 'boolean'
2374
- || !isJsonObject(room)
2375
- || !isNonEmptyString(room.id)
2376
- || typeof room.name !== 'string'
2377
- || !isNonEmptyString(room.invite_code)
2378
- || typeof room.is_agent_inbox !== 'boolean'
2379
- || !isJsonObject(question)
2380
- || !isNonEmptyString(question.id)
2381
- || question.kind !== 'question'
2382
- || !isNonEmptyString(question.prompt)
2383
- || !Array.isArray(question.options)
2384
- || question.options.some((option) => (
2385
- !isJsonObject(option)
2386
- || !isNonEmptyString(option.value)
2387
- || !isNonEmptyString(option.label)
2388
- ))
2389
- || !validState
2390
- || !isNullableString(question.expires_at)
2391
- || !isNullableString(question.created_at)
2392
- ) {
2393
- return { error: 'PingRoom returned an incomplete Agent Inbox ensure response' };
2394
- }
2395
- return { question };
2396
- }
2397
-
2398
- function validateActivationWait(json, questionId) {
2399
- const state = json?.state;
2400
- const validState = state === 'pending' || state === 'answered' || state === 'expired' || state === 'cancelled';
2401
- if (
2402
- !isJsonObject(json)
2403
- || !isNonEmptyString(json.id)
2404
- || json.id !== questionId
2405
- || json.kind !== 'question'
2406
- || !validState
2407
- || (json.activation_completed !== undefined && typeof json.activation_completed !== 'boolean')
2408
- || (state !== 'answered' && json.activation_completed === true)
2409
- ) {
2410
- return { error: 'PingRoom returned a mismatched Agent Inbox wait response' };
2411
- }
2412
-
2413
- if (state === 'answered') {
2414
- const answer = json.answer;
2415
- const responder = answer?.responder;
2416
- if (
2417
- !isJsonObject(answer)
2418
- || !isNullableString(answer.value)
2419
- || !isNullableString(answer.label)
2420
- || !isNullableString(answer.text)
2421
- || (!isNonEmptyString(answer.value) && !isNonEmptyString(answer.text))
2422
- || !isNullableString(answer.answered_at)
2423
- || (responder !== null && !isJsonObject(responder))
2424
- || (isJsonObject(responder)
2425
- && (!isNullableString(responder.id) || !isNullableString(responder.display_name)))
2426
- ) {
2427
- return { error: 'PingRoom returned an answered activation without a valid answer' };
2428
- }
2429
- } else if (json.answer !== undefined && json.answer !== null) {
2430
- return { error: 'PingRoom returned an answer for an unresolved activation' };
2431
- }
2432
-
2433
- return { value: json };
2434
- }
2435
-
2436
- function retryAfterMs(response) {
2437
- const raw = response?.headers?.get('retry-after')?.trim();
2438
- if (!raw) return null;
2439
- if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw) * 1000;
2440
- const at = Date.parse(raw);
2441
- return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
2442
- }
2443
-
2444
- function activationRetryDelay(result, transientRun, deadline) {
2445
- const fromHeader = result.res?.status === 429 ? retryAfterMs(result.res) : null;
2446
- const fallback = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 10_000);
2447
- return Math.max(0, Math.min(fromHeader ?? fallback, deadline - Date.now()));
2448
- }
2449
-
2450
- function activationIncomplete(detail, instruction = 'Run "pingroom activate" to retry with this saved connection.') {
2451
- const safeDetail = detail ? `: ${stripControlChars(detail)}` : '';
2452
- process.stdout.write(` Agent Inbox activation is not complete${safeDetail}\n`);
2453
- process.stdout.write(' Your connection is saved and usable.\n');
2454
- process.stdout.write(` ${instruction}\n`);
2455
- }
2456
-
2457
- /**
2458
- * Prove the freshly paired credential can complete a human round-trip. This is
2459
- * intentionally best-effort: saveCredential() has already committed the active
2460
- * bearer atomically, so no activation outage can roll back or corrupt it.
2461
- */
2462
- async function activateInboxAfterPairing(cred) {
2463
- const headers = { Authorization: `Bearer ${cred.token}` };
2464
- const overallDeadline = Date.now() + activationMaxWaitMs();
2465
- process.stdout.write(' Sending a test question to PingRoom…\n');
2466
-
2467
- let ensured;
2468
- let ensureTransientRun = 0;
2469
- while (Date.now() < overallDeadline) {
2470
- ensured = await httpJson('POST', `${cred.apiBase}/api/agent/inbox/ensure`, {
2471
- body: {},
2472
- headers,
2473
- soft: true,
2474
- signal: AbortSignal.timeout(Math.max(1, Math.min(15_000, overallDeadline - Date.now()))),
2475
- });
2476
- const transient = ensured.error || ensured.res?.status === 429 || ensured.res?.status >= 500;
2477
- if (!transient) break;
2478
- ensureTransientRun += 1;
2479
- await sleep(activationRetryDelay(ensured, ensureTransientRun, overallDeadline));
2480
- }
2481
-
2482
- if (!ensured.res?.ok) {
2483
- const detail = Date.now() >= overallDeadline
2484
- ? 'the two-minute activation deadline elapsed while PingRoom was unavailable'
2485
- : activationFailureDetail(ensured);
2486
- activationIncomplete(detail);
2487
- return false;
2488
- }
2489
-
2490
- const ensureEnvelope = validateActivationEnsure(ensured.json);
2491
- if (ensureEnvelope.error) {
2492
- activationIncomplete(ensureEnvelope.error);
2493
- return false;
2494
- }
2495
- const { question } = ensureEnvelope;
2496
-
2497
- process.stdout.write(' Answer “PingRoom connected. Can you answer this?” on your phone.\n');
2498
- // The server stamp, not the terminal state by itself, is the activation
2499
- // authority. A terminal answer without the stamp cannot become a valid
2500
- // receipt-before-answer sequence later, so fail clearly instead of polling a
2501
- // state the server intentionally will not rewrite.
2502
- const deadline = overallDeadline;
2503
- let transientRun = 0;
2504
-
2505
- while (Date.now() < deadline) {
2506
- const pollStartedAt = Date.now();
2507
- const remainingSeconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
2508
- const hold = Math.min(20, remainingSeconds);
2509
- const waited = await httpJson(
2510
- 'GET',
2511
- `${cred.apiBase}/api/agent/handoffs/${encodeURIComponent(question.id)}/wait?timeout=${hold}`,
2512
- {
2513
- headers,
2514
- soft: true,
2515
- signal: AbortSignal.timeout(Math.max(1, Math.min(
2516
- hold * 1000 + 10_000,
2517
- deadline - Date.now(),
2518
- ))),
2519
- },
2520
- );
2521
-
2522
- const transient = waited.error || waited.res?.status === 429 || waited.res?.status >= 500;
2523
- if (transient) {
2524
- transientRun += 1;
2525
- const retryDelay = activationRetryDelay(waited, transientRun, deadline);
2526
- const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
2527
- await sleep(Math.max(0, Math.min(Math.max(retryDelay, cadenceDelay), deadline - Date.now())));
2528
- continue;
2529
- }
2530
- transientRun = 0;
2531
-
2532
- if (!waited.res?.ok) {
2533
- activationIncomplete(activationFailureDetail(waited));
2534
- return false;
2535
- }
2536
-
2537
- const waitEnvelope = validateActivationWait(waited.json, question.id);
2538
- if (waitEnvelope.error) {
2539
- activationIncomplete(waitEnvelope.error);
2540
- return false;
2541
- }
2542
- const resolved = waitEnvelope.value;
2543
- const state = resolved.state;
2544
- if (state === 'answered') {
2545
- if (resolved.activation_completed !== true) {
2546
- activationIncomplete(
2547
- 'the test question was answered without verified phone receipt before the answer',
2548
- 'Update the PingRoom app if needed, then run "pingroom activate" to send a fresh test with this saved connection.',
2549
- );
2550
- return false;
2551
- }
2552
- const answer = resolved.answer.text || resolved.answer.label || resolved.answer.value;
2553
- process.stdout.write(`✓ Test question answered (${stripControlChars(answer)}). Agent Inbox is ready.\n`);
2554
- return true;
2555
- }
2556
- if (state === 'expired' || state === 'cancelled') {
2557
- activationIncomplete(
2558
- `the test question ${state}`,
2559
- 'Run "pingroom activate" to send a fresh test with this saved connection.',
2560
- );
2561
- return false;
2562
- }
2563
- // `pending` at the bounded hold timeout — continue at a throttle-safe
2564
- // cadence until the local/server deadline.
2565
- const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
2566
- await sleep(Math.max(0, Math.min(cadenceDelay, deadline - Date.now())));
2567
- }
2568
-
2569
- activationIncomplete(
2570
- 'still waiting for the test answer at the activation deadline',
2571
- );
2572
- return false;
2573
- }
2574
-
2575
- /** Retry activation only for the durable credential created by QR pairing. */
2576
- async function activateStoredInbox(args) {
2577
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2578
- if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
2579
- if (args.token !== undefined) {
2580
- fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
2581
- }
2582
- const unsupported = Object.keys(args).filter((key) => !['_', 'help', 'api', 'token'].includes(key));
2583
- if (unsupported.length > 0) {
2584
- fail('usage: pingroom activate [--api <url>]', EXIT.USAGE);
2585
- }
2586
-
2587
- const credential = readStoredCredential();
2588
- if (!credential) {
2589
- fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
2590
- }
2591
- if (!credential.room || !isNonEmptyString(credential.room.invite_code)) {
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
- );
2601
- }
2602
- if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
2603
- fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
2604
- }
2605
-
2606
- const apiBase = resolveApiBase(args);
2607
- requireSafeUrl('--api', apiBase);
2608
- if (!isNonEmptyString(credential.api_url)) {
2609
- fail('the saved QR-paired credential has no trusted API origin; pair again before running "pingroom activate"', EXIT.USAGE);
2610
- }
2611
- let credentialOrigin;
2612
- let targetOrigin;
2613
- try {
2614
- credentialOrigin = new URL(credential.api_url).origin;
2615
- targetOrigin = new URL(apiBase).origin;
2616
- } catch {
2617
- fail('the saved QR-paired credential has an invalid API origin; pair again', EXIT.USAGE);
2618
- }
2619
- if (credentialOrigin !== targetOrigin) {
2620
- fail(`stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}`, EXIT.USAGE);
2621
- }
2622
- process.stdout.write(`${connectedLine(credential)}\n`);
2623
-
2624
- const completed = await activateInboxAfterPairing({
2625
- ...credential,
2626
- apiBase,
2627
- });
2628
- return completed ? EXIT.OK : EXIT.ERROR;
2629
- }
2630
-
2631
- /**
2632
- * The QR path. Mints a pre-claim credential, asks the server for a pairing
2633
- * token, renders it, then polls until the human approves. Returns a credential
2634
- * object, or null when the pairing lapsed and the user declined a fresh one.
2635
- */
2636
- async function connectByPairing(apiBase, ask) {
2637
- for (;;) {
2638
- const preClaim = await registerAnonymous(apiBase);
2639
- const headers = { Authorization: `Bearer ${preClaim}` };
2640
-
2641
- const start = await httpJson('POST', `${apiBase}/api/agent/auth/pair/start`, {
2642
- body: { scopes: CLI_SCOPES },
2643
- headers,
2644
- });
2645
- if (!start.res.ok || !start.json || typeof start.json.pair_url !== 'string') {
2646
- const detail = (start.json && (start.json.message || start.json.error || start.json.code))
2647
- || `HTTP ${start.res.status}`;
2648
- fail(`could not start pairing: ${detail}`);
2649
- }
2650
-
2651
- // The URL is server-controlled and goes straight to the terminal, so strip
2652
- // C0/C1 controls: an --api / config api_url pointing at a hostile host could
2653
- // otherwise emit ANSI escapes that repaint or hide the line the user is
2654
- // about to trust with their account.
2655
- const pairUrl = stripControlChars(start.json.pair_url);
2656
- // 900s is the server's pre-claim lifetime; never poll past it, and clamp the
2657
- // server's suggested interval so a bad value can't busy-loop or stall.
2658
- // The 1000ms floor is not cosmetic: AGENT_PAIRING_SPEC.md throttles
2659
- // pair/status at `60,1`, so a faster floor spends the pairing window
2660
- // collecting 429s instead of the approval.
2661
- const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
2662
- const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
2663
- const deadline = Date.now() + lifetimeMs;
2664
-
2665
- const drew = await renderQr(pairUrl);
2666
- process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
2667
- process.stdout.write(' Waiting for approval… ');
2668
-
2669
- // A transient failure must not end a wait the human is mid-way through.
2670
- // Network errors, 5xx and 429 are the load balancer / rate limiter talking,
2671
- // not the pairing being over; hard-failing on the first one throws away the
2672
- // whole 15 minutes over a single blip. 401/403/404 still exit immediately —
2673
- // those say the pre-claim is gone, and retrying can only spin.
2674
- // The `Date.now() < deadline` bound is what keeps a *persistent* outage from
2675
- // retrying forever: it ends at the same moment a clean poll would have.
2676
- let transientRun = 0;
2677
- let lastTransient = null;
2678
- let warnedTransient = false;
2679
-
2680
- while (Date.now() < deadline) {
2681
- const { res, json, error } = await httpJson(
2682
- 'GET', `${apiBase}/api/agent/auth/pair/status`, { headers, soft: true },
2683
- );
2684
-
2685
- if (error || res.status >= 500 || res.status === 429) {
2686
- transientRun += 1;
2687
- lastTransient = error
2688
- ? error.message
2689
- : `HTTP ${res.status}`;
2690
- // Say something rather than sitting mute: a user watching a QR with no
2691
- // output cannot tell a slow approval from a broken endpoint.
2692
- if (transientRun === 3 && !warnedTransient) {
2693
- warnedTransient = true;
2694
- process.stdout.write(`\n (still trying — ${lastTransient}) `);
2695
- }
2696
- // Ride out a short blip at the normal cadence, then back off
2697
- // geometrically so a real outage is not also a thundering herd. Never
2698
- // sleep past the deadline this loop is bounded by.
2699
- const backoff = Math.min(intervalMs * 2 ** Math.max(0, transientRun - 3), 30_000);
2700
- await sleep(Math.max(0, Math.min(backoff, deadline - Date.now())));
2701
- continue;
2702
- }
2703
-
2704
- transientRun = 0;
2705
-
2706
- if (!res.ok) {
2707
- process.stdout.write('\n');
2708
- const detail = apiDetail(res, json);
2709
- fail(`pairing failed: ${detail}`);
2710
- }
2711
- const status = json && json.status;
2712
- if (status === 'active') {
2713
- // A server that says "active" with no credential has not paired us.
2714
- // Without this, `token: undefined` is written to credentials.json and
2715
- // every later command reads a credential file that exists but cannot
2716
- // authenticate — a far more confusing failure than stopping here.
2717
- if (typeof json.credential !== 'string' || json.credential === '') {
2718
- process.stdout.write('\n');
2719
- fail('pairing succeeded but the server returned no credential');
2720
- }
2721
- const cred = {
2722
- token: json.credential,
2723
- handle: json.handle,
2724
- room: json.room,
2725
- rooms: Array.isArray(json.rooms) ? json.rooms : [],
2726
- roomAccess: typeof json.room_access === 'string' ? json.room_access : null,
2727
- account: json.account,
2728
- scopes: json.scopes,
2729
- apiBase,
2730
- };
2731
- saveCredential(cred);
2732
- process.stdout.write(`${connectedLine(cred)}\n`);
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.
2738
- return cred;
2739
- }
2740
- if (status === 'expired') break;
2741
- // `pending` (or anything unrecognized) — keep waiting.
2742
- await sleep(intervalMs);
2743
- }
2744
-
2745
- if (transientRun > 0) {
2746
- process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
2747
- } else {
2748
- process.stdout.write(`\n That code expired.\n`);
2749
- }
2750
-
2751
- // `null` means the input is closed, and that is the whole point of this
2752
- // guard. Reading EOF as "" would fall through the y/yes test below (empty
2753
- // means "take the default: yes"), restart the for(;;), mint another
2754
- // anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
2755
- // a single pairing attempt into thousands of registrations against the API.
2756
- const again = await ask(' Show a fresh QR code? [Y/n]: ');
2757
- if (again === null) { process.stdout.write('\n'); return null; }
2758
- const answer = again.trim().toLowerCase();
2759
- if (answer && answer !== 'y' && answer !== 'yes') return null;
2760
- }
2761
- }
2762
-
2763
- /**
2764
- * The email fallback, over the unchanged claim/* endpoints: the server mails a
2765
- * link, the web page shows a 6-digit code, the user reads it back here.
2766
- */
2767
- async function connectByEmail(apiBase, ask) {
2768
- const preClaim = await registerAnonymous(apiBase);
2769
- const headers = { Authorization: `Bearer ${preClaim}` };
2770
-
2771
- // `?? ''` preserves the old EOF behaviour deliberately: ask() now returns null
2772
- // at EOF, and without the coalesce this would throw a TypeError on `.trim()`
2773
- // instead of reaching the "this is required" error the user should see.
2774
- const email = (await ask(' Your PingRoom email: ') ?? '').trim();
2775
- if (!email) fail('an email address is required', EXIT.USAGE);
2776
-
2777
- const start = await httpJson('POST', `${apiBase}/api/agent/auth/claim/start`, {
2778
- body: { email },
2779
- headers,
2780
- });
2781
- if (!start.res.ok) {
2782
- const detail = (start.json && (start.json.message || start.json.error || start.json.code))
2783
- || `HTTP ${start.res.status}`;
2784
- fail(`could not send the email: ${detail}`);
2785
- }
2786
-
2787
- process.stdout.write(' Sent. Open the link in that email — the page shows a 6-digit code.\n');
2788
-
2789
- // A mistyped code is the common case, so allow a few tries before giving up.
2790
- // The server locks the registration out after its own attempt cap anyway.
2791
- for (let attempt = 1; attempt <= 3; attempt++) {
2792
- // Same reason as the email prompt: EOF stays an empty answer, which the
2793
- // server rejects, rather than a TypeError on null.
2794
- const otp = (await ask(' Code: ') ?? '').trim();
2795
- const done = await httpJson('POST', `${apiBase}/api/agent/auth/claim/complete`, {
2796
- body: { email, otp },
2797
- headers,
2798
- });
2799
- if (done.res.ok && done.json && typeof done.json.credential === 'string') {
2800
- const cred = {
2801
- token: done.json.credential,
2802
- handle: done.json.handle,
2803
- // claim/complete carries no room — the email flow does not choose one.
2804
- room: done.json.room,
2805
- account: done.json.account,
2806
- scopes: done.json.scopes,
2807
- apiBase,
2808
- };
2809
- saveCredential(cred);
2810
- process.stdout.write(`${connectedLine(cred)}\n`);
2811
- if (!cred.room) {
2812
- process.stdout.write(' For room commands: pingroom config set default_room <invite code>\n');
2813
- process.stdout.write(' For private Inbox/Handoff delivery, reconnect with QR pairing.\n');
2814
- }
2815
- return cred;
2816
- }
2817
- const detail = (done.json && (done.json.message || done.json.error || done.json.code))
2818
- || `HTTP ${done.res.status}`;
2819
- if (attempt === 3) fail(`could not connect: ${detail}`);
2820
- process.stderr.write(`pingroom: ${detail}\n`);
2821
- }
2822
- return null;
2823
- }
2824
-
2825
- /**
2826
- * Resolve the unconnected state interactively. Refuses outright when there is no
2827
- * TTY — a hung prompt in CI is worse than a clean failure, and the fix there is
2828
- * PINGROOM_TOKEN, not a QR nobody can scan.
2829
- */
2830
- async function connect(args) {
2831
- if (!isInteractive()) {
2832
- fail(
2833
- 'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
2834
- EXIT.USAGE,
2835
- );
2836
- }
2837
-
2838
- const apiBase = resolveApiBase(args);
2839
- requireSafeUrl('--api', apiBase);
2840
-
2841
- const prompter = createPrompter();
2842
- const ask = (question) => prompter.ask(question);
2843
- try {
2844
- process.stdout.write(' Not connected. How do you want to connect?\n');
2845
- process.stdout.write(' 1) Scan a QR code with the PingRoom app\n');
2846
- process.stdout.write(' 2) Email me a code\n');
2847
- // EOF here means "no answer", which is what the default already covers, so
2848
- // coalesce rather than crash on null — the pairing branch below is the one
2849
- // that must distinguish EOF, and it does.
2850
- const choice = (await ask(' Choose [1]: ') ?? '').trim();
2851
- if (choice && choice !== '1' && choice !== '2') {
2852
- process.stderr.write('pingroom: choose 1 or 2\n');
2853
- return EXIT.USAGE;
2854
- }
2855
-
2856
- const cred = choice === '2'
2857
- ? await connectByEmail(apiBase, ask)
2858
- : await connectByPairing(apiBase, ask);
2859
-
2860
- return cred ? EXIT.OK : EXIT.EXPIRED;
2861
- } finally {
2862
- prompter.close();
2863
- }
2864
- }
2865
-
2866
- // --- status / bare invocation ----------------------------------------------
2867
-
2868
- /**
2869
- * `pingroom` with no arguments. Connected -> one status line then the usual
2870
- * help. Not connected -> pair (interactive) or, in a pipe/CI, say so on stderr
2871
- * and still print the help rather than prompting into the void.
2872
- */
2873
- async function bare(args) {
2874
- const envToken = process.env.PINGROOM_TOKEN;
2875
- const stored = readStoredCredential();
2876
-
2877
- if (envToken) {
2878
- process.stdout.write('Using the agent token from PINGROOM_TOKEN.\n');
2879
- if (stored) process.stdout.write(`(the stored credential in ${credentialsPath()} is ignored while it is set)\n`);
2880
- const room = resolveRoom(args);
2881
- if (room) process.stdout.write(`Default room: ${room}\n`);
2882
- process.stdout.write(`\n${HELP}\n`);
2883
- return EXIT.OK;
2884
- }
2885
-
2886
- if (stored) {
2887
- process.stdout.write(`${connectedLine(stored)}\n`);
2888
- const room = resolveRoom(args);
2889
- if (room) process.stdout.write(`Default room: ${room}\n`);
2890
- process.stdout.write(`\n${HELP}\n`);
2891
- return EXIT.OK;
2892
- }
2893
-
2894
- if (!isInteractive()) {
2895
- process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
2896
- process.stdout.write(`${HELP}\n`);
2897
- return EXIT.OK;
2898
- }
2899
-
2900
- return connect(args);
2901
- }
2902
-
2903
- // --- config ----------------------------------------------------------------
2904
-
2905
- // Only these keys are storable. An unknown key is a usage error rather than a
2906
- // silently-ignored setting the user then blames the tool for not honouring.
2907
- const CONFIG_KEYS = {
2908
- default_room: {
2909
- describe: 'Room invite code used when --room / PINGROOM_ROOM is absent',
2910
- validate: (value) => {
2911
- if (/\s/.test(value) || value.length > 64) return 'default_room must be an invite code (no spaces, <= 64 chars)';
2912
- return null;
2913
- },
2914
- },
2915
- api_url: {
2916
- describe: `API base URL (default ${BUILTIN_API})`,
2917
- validate: (value) => {
2918
- let u;
2919
- try { u = new URL(value); } catch { return 'api_url must be a valid URL'; }
2920
- const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
2921
- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
2922
- return 'api_url must use https (refusing to send credentials over cleartext)';
2923
- }
2924
- return null;
2925
- },
2926
- },
2927
- };
2928
-
2929
- async function config(args) {
2930
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2931
-
2932
- const sub = args._[0];
2933
- const known = ['list', 'get', 'set'];
2934
- if (!sub || !known.includes(sub)) {
2935
- fail(`config needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
2936
- }
2937
-
2938
- const stored = readConfigFile();
2939
-
2940
- if (sub === 'list') {
2941
- if (args.json) { process.stdout.write(`${JSON.stringify(stored)}\n`); return EXIT.OK; }
2942
- const keys = Object.keys(CONFIG_KEYS).filter((k) => stored[k] !== undefined && stored[k] !== '');
2943
- if (keys.length === 0) {
2944
- process.stdout.write(`no settings stored in ${configPath()}\n`);
2945
- return EXIT.OK;
2946
- }
2947
- for (const key of keys) process.stdout.write(`${key}=${stored[key]}\n`);
2948
- return EXIT.OK;
2949
- }
2950
-
2951
- const key = args._[1];
2952
- if (!key) fail(`config ${sub} needs a key (${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
2953
- if (!Object.hasOwn(CONFIG_KEYS, key)) {
2954
- fail(`unknown config key: ${key} (known keys: ${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
2955
- }
2956
-
2957
- if (sub === 'get') {
2958
- const value = stored[key];
2959
- if (value === undefined || value === '') return EXIT.OK; // unset: print nothing, exit 0
2960
- process.stdout.write(`${value}\n`);
2961
- return EXIT.OK;
2962
- }
2963
-
2964
- // set
2965
- const raw = args._[2];
2966
- if (raw === undefined) fail(`config set needs a value (pass "" to clear ${key})`, EXIT.USAGE);
2967
- const value = String(raw).trim();
2968
-
2969
- if (value === '') {
2970
- delete stored[key];
2971
- writeJsonFile(configPath(), stored);
2972
- process.stdout.write(`${key} cleared\n`);
2973
- return EXIT.OK;
2974
- }
2975
-
2976
- const problem = CONFIG_KEYS[key].validate(value);
2977
- if (problem) fail(problem, EXIT.USAGE);
2978
-
2979
- stored[key] = value;
2980
- writeJsonFile(configPath(), stored);
2981
- process.stdout.write(`${key}=${value}\n`);
2982
- return EXIT.OK;
2983
- }
2984
-
2985
- // --- logout ----------------------------------------------------------------
2986
-
2987
- async function logout(args) {
2988
- if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
2989
-
2990
- const path = credentialsPath();
2991
- const stored = readStoredCredential();
2992
- try {
2993
- unlinkSync(path);
2994
- } catch (err) {
2995
- if (err.code === 'ENOENT') {
2996
- process.stdout.write('not connected — there was no stored credential to clear\n');
2997
- return EXIT.OK;
2998
- }
2999
- fail(`could not clear ${path}: ${err.message}`);
3000
- }
3001
-
3002
- const who = stored && stored.handle ? ` (@${stored.handle})` : '';
3003
- process.stdout.write(`logged out${who} — cleared ${path}\n`);
3004
- if (process.env.PINGROOM_TOKEN) {
3005
- process.stdout.write('note: PINGROOM_TOKEN is still set in this environment and will keep being used\n');
3006
- }
3007
- return EXIT.OK;
3008
- }
33
+ // This file is the entry point only: the dispatch table, main(), and the
34
+ // top-level catch. Everything it calls lives under lib/ see lib/help.js for
35
+ // the --help text, lib/parser.js for the flag vocabulary, and lib/commands/*
36
+ // for one module per command.
37
+
38
+ import { EXIT } from '../lib/constants.js';
39
+ import { fail, stripControlChars } from '../lib/util.js';
40
+ import { VERSION } from '../lib/version.js';
41
+ import { HELP } from '../lib/help.js';
42
+ import {
43
+ parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
44
+ parseLiveArgs, parseLogoutArgs, parseQArgs,
45
+ } from '../lib/parser.js';
46
+ import { ping } from '../lib/commands/ping.js';
47
+ import { ask, cancel, list, watch } from '../lib/commands/ask.js';
48
+ import { handoff, listHandoffs } from '../lib/commands/handoff.js';
49
+ import { listen } from '../lib/commands/listen.js';
50
+ import { live } from '../lib/commands/live.js';
51
+ import { hook } from '../lib/commands/hook.js';
52
+ import { mcp } from '../lib/commands/mcp.js';
53
+ import { activateStoredInbox, bare } from '../lib/commands/connect.js';
54
+ import { config, logout } from '../lib/commands/config.js';
3009
55
 
3010
56
  const COMMANDS = {
3011
57
  ping: (rest) => ping(parseArgs(rest)),
@@ -3015,14 +61,14 @@ const COMMANDS = {
3015
61
  cancel: (rest) => cancel(parseQArgs(rest)),
3016
62
  list: (rest) => list(parseQArgs(rest)),
3017
63
  handoff: (rest) => handoff(parseHandoffArgs(rest)),
3018
- handoffs: (rest) => listHandoffs(parseQArgs(rest)),
64
+ handoffs: (rest) => listHandoffs(parseHandoffsArgs(rest)),
3019
65
  listen: (rest) => listen(parseQArgs(rest)),
3020
66
  hook: (rest) => hook(parseHookArgs(rest)),
3021
67
  mcp,
3022
68
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
3023
69
  live: (rest) => live(parseLiveArgs(rest)),
3024
- config: (rest) => config(parseQArgs(rest)),
3025
- logout: (rest) => logout(parseQArgs(rest)),
70
+ config: (rest) => config(parseConfigArgs(rest)),
71
+ logout: (rest) => logout(parseLogoutArgs(rest)),
3026
72
  };
3027
73
 
3028
74
  function waitFrom(handler, rest) {