@pingroom/cli 0.6.2 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -40
- package/bin/pingroom.js +487 -225
- package/package.json +1 -1
package/bin/pingroom.js
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
// handoff Hand a decision to a specific human (ack or question) and, with
|
|
20
20
|
// --wait, block until they acknowledge / answer.
|
|
21
21
|
// handoffs List the agent's open handoffs or bounded recent history.
|
|
22
|
+
// listen Long-poll for pings arriving in the agent's rooms.
|
|
22
23
|
// live Drive a live progress card (iOS Live Activity / Android live
|
|
23
24
|
// update) on the room members' lock screen: start / update / end.
|
|
24
25
|
// mcp Print the canonical remote MCP endpoint and client setup snippets.
|
|
25
|
-
// activate
|
|
26
|
+
// activate Send one optional test Question with the saved QR-paired credential.
|
|
26
27
|
// config Read/write ~/.pingroom/config.json (default_room, api_url).
|
|
27
28
|
// logout Forget the credential in ~/.pingroom/credentials.json.
|
|
28
29
|
//
|
|
@@ -40,13 +41,17 @@ import { join } from 'node:path';
|
|
|
40
41
|
// Kept in lockstep with package.json / package-lock.json. The GitHub Action is
|
|
41
42
|
// pinned independently to the latest version already published on npm; a test
|
|
42
43
|
// makes that release gate explicit. `hook --print-config` emits this candidate.
|
|
43
|
-
const VERSION = '0.
|
|
44
|
+
const VERSION = '0.7.2';
|
|
44
45
|
|
|
45
46
|
const BUILTIN_API = 'https://api.pingroom.io';
|
|
46
47
|
const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
|
|
47
48
|
const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
// The help text lives as one section per command plus intro/shared/tail, so
|
|
51
|
+
// `pingroom <command> --help` can print a focused excerpt (see commandHelp).
|
|
52
|
+
// The full HELP below joins them in the historical order — `pingroom --help`
|
|
53
|
+
// output is byte-identical to the pre-split single blob.
|
|
54
|
+
const HELP_INTRO = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
|
|
50
55
|
|
|
51
56
|
Usage:
|
|
52
57
|
pingroom <command> [options]
|
|
@@ -60,6 +65,7 @@ Commands:
|
|
|
60
65
|
handoff Hand a decision (ack or question) to a specific human; with --wait,
|
|
61
66
|
block until they acknowledge or answer
|
|
62
67
|
handoffs List the agent's open handoffs or bounded recent history
|
|
68
|
+
listen Block on pings arriving in your rooms and print them as they land
|
|
63
69
|
live Drive a live progress card on the lock screen (Live Activity)
|
|
64
70
|
hook Claude Code hook: ping on Stop/Notification, and route tool
|
|
65
71
|
permission prompts to a PingRoom question you answer from your phone
|
|
@@ -67,9 +73,9 @@ Commands:
|
|
|
67
73
|
Claude Desktop
|
|
68
74
|
activate Retry Agent Inbox activation with the saved QR-paired credential
|
|
69
75
|
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
70
|
-
logout Forget the stored credential
|
|
76
|
+
logout Forget the stored credential`;
|
|
71
77
|
|
|
72
|
-
ping options:
|
|
78
|
+
const HELP_PING = `ping options:
|
|
73
79
|
-m, --message <text> Ping body text (required)
|
|
74
80
|
-t, --title <text> Ping title (<= 40 chars)
|
|
75
81
|
-a, --action <1-4> Quick-action slot to attribute the ping to
|
|
@@ -82,9 +88,9 @@ ping options:
|
|
|
82
88
|
repeat for up to 4. Requires --token and a Pro account
|
|
83
89
|
-w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
|
|
84
90
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
85
|
-
--room <code> Room invite code (used with --token)
|
|
91
|
+
--room <code> Room invite code (used with --token)`;
|
|
86
92
|
|
|
87
|
-
ask options (agent token required):
|
|
93
|
+
const HELP_ASK = `ask options (agent token required):
|
|
88
94
|
-p, --prompt <text> The question a human reads (required)
|
|
89
95
|
-o, --option <v:label[:style]>
|
|
90
96
|
An answer option (style: primary|danger|default);
|
|
@@ -100,12 +106,12 @@ ask options (agent token required):
|
|
|
100
106
|
-d, --data <json> Structured data object echoed back on the answer
|
|
101
107
|
--correlation-id <id> Opaque id echoed on every read of this question
|
|
102
108
|
--reply-to <id> Id of the ping this question replies to
|
|
103
|
-
--room <code> Room invite code (required for ask)
|
|
109
|
+
--room <code> Room invite code (required for ask)`;
|
|
104
110
|
|
|
105
|
-
list options:
|
|
106
|
-
--state <s> pending | answered | expired | cancelled | all
|
|
111
|
+
const HELP_LIST = `list options:
|
|
112
|
+
--state <s> pending | answered | expired | cancelled | all`;
|
|
107
113
|
|
|
108
|
-
handoff options (agent token required; consent scope pingroom:handoffs:create):
|
|
114
|
+
const HELP_HANDOFF = `handoff options (agent token required; consent scope pingroom:handoffs:create):
|
|
109
115
|
-m, --message <text> The prompt a human reads (required)
|
|
110
116
|
--question Make it a question (else a simple acknowledge). Also
|
|
111
117
|
implied whenever one or more --option is given.
|
|
@@ -119,15 +125,24 @@ handoff options (agent token required; consent scope pingroom:handoffs:create):
|
|
|
119
125
|
-d, --data <json> Structured data object echoed on the handoff
|
|
120
126
|
--wait Block until acked / answered / expired / cancelled
|
|
121
127
|
--timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
|
|
122
|
-
--github-output <path> Safely append handoff outputs for GitHub Actions
|
|
128
|
+
--github-output <path> Safely append handoff outputs for GitHub Actions`;
|
|
123
129
|
|
|
124
|
-
handoffs options (agent token required; consent scope pingroom:handoffs:create):
|
|
125
|
-
--state <s> open | all (default open)
|
|
130
|
+
const HELP_HANDOFFS = `handoffs options (agent token required; consent scope pingroom:handoffs:create):
|
|
131
|
+
--state <s> open | all (default open)`;
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
const HELP_LISTEN = `listen options (agent token required; consent scope pingroom:notifications:read):
|
|
134
|
+
--timeout <sec> Per long-poll hold (0-30, default 25)
|
|
135
|
+
--limit <n> Max pings per batch (1-100, default 50)
|
|
136
|
+
--from <id> Start after this ping id instead of "now"
|
|
137
|
+
--once Print one batch and exit instead of blocking forever
|
|
138
|
+
--json One JSON object per line instead of a readable line`;
|
|
139
|
+
|
|
140
|
+
const HELP_LIVE = `live <start|update|end|get> options (agent token, or a room webhook):
|
|
128
141
|
-c, --correlation-id <id> The stream key — reuse it for every ping (required)
|
|
129
142
|
--template <name> start only: status | steps | progress | metrics |
|
|
130
|
-
countdown |
|
|
143
|
+
countdown | decision | matchup (fixed at creation;
|
|
144
|
+
'decision' is the app's name for the wire id
|
|
145
|
+
'question', which is still accepted)
|
|
131
146
|
--category <name> start only: status | steps | alert. Legacy, but
|
|
132
147
|
'alert' has no template equivalent and is the only
|
|
133
148
|
way to start time-sensitive without --require-ack
|
|
@@ -138,50 +153,52 @@ live <start|update|end|get> options (agent token, or a room webhook):
|
|
|
138
153
|
--metric <label:value> Repeatable, up to 3 (metrics template)
|
|
139
154
|
--deadline-at <epoch> Countdown target (countdown template)
|
|
140
155
|
--eta-at <epoch> Live ETA (status/progress templates)
|
|
141
|
-
--prompt <text> The ask (
|
|
142
|
-
--option <value:label> Repeatable, up to 4 (
|
|
156
|
+
--prompt <text> The ask (decision template)
|
|
157
|
+
--option <value:label> Repeatable, up to 4 (decision template). A bare
|
|
143
158
|
token is both value and label
|
|
144
159
|
--left <label:value> Left side (matchup template)
|
|
145
160
|
--right <label:value> Right side (matchup template)
|
|
146
161
|
--center <text> Center score/clock, <= 40 (matchup template)
|
|
147
162
|
--accent-override <#rrggbb> Semantic accent for this frame
|
|
148
163
|
--failed end only: finish as failed instead of done
|
|
164
|
+
-d, --data <json> Structured data object carried on this frame
|
|
149
165
|
-t, --title <text> Card title (<= 40 chars)
|
|
150
166
|
-a, --action <1-4> Quick-action slot supplying the icon and sound
|
|
151
167
|
--require-ack Add an Acknowledge button
|
|
152
168
|
--ack-timeout <s> Ack deadline in seconds
|
|
153
169
|
--room <code> Room invite code (used with --token)
|
|
154
|
-
-w, --webhook <url> Room webhook URL instead of a token
|
|
170
|
+
-w, --webhook <url> Room webhook URL instead of a token`;
|
|
155
171
|
|
|
156
|
-
hook options (reads a Claude Code event; defaults to stored credentials/config):
|
|
172
|
+
const HELP_HOOK = `hook options (reads a Claude Code event; defaults to stored credentials/config):
|
|
157
173
|
--room <code> Room invite code (or env/config/paired room)
|
|
158
174
|
--ttl <seconds> Approval-question expiry for PreToolUse (default 900)
|
|
159
175
|
--quiet Suppress the informational stderr lines
|
|
160
|
-
--print-config Print a ready-to-paste ~/.claude/settings.json block
|
|
176
|
+
--print-config Print a ready-to-paste ~/.claude/settings.json block`;
|
|
161
177
|
|
|
162
|
-
mcp:
|
|
178
|
+
const HELP_MCP = `mcp:
|
|
163
179
|
pingroom mcp Print the endpoint and client setup snippets
|
|
164
180
|
pingroom mcp add claude-code Print the Claude Code setup command
|
|
165
|
-
(output-only; does not change client config)
|
|
181
|
+
(output-only; does not change client config)`;
|
|
166
182
|
|
|
167
|
-
activate:
|
|
168
|
-
pingroom activate
|
|
169
|
-
|
|
183
|
+
const HELP_ACTIVATE = `activate:
|
|
184
|
+
pingroom activate Send one test Question to your phone to prove the
|
|
185
|
+
saved QR-paired credential works (optional —
|
|
186
|
+
connecting no longer does this for you)`;
|
|
170
187
|
|
|
171
|
-
config options:
|
|
188
|
+
const HELP_CONFIG = `config options:
|
|
172
189
|
pingroom config list Print the stored settings
|
|
173
190
|
pingroom config get <key> Print one setting
|
|
174
191
|
pingroom config set <key> <val> Store a setting (an empty value clears it)
|
|
175
|
-
Keys: default_room, api_url
|
|
192
|
+
Keys: default_room, api_url`;
|
|
176
193
|
|
|
177
|
-
Shared:
|
|
194
|
+
const HELP_SHARED = `Shared:
|
|
178
195
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
179
196
|
--api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
|
|
180
197
|
--json Print the raw JSON response
|
|
181
198
|
-h, --help Show this help
|
|
182
|
-
-v, --version Show the CLI version
|
|
199
|
+
-v, --version Show the CLI version`;
|
|
183
200
|
|
|
184
|
-
Connecting:
|
|
201
|
+
const HELP_TAIL = `Connecting:
|
|
185
202
|
Install globally, then run with no arguments:
|
|
186
203
|
npm install --global @pingroom/cli
|
|
187
204
|
pingroom
|
|
@@ -190,11 +207,10 @@ Connecting:
|
|
|
190
207
|
npx --yes @pingroom/cli
|
|
191
208
|
|
|
192
209
|
It prints a QR code you scan with the PingRoom app — you pick the account and
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
test later. The emailed-code fallback stores no server-side delivery room.
|
|
210
|
+
the rooms it may reach there (one, several, or all of them). Once paired, it
|
|
211
|
+
saves the credential and you are done; connecting sends nothing to your phone.
|
|
212
|
+
Run "pingroom activate" if you want to prove the round-trip with one test
|
|
213
|
+
Question. The emailed-code fallback stores no server-side delivery room.
|
|
198
214
|
"config set default_room" enables room-addressed commands, but private
|
|
199
215
|
Inbox/Handoff delivery requires QR pairing.
|
|
200
216
|
There is no "login" command: being unconnected is a state the tool resolves,
|
|
@@ -280,6 +296,54 @@ or the recipient was not ready (409 recipient_not_ready). A question answered
|
|
|
280
296
|
with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
|
|
281
297
|
human decision is not an infrastructure failure.`;
|
|
282
298
|
|
|
299
|
+
const HELP = [
|
|
300
|
+
HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
|
|
301
|
+
HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
|
|
302
|
+
HELP_SHARED, HELP_TAIL,
|
|
303
|
+
].join('\n\n');
|
|
304
|
+
|
|
305
|
+
// Sections for `pingroom <command> --help`. watch/cancel/logout have no block
|
|
306
|
+
// of their own in the full help, so they get a minimal one here.
|
|
307
|
+
const COMMAND_HELP_SECTIONS = {
|
|
308
|
+
ping: HELP_PING,
|
|
309
|
+
ask: HELP_ASK,
|
|
310
|
+
watch: `watch:
|
|
311
|
+
pingroom watch <question-id> Block until the question resolves and
|
|
312
|
+
print the outcome
|
|
313
|
+
--timeout <sec> Per long-poll hold (0–30, default 25)`,
|
|
314
|
+
cancel: `cancel:
|
|
315
|
+
pingroom cancel <question-id> Withdraw a pending question`,
|
|
316
|
+
list: HELP_LIST,
|
|
317
|
+
handoff: HELP_HANDOFF,
|
|
318
|
+
handoffs: HELP_HANDOFFS,
|
|
319
|
+
listen: HELP_LISTEN,
|
|
320
|
+
live: HELP_LIVE,
|
|
321
|
+
hook: HELP_HOOK,
|
|
322
|
+
activate: HELP_ACTIVATE,
|
|
323
|
+
config: HELP_CONFIG,
|
|
324
|
+
logout: `logout:
|
|
325
|
+
pingroom logout Forget the stored credential (PINGROOM_TOKEN
|
|
326
|
+
in the environment is unaffected)`,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// config and logout are local-only commands that reject --token/--api (and,
|
|
330
|
+
// for logout, --json), so their help gets a footer that only lists what they
|
|
331
|
+
// actually accept instead of the full shared block.
|
|
332
|
+
const COMMAND_HELP_FOOTERS = {
|
|
333
|
+
config: `Shared:
|
|
334
|
+
--json Print the raw JSON response
|
|
335
|
+
-h, --help Show this help`,
|
|
336
|
+
logout: `Shared:
|
|
337
|
+
-h, --help Show this help`,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// `<command> --help`: that command's section plus the shared flags, instead of
|
|
341
|
+
// the full reference `pingroom --help` / `pingroom help` still print.
|
|
342
|
+
function commandHelp(name) {
|
|
343
|
+
const section = COMMAND_HELP_SECTIONS[name];
|
|
344
|
+
return section ? `${section}\n\n${COMMAND_HELP_FOOTERS[name] ?? HELP_SHARED}` : HELP;
|
|
345
|
+
}
|
|
346
|
+
|
|
283
347
|
const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
|
|
284
348
|
|
|
285
349
|
function fail(message, code = EXIT.ERROR) {
|
|
@@ -287,6 +351,35 @@ function fail(message, code = EXIT.ERROR) {
|
|
|
287
351
|
process.exit(code);
|
|
288
352
|
}
|
|
289
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The fixes that live on THIS side of the wire. The server's message always
|
|
356
|
+
* leads; these are appended only for the codes where the operator would
|
|
357
|
+
* otherwise have no way to know what to do next, and where the answer is a
|
|
358
|
+
* local action rather than "try again".
|
|
359
|
+
*/
|
|
360
|
+
const API_HINTS = {
|
|
361
|
+
room_not_granted:
|
|
362
|
+
'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.',
|
|
363
|
+
insufficient_scope:
|
|
364
|
+
'This credential was approved before the command needed that permission. Run "pingroom" to reconnect and re-approve.',
|
|
365
|
+
no_room_configured:
|
|
366
|
+
'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What to print when an API call fails: the server's own wording, plus the one
|
|
371
|
+
* thing that would fix it when we know one.
|
|
372
|
+
*/
|
|
373
|
+
function apiDetail(res, json) {
|
|
374
|
+
// The server's wording is untrusted text headed for the terminal — strip
|
|
375
|
+
// escapes so a hostile API can't smuggle ANSI (same threat model as pair_url).
|
|
376
|
+
const base = stripControlChars(
|
|
377
|
+
(json && (json.message || json.error || json.code)) || `HTTP ${res ? res.status : 'error'}`,
|
|
378
|
+
);
|
|
379
|
+
const hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
|
|
380
|
+
return hint ? `${base}\n ${hint}` : base;
|
|
381
|
+
}
|
|
382
|
+
|
|
290
383
|
// --- local state (~/.pingroom) ---------------------------------------------
|
|
291
384
|
//
|
|
292
385
|
// Two files, both under a 0700 directory:
|
|
@@ -462,16 +555,86 @@ function sleep(ms) {
|
|
|
462
555
|
// Drop C0/C1 control characters before echoing server-supplied text to the
|
|
463
556
|
// terminal. Without this an attacker-controlled API base can smuggle ANSI
|
|
464
557
|
// escapes into the output and repaint, erase or overwrite the lines around them.
|
|
558
|
+
/**
|
|
559
|
+
* Reject an over-long field here rather than letting it become a 422.
|
|
560
|
+
*
|
|
561
|
+
* Every bound mirrors a Laravel rule (StoreNotificationRequest,
|
|
562
|
+
* StoreQuestionRequest, LiveStatusRules) and is documented in --help, so a value
|
|
563
|
+
* past it was always going to be refused — locally it reads as the usage error
|
|
564
|
+
* it is, with the limit and the actual length named.
|
|
565
|
+
*/
|
|
566
|
+
function requireMaxLength(value, max, flag) {
|
|
567
|
+
if (typeof value === 'string' && value.length > max) {
|
|
568
|
+
fail(`${flag} must be at most ${max} characters (got ${value.length})`, EXIT.USAGE);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Validate --timeout and resolve the per-poll hold. Called by ask/handoff
|
|
574
|
+
* BEFORE the create POST: the old in-wait check ran only after the question or
|
|
575
|
+
* handoff already existed, so `--timeout -5` put a live question on someone's
|
|
576
|
+
* phone and then exited 2, orphaning it until its TTL.
|
|
577
|
+
*/
|
|
578
|
+
function resolveWaitHold(args, { def, cap }) {
|
|
579
|
+
if (args.timeout === undefined) return Math.min(def, cap);
|
|
580
|
+
const hold = Number(args.timeout);
|
|
581
|
+
if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
|
|
582
|
+
return Math.min(hold, cap);
|
|
583
|
+
}
|
|
584
|
+
|
|
465
585
|
function stripControlChars(value) {
|
|
466
586
|
// eslint-disable-next-line no-control-regex
|
|
467
587
|
return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
468
588
|
}
|
|
469
589
|
|
|
590
|
+
// --- argument parsing -------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Build an argv parser from a flag table. Every command parser runs the same
|
|
594
|
+
* loop; only the tables differ:
|
|
595
|
+
* aliases flag or alias -> canonical args key
|
|
596
|
+
* booleans keys that take no value
|
|
597
|
+
* repeatable keys collected into an array (the flag may repeat)
|
|
598
|
+
* bareDashIsPositional whether a lone `-` collects into `_` (the question-
|
|
599
|
+
* style parsers) or fails as an unknown option (ping,
|
|
600
|
+
* live)
|
|
601
|
+
* Unknown flags always fail as a usage error; bare words collect into `_`.
|
|
602
|
+
*/
|
|
603
|
+
function makeParser({ aliases, booleans, repeatable = [], bareDashIsPositional = false }) {
|
|
604
|
+
const booleanKeys = new Set(booleans);
|
|
605
|
+
const repeatableKeys = new Set(repeatable);
|
|
606
|
+
return function parse(argv) {
|
|
607
|
+
const args = { _: [] };
|
|
608
|
+
for (let i = 0; i < argv.length; i++) {
|
|
609
|
+
const token = argv[i];
|
|
610
|
+
// Object.hasOwn, not aliases[token]: a bare lookup walks the prototype
|
|
611
|
+
// chain, so `constructor` / `toString` / `__proto__` in flag position
|
|
612
|
+
// resolve to a truthy inherited value, get treated as an option, and
|
|
613
|
+
// swallow the next argument instead of failing as an unknown flag.
|
|
614
|
+
const key = Object.hasOwn(aliases, token) ? aliases[token] : undefined;
|
|
615
|
+
if (key && booleanKeys.has(key)) {
|
|
616
|
+
args[key] = true;
|
|
617
|
+
} else if (key) {
|
|
618
|
+
const value = argv[++i];
|
|
619
|
+
if (value === undefined) {
|
|
620
|
+
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
621
|
+
}
|
|
622
|
+
if (repeatableKeys.has(key)) (args[key] ||= []).push(value);
|
|
623
|
+
else args[key] = value;
|
|
624
|
+
} else if (token.startsWith('-') && !(bareDashIsPositional && token === '-')) {
|
|
625
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
626
|
+
} else {
|
|
627
|
+
args._.push(token);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return args;
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
470
634
|
// --- ping (unchanged wire behaviour) ---------------------------------------
|
|
471
635
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const alias = {
|
|
636
|
+
const parseArgs = makeParser({
|
|
637
|
+
aliases: {
|
|
475
638
|
'-m': 'message', '--message': 'message',
|
|
476
639
|
'-t': 'title', '--title': 'title',
|
|
477
640
|
'-a': 'action', '--action': 'action',
|
|
@@ -487,40 +650,15 @@ function parseArgs(argv) {
|
|
|
487
650
|
'--api': 'api',
|
|
488
651
|
'--json': 'json',
|
|
489
652
|
'-h': 'help', '--help': 'help',
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
for (let i = 0; i < argv.length; i++) {
|
|
495
|
-
const token = argv[i];
|
|
496
|
-
// Object.hasOwn, not alias[token]: a bare lookup walks the prototype chain,
|
|
497
|
-
// so `constructor` / `toString` / `__proto__` in flag position resolve to a
|
|
498
|
-
// truthy inherited value, get treated as an option, and swallow the next
|
|
499
|
-
// argument instead of failing as an unknown flag.
|
|
500
|
-
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
501
|
-
if (key && booleans.has(key)) {
|
|
502
|
-
args[key] = true;
|
|
503
|
-
} else if (key) {
|
|
504
|
-
const value = argv[++i];
|
|
505
|
-
if (value === undefined) {
|
|
506
|
-
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
507
|
-
}
|
|
508
|
-
if (repeatable.has(key)) (args[key] ||= []).push(value);
|
|
509
|
-
else args[key] = value;
|
|
510
|
-
} else if (token.startsWith('-')) {
|
|
511
|
-
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
512
|
-
} else {
|
|
513
|
-
args._.push(token);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
return args;
|
|
517
|
-
}
|
|
653
|
+
},
|
|
654
|
+
booleans: ['require_ack', 'json', 'help'],
|
|
655
|
+
repeatable: ['attach'],
|
|
656
|
+
});
|
|
518
657
|
|
|
519
658
|
// Parser for the question commands: supports repeatable --option and a trailing
|
|
520
659
|
// positional (a question id). Unknown flags fail like the ping parser.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const alias = {
|
|
660
|
+
const parseQArgs = makeParser({
|
|
661
|
+
aliases: {
|
|
524
662
|
'-p': 'prompt', '--prompt': 'prompt',
|
|
525
663
|
'-o': 'option', '--option': 'option',
|
|
526
664
|
'-c': 'context', '--context': 'context',
|
|
@@ -534,46 +672,25 @@ function parseQArgs(argv) {
|
|
|
534
672
|
'--text-max': 'text_max',
|
|
535
673
|
'--timeout': 'timeout',
|
|
536
674
|
'--state': 'state',
|
|
675
|
+
'--limit': 'limit',
|
|
676
|
+
'--from': 'from',
|
|
677
|
+
'--once': 'once',
|
|
537
678
|
'--token': 'token',
|
|
538
679
|
'--room': 'room',
|
|
539
680
|
'--api': 'api',
|
|
540
681
|
'--wait': 'wait',
|
|
541
682
|
'--json': 'json',
|
|
542
683
|
'-h': 'help', '--help': 'help',
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const token = argv[i];
|
|
549
|
-
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
550
|
-
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
551
|
-
if (key && booleans.has(key)) {
|
|
552
|
-
args[key] = true;
|
|
553
|
-
} else if (key) {
|
|
554
|
-
const value = argv[++i];
|
|
555
|
-
if (value === undefined) {
|
|
556
|
-
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
557
|
-
}
|
|
558
|
-
if (multi.has(key)) {
|
|
559
|
-
(args[key] ||= []).push(value);
|
|
560
|
-
} else {
|
|
561
|
-
args[key] = value;
|
|
562
|
-
}
|
|
563
|
-
} else if (token.startsWith('-') && token !== '-') {
|
|
564
|
-
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
565
|
-
} else {
|
|
566
|
-
args._.push(token);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
return args;
|
|
570
|
-
}
|
|
684
|
+
},
|
|
685
|
+
booleans: ['wait', 'json', 'help', 'once'],
|
|
686
|
+
repeatable: ['option'],
|
|
687
|
+
bareDashIsPositional: true,
|
|
688
|
+
});
|
|
571
689
|
|
|
572
690
|
// Parser for `handoff`: --message plus repeatable --option, boolean --question,
|
|
573
691
|
// and the handoff-specific flags. Unknown flags fail like the other parsers.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const alias = {
|
|
692
|
+
const parseHandoffArgs = makeParser({
|
|
693
|
+
aliases: {
|
|
577
694
|
'-m': 'message', '--message': 'message',
|
|
578
695
|
'--question': 'question',
|
|
579
696
|
'-o': 'option', '--option': 'option',
|
|
@@ -591,34 +708,11 @@ function parseHandoffArgs(argv) {
|
|
|
591
708
|
'--wait': 'wait',
|
|
592
709
|
'--json': 'json',
|
|
593
710
|
'-h': 'help', '--help': 'help',
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const token = argv[i];
|
|
600
|
-
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
601
|
-
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
602
|
-
if (key && booleans.has(key)) {
|
|
603
|
-
args[key] = true;
|
|
604
|
-
} else if (key) {
|
|
605
|
-
const value = argv[++i];
|
|
606
|
-
if (value === undefined) {
|
|
607
|
-
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
608
|
-
}
|
|
609
|
-
if (multi.has(key)) {
|
|
610
|
-
(args[key] ||= []).push(value);
|
|
611
|
-
} else {
|
|
612
|
-
args[key] = value;
|
|
613
|
-
}
|
|
614
|
-
} else if (token.startsWith('-') && token !== '-') {
|
|
615
|
-
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
616
|
-
} else {
|
|
617
|
-
args._.push(token);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
return args;
|
|
621
|
-
}
|
|
711
|
+
},
|
|
712
|
+
booleans: ['question', 'wait', 'json', 'help'],
|
|
713
|
+
repeatable: ['option'],
|
|
714
|
+
bareDashIsPositional: true,
|
|
715
|
+
});
|
|
622
716
|
|
|
623
717
|
// True when a URL is safe to attach a bearer token or webhook secret to: https,
|
|
624
718
|
// or http on loopback so local dev against http://localhost still works.
|
|
@@ -772,7 +866,7 @@ async function uploadAttachments(paths, apiBase, token) {
|
|
|
772
866
|
fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
|
|
773
867
|
}
|
|
774
868
|
if (!res.ok || !json?.attachment?.id) {
|
|
775
|
-
const detail =
|
|
869
|
+
const detail = apiDetail(res, json);
|
|
776
870
|
fail(`upload failed for ${name}: ${detail}`);
|
|
777
871
|
}
|
|
778
872
|
|
|
@@ -783,10 +877,12 @@ async function uploadAttachments(paths, apiBase, token) {
|
|
|
783
877
|
}
|
|
784
878
|
|
|
785
879
|
async function ping(args) {
|
|
786
|
-
if (args.help) { process.stdout.write(`${
|
|
880
|
+
if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
|
|
787
881
|
|
|
788
882
|
const message = args.message;
|
|
789
883
|
if (!message) fail('a --message is required', EXIT.USAGE);
|
|
884
|
+
requireMaxLength(message, 500, '--message');
|
|
885
|
+
requireMaxLength(args.title, 40, '--title');
|
|
790
886
|
|
|
791
887
|
if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
|
|
792
888
|
fail('--action must be an integer 1–4', EXIT.USAGE);
|
|
@@ -890,7 +986,7 @@ async function ping(args) {
|
|
|
890
986
|
const ok = res.ok && !(json && json.success === false);
|
|
891
987
|
|
|
892
988
|
if (!ok) {
|
|
893
|
-
const detail = (
|
|
989
|
+
const detail = apiDetail(res, json);
|
|
894
990
|
fail(`delivery failed: ${detail}`);
|
|
895
991
|
}
|
|
896
992
|
|
|
@@ -905,11 +1001,30 @@ async function ping(args) {
|
|
|
905
1001
|
// --template line in HELP and with LIVE_ACTIVITY_TEMPLATES.md.
|
|
906
1002
|
const LIVE_TEMPLATES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'question', 'matchup'];
|
|
907
1003
|
|
|
1004
|
+
/**
|
|
1005
|
+
* Names the API does not take, folded onto the wire id it does.
|
|
1006
|
+
*
|
|
1007
|
+
* The `question` template is labelled **Decision** everywhere a person sees it,
|
|
1008
|
+
* so it is never confused with PingRoom's first-class Question protocol — that
|
|
1009
|
+
* one is answered through `pingroom ask`, carries a real Question id, and this
|
|
1010
|
+
* template does not. The wire id stayed `question`, so someone who reads
|
|
1011
|
+
* "Decision" in the app and types it would otherwise get a usage error for
|
|
1012
|
+
* using the only name they have been shown.
|
|
1013
|
+
*/
|
|
1014
|
+
const LIVE_TEMPLATE_ALIASES = { decision: 'question' };
|
|
1015
|
+
|
|
1016
|
+
/** The wire id for a template name a human typed, or the name unchanged. */
|
|
1017
|
+
function canonicalTemplate(name) {
|
|
1018
|
+
return LIVE_TEMPLATE_ALIASES[name] ?? name;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** What we offer in help and errors: the alias leads, since it is what the app shows. */
|
|
1022
|
+
const LIVE_TEMPLATE_NAMES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'decision', 'matchup'];
|
|
1023
|
+
|
|
908
1024
|
// Parser for `live`: a leading subcommand (start|update|end|get) plus the
|
|
909
1025
|
// live-status flags. Unknown flags fail like the other parsers.
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const alias = {
|
|
1026
|
+
const parseLiveArgs = makeParser({
|
|
1027
|
+
aliases: {
|
|
913
1028
|
'-c': 'correlation_id', '--correlation-id': 'correlation_id',
|
|
914
1029
|
'-t': 'title', '--title': 'title',
|
|
915
1030
|
'-m': 'message', '--message': 'message',
|
|
@@ -938,29 +1053,10 @@ function parseLiveArgs(argv) {
|
|
|
938
1053
|
'--api': 'api',
|
|
939
1054
|
'--json': 'json',
|
|
940
1055
|
'-h': 'help', '--help': 'help',
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
for (let i = 0; i < argv.length; i++) {
|
|
946
|
-
const token = argv[i];
|
|
947
|
-
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
948
|
-
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
949
|
-
if (key && booleans.has(key)) {
|
|
950
|
-
args[key] = true;
|
|
951
|
-
} else if (key) {
|
|
952
|
-
const value = argv[++i];
|
|
953
|
-
if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
954
|
-
if (repeatable.has(key)) (args[key] ||= []).push(value);
|
|
955
|
-
else args[key] = value;
|
|
956
|
-
} else if (token.startsWith('-')) {
|
|
957
|
-
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
958
|
-
} else {
|
|
959
|
-
args._.push(token);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
return args;
|
|
963
|
-
}
|
|
1056
|
+
},
|
|
1057
|
+
booleans: ['require_ack', 'json', 'help', 'failed'],
|
|
1058
|
+
repeatable: ['metric', 'option'],
|
|
1059
|
+
});
|
|
964
1060
|
|
|
965
1061
|
// "label:value" -> {label, value}. Only the first colon splits.
|
|
966
1062
|
function buildMetrics(list) {
|
|
@@ -1022,6 +1118,7 @@ function numberOption(raw, flag, { min, max, integer = false } = {}) {
|
|
|
1022
1118
|
* (--webhook), which speak the same `live_status` contract.
|
|
1023
1119
|
*/
|
|
1024
1120
|
async function live(args) {
|
|
1121
|
+
if (args.help) { process.stdout.write(`${commandHelp('live')}\n`); return EXIT.OK; }
|
|
1025
1122
|
const sub = args._[0];
|
|
1026
1123
|
const known = ['start', 'update', 'end', 'get'];
|
|
1027
1124
|
if (!sub || !known.includes(sub)) {
|
|
@@ -1045,7 +1142,7 @@ async function live(args) {
|
|
|
1045
1142
|
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1046
1143
|
if (args.json) process.stdout.write(`${text || '{}'}\n`);
|
|
1047
1144
|
if (!res.ok) {
|
|
1048
|
-
fail(`read failed: ${(
|
|
1145
|
+
fail(`read failed: ${apiDetail(res, json)}`);
|
|
1049
1146
|
}
|
|
1050
1147
|
if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
|
|
1051
1148
|
return EXIT.OK;
|
|
@@ -1055,6 +1152,11 @@ async function live(args) {
|
|
|
1055
1152
|
state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
|
|
1056
1153
|
};
|
|
1057
1154
|
|
|
1155
|
+
// 256, not the 500 a ping body gets: this is the card's one live line.
|
|
1156
|
+
requireMaxLength(args.message, 256, '--message');
|
|
1157
|
+
requireMaxLength(args.title, 40, '--title');
|
|
1158
|
+
requireMaxLength(args.prompt, 256, '--prompt');
|
|
1159
|
+
requireMaxLength(args.center, 40, '--center');
|
|
1058
1160
|
if (args.message !== undefined) liveStatus.message = args.message;
|
|
1059
1161
|
if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
|
|
1060
1162
|
|
|
@@ -1095,10 +1197,11 @@ async function live(args) {
|
|
|
1095
1197
|
// usage error, and letting it reach the server turns it into a 422 round
|
|
1096
1198
|
// trip that reads like an outage.
|
|
1097
1199
|
if (args.template) {
|
|
1098
|
-
|
|
1099
|
-
|
|
1200
|
+
const template = canonicalTemplate(args.template);
|
|
1201
|
+
if (!LIVE_TEMPLATES.includes(template)) {
|
|
1202
|
+
fail(`--template must be one of: ${LIVE_TEMPLATE_NAMES.join(', ')}`, EXIT.USAGE);
|
|
1100
1203
|
}
|
|
1101
|
-
liveStatus.template =
|
|
1204
|
+
liveStatus.template = template;
|
|
1102
1205
|
}
|
|
1103
1206
|
// `alert` has no template equivalent and is the only way to start a stream
|
|
1104
1207
|
// time-sensitive (breaking through Focus) without also demanding an ack.
|
|
@@ -1151,7 +1254,7 @@ async function live(args) {
|
|
|
1151
1254
|
if (args.json) process.stdout.write(`${text || '{}'}\n`);
|
|
1152
1255
|
|
|
1153
1256
|
if (!res.ok || (json && json.success === false)) {
|
|
1154
|
-
const detail = (
|
|
1257
|
+
const detail = apiDetail(res, json);
|
|
1155
1258
|
fail(`live ${sub} failed: ${detail}`);
|
|
1156
1259
|
}
|
|
1157
1260
|
|
|
@@ -1234,15 +1337,14 @@ function printResolution(q) {
|
|
|
1234
1337
|
// and return the state's exit code. The server expires it at its ttl, so this
|
|
1235
1338
|
// always terminates.
|
|
1236
1339
|
async function waitForResolution(id, args, { token, apiBase }) {
|
|
1237
|
-
|
|
1238
|
-
if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
|
|
1239
|
-
hold = Math.min(hold, 30);
|
|
1340
|
+
const hold = resolveWaitHold(args, { def: 25, cap: 30 });
|
|
1240
1341
|
|
|
1241
1342
|
for (;;) {
|
|
1343
|
+
const started = Date.now();
|
|
1242
1344
|
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
1243
1345
|
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1244
1346
|
if (!res.ok) {
|
|
1245
|
-
const detail = (
|
|
1347
|
+
const detail = apiDetail(res, json);
|
|
1246
1348
|
fail(`wait failed: ${detail}`);
|
|
1247
1349
|
}
|
|
1248
1350
|
if (json && json.state && json.state !== 'pending') {
|
|
@@ -1250,15 +1352,21 @@ async function waitForResolution(id, args, { token, apiBase }) {
|
|
|
1250
1352
|
else printResolution(json);
|
|
1251
1353
|
return exitForState(json.state);
|
|
1252
1354
|
}
|
|
1253
|
-
// Still pending at the hold timeout — poll again
|
|
1355
|
+
// Still pending at the hold timeout — poll again, but never hot-loop: a
|
|
1356
|
+
// misbehaving server that answers `pending` instantly (ignoring the hold)
|
|
1357
|
+
// would otherwise be hammered at full speed.
|
|
1358
|
+
const elapsed = Date.now() - started;
|
|
1359
|
+
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
1254
1360
|
}
|
|
1255
1361
|
}
|
|
1256
1362
|
|
|
1257
1363
|
async function ask(args) {
|
|
1258
|
-
if (args.help) { process.stdout.write(`${
|
|
1364
|
+
if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
|
|
1259
1365
|
|
|
1260
1366
|
const prompt = args.prompt;
|
|
1261
1367
|
if (!prompt) fail('a --prompt is required', EXIT.USAGE);
|
|
1368
|
+
requireMaxLength(prompt, 500, '--prompt');
|
|
1369
|
+
requireMaxLength(args.context, 40, '--context');
|
|
1262
1370
|
|
|
1263
1371
|
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
1264
1372
|
|
|
@@ -1291,10 +1399,13 @@ async function ask(args) {
|
|
|
1291
1399
|
}
|
|
1292
1400
|
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
1293
1401
|
|
|
1402
|
+
// Pre-flight: reject a bad --timeout before the question exists.
|
|
1403
|
+
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
|
1404
|
+
|
|
1294
1405
|
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
|
|
1295
1406
|
const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
1296
1407
|
if (!res.ok) {
|
|
1297
|
-
const detail = (
|
|
1408
|
+
const detail = apiDetail(res, json);
|
|
1298
1409
|
fail(`ask failed: ${detail}`);
|
|
1299
1410
|
}
|
|
1300
1411
|
|
|
@@ -1308,7 +1419,7 @@ async function ask(args) {
|
|
|
1308
1419
|
}
|
|
1309
1420
|
|
|
1310
1421
|
async function watch(args) {
|
|
1311
|
-
if (args.help) { process.stdout.write(`${
|
|
1422
|
+
if (args.help) { process.stdout.write(`${commandHelp('watch')}\n`); return EXIT.OK; }
|
|
1312
1423
|
const id = args._[0];
|
|
1313
1424
|
if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
|
|
1314
1425
|
const { token, apiBase } = agentContext(args);
|
|
@@ -1316,14 +1427,14 @@ async function watch(args) {
|
|
|
1316
1427
|
}
|
|
1317
1428
|
|
|
1318
1429
|
async function cancel(args) {
|
|
1319
|
-
if (args.help) { process.stdout.write(`${
|
|
1430
|
+
if (args.help) { process.stdout.write(`${commandHelp('cancel')}\n`); return EXIT.OK; }
|
|
1320
1431
|
const id = args._[0];
|
|
1321
1432
|
if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
|
|
1322
1433
|
const { token, apiBase } = agentContext(args);
|
|
1323
1434
|
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
|
|
1324
1435
|
const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
|
|
1325
1436
|
if (!res.ok) {
|
|
1326
|
-
const detail = (
|
|
1437
|
+
const detail = apiDetail(res, json);
|
|
1327
1438
|
fail(`cancel failed: ${detail}`);
|
|
1328
1439
|
}
|
|
1329
1440
|
if (args.json) process.stdout.write(`${text}\n`);
|
|
@@ -1332,13 +1443,13 @@ async function cancel(args) {
|
|
|
1332
1443
|
}
|
|
1333
1444
|
|
|
1334
1445
|
async function list(args) {
|
|
1335
|
-
if (args.help) { process.stdout.write(`${
|
|
1446
|
+
if (args.help) { process.stdout.write(`${commandHelp('list')}\n`); return EXIT.OK; }
|
|
1336
1447
|
const { token, apiBase } = agentContext(args);
|
|
1337
1448
|
const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
|
|
1338
1449
|
const url = `${apiBase}/api/agent/questions${qs}`;
|
|
1339
1450
|
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1340
1451
|
if (!res.ok) {
|
|
1341
|
-
const detail = (
|
|
1452
|
+
const detail = apiDetail(res, json);
|
|
1342
1453
|
fail(`list failed: ${detail}`);
|
|
1343
1454
|
}
|
|
1344
1455
|
if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
|
|
@@ -1352,8 +1463,100 @@ async function list(args) {
|
|
|
1352
1463
|
return EXIT.OK;
|
|
1353
1464
|
}
|
|
1354
1465
|
|
|
1466
|
+
// --- listen ----------------------------------------------------------------
|
|
1467
|
+
//
|
|
1468
|
+
// The inbound half. Everything else here talks; this is how an agent hears —
|
|
1469
|
+
// replies to its own structured pings, a human's ping in a room it belongs to,
|
|
1470
|
+
// anything landing while it works.
|
|
1471
|
+
//
|
|
1472
|
+
// The server holds each request open until something arrives or the timeout
|
|
1473
|
+
// elapses, so this is a long-poll, not a poll loop: an idle hour costs ~144
|
|
1474
|
+
// requests, not one per second.
|
|
1475
|
+
|
|
1476
|
+
/** Cursor bookkeeping is the whole protocol: `after` in, `cursor` back. */
|
|
1477
|
+
async function listen(args) {
|
|
1478
|
+
if (args.help) { process.stdout.write(`${commandHelp('listen')}\n`); return EXIT.OK; }
|
|
1479
|
+
|
|
1480
|
+
const { token, apiBase } = agentContext(args);
|
|
1481
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
1482
|
+
|
|
1483
|
+
const timeout = numberOption(args.timeout, '--timeout', { min: 0, max: 30, integer: true }) ?? 25;
|
|
1484
|
+
const limit = numberOption(args.limit, '--limit', { min: 1, max: 100, integer: true }) ?? 50;
|
|
1485
|
+
|
|
1486
|
+
// No cursor means "from now": the server answers an empty `after` with the
|
|
1487
|
+
// head id and no rows, so starting up never replays history the agent has
|
|
1488
|
+
// already seen. `--from` opts into catching up from a known id instead.
|
|
1489
|
+
let cursor = args.from;
|
|
1490
|
+
if (!cursor) {
|
|
1491
|
+
const { res, json } = await httpJson('GET', `${apiBase}/api/agent/notifications/wait`, {
|
|
1492
|
+
headers,
|
|
1493
|
+
soft: true,
|
|
1494
|
+
});
|
|
1495
|
+
if (!res?.ok) fail(`listen failed: ${apiDetail(res, json)}`);
|
|
1496
|
+
cursor = json && json.cursor;
|
|
1497
|
+
if (!cursor) {
|
|
1498
|
+
// A brand-new account with no pings at all has no head id. Nothing is
|
|
1499
|
+
// wrong; there is simply nothing to be after yet.
|
|
1500
|
+
cursor = '';
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
let transientRun = 0;
|
|
1505
|
+
|
|
1506
|
+
for (;;) {
|
|
1507
|
+
const query = new URLSearchParams({ timeout: String(timeout), limit: String(limit) });
|
|
1508
|
+
if (cursor) query.set('after', cursor);
|
|
1509
|
+
|
|
1510
|
+
const { res, json, error } = await httpJson(
|
|
1511
|
+
'GET',
|
|
1512
|
+
`${apiBase}/api/agent/notifications/wait?${query}`,
|
|
1513
|
+
// The hold plus headroom: aborting at exactly the server's deadline would
|
|
1514
|
+
// race it and turn every quiet window into a client-side error.
|
|
1515
|
+
{ headers, soft: true, signal: AbortSignal.timeout((timeout + 10) * 1000) },
|
|
1516
|
+
);
|
|
1517
|
+
|
|
1518
|
+
if (error || res.status === 429 || res.status >= 500) {
|
|
1519
|
+
transientRun += 1;
|
|
1520
|
+
const retryAfter = res?.status === 429 ? retryAfterMs(res) : null;
|
|
1521
|
+
// Geometric backoff so a real outage is not also a thundering herd. The
|
|
1522
|
+
// loop is unbounded by design — `listen` is a daemon, not a request.
|
|
1523
|
+
const backoff = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 30_000);
|
|
1524
|
+
await sleep(Math.max(0, retryAfter ?? backoff));
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (!res.ok) fail(`listen failed: ${apiDetail(res, json)}`);
|
|
1529
|
+
transientRun = 0;
|
|
1530
|
+
|
|
1531
|
+
const batch = Array.isArray(json?.notifications) ? json.notifications : [];
|
|
1532
|
+
for (const item of batch) {
|
|
1533
|
+
process.stdout.write(args.json ? `${JSON.stringify(item)}\n` : `${formatIncoming(item)}\n`);
|
|
1534
|
+
}
|
|
1535
|
+
// Advance only on a cursor the server actually returned, or a batch could be
|
|
1536
|
+
// replayed forever against a stale `after`.
|
|
1537
|
+
if (json && typeof json.cursor === 'string' && json.cursor) cursor = json.cursor;
|
|
1538
|
+
|
|
1539
|
+
if (args.once) return EXIT.OK;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** One readable line per incoming ping. */
|
|
1544
|
+
function formatIncoming(item) {
|
|
1545
|
+
const room = item?.room?.name || item?.room?.code || '?';
|
|
1546
|
+
const body = stripControlChars(item?.message ?? '');
|
|
1547
|
+
const marks = [];
|
|
1548
|
+
if (item?.correlation_id) marks.push(`corr=${stripControlChars(item.correlation_id)}`);
|
|
1549
|
+
if (item?.reply_to) marks.push(`reply_to=${stripControlChars(item.reply_to)}`);
|
|
1550
|
+
if (item?.question) marks.push('question');
|
|
1551
|
+
if (Array.isArray(item?.attachments) && item.attachments.length) {
|
|
1552
|
+
marks.push(`${item.attachments.length} attachment${item.attachments.length === 1 ? '' : 's'}`);
|
|
1553
|
+
}
|
|
1554
|
+
const suffix = marks.length ? ` (${marks.join(' · ')})` : '';
|
|
1555
|
+
return `[${stripControlChars(room)}] ${body}${suffix}`;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1355
1558
|
async function listHandoffs(args) {
|
|
1356
|
-
if (args.help) { process.stdout.write(`${
|
|
1559
|
+
if (args.help) { process.stdout.write(`${commandHelp('handoffs')}\n`); return EXIT.OK; }
|
|
1357
1560
|
const { token, apiBase } = agentContext(args);
|
|
1358
1561
|
const state = args.state || 'open';
|
|
1359
1562
|
if (state !== 'open' && state !== 'all') {
|
|
@@ -1363,7 +1566,7 @@ async function listHandoffs(args) {
|
|
|
1363
1566
|
const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
|
|
1364
1567
|
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1365
1568
|
if (!res.ok) {
|
|
1366
|
-
const detail = (
|
|
1569
|
+
const detail = apiDetail(res, json);
|
|
1367
1570
|
fail(`handoffs list failed: ${detail}`);
|
|
1368
1571
|
}
|
|
1369
1572
|
if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
|
|
@@ -1471,15 +1674,14 @@ function writeGitHubHandoffOutputs(path, h) {
|
|
|
1471
1674
|
// Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
|
|
1472
1675
|
// print it and return the state's exit code. Reuses the shared bounded hold.
|
|
1473
1676
|
async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
|
|
1474
|
-
|
|
1475
|
-
if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
|
|
1476
|
-
hold = Math.min(hold, 25);
|
|
1677
|
+
const hold = resolveWaitHold(args, { def: 20, cap: 25 });
|
|
1477
1678
|
|
|
1478
1679
|
for (;;) {
|
|
1680
|
+
const started = Date.now();
|
|
1479
1681
|
const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
1480
1682
|
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1481
1683
|
if (!res.ok) {
|
|
1482
|
-
const detail = (
|
|
1684
|
+
const detail = apiDetail(res, json);
|
|
1483
1685
|
fail(`wait failed: ${detail}`);
|
|
1484
1686
|
}
|
|
1485
1687
|
if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
|
|
@@ -1494,15 +1696,19 @@ async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState
|
|
|
1494
1696
|
else printHandoff(resolved);
|
|
1495
1697
|
return exitForHandoffState(resolved.state);
|
|
1496
1698
|
}
|
|
1497
|
-
// Still open/pending at the hold timeout — poll again
|
|
1699
|
+
// Still open/pending at the hold timeout — poll again, with the same
|
|
1700
|
+
// hot-loop floor as waitForResolution.
|
|
1701
|
+
const elapsed = Date.now() - started;
|
|
1702
|
+
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
1498
1703
|
}
|
|
1499
1704
|
}
|
|
1500
1705
|
|
|
1501
1706
|
async function handoff(args) {
|
|
1502
|
-
if (args.help) { process.stdout.write(`${
|
|
1707
|
+
if (args.help) { process.stdout.write(`${commandHelp('handoff')}\n`); return EXIT.OK; }
|
|
1503
1708
|
|
|
1504
1709
|
const message = args.message;
|
|
1505
1710
|
if (!message) fail('a --message is required', EXIT.USAGE);
|
|
1711
|
+
requireMaxLength(message, 500, '--message');
|
|
1506
1712
|
|
|
1507
1713
|
const { token, apiBase } = agentContext(args);
|
|
1508
1714
|
|
|
@@ -1548,11 +1754,14 @@ async function handoff(args) {
|
|
|
1548
1754
|
headers['Idempotency-Key'] = args.idempotency_key;
|
|
1549
1755
|
}
|
|
1550
1756
|
|
|
1757
|
+
// Pre-flight: reject a bad --timeout before the handoff exists.
|
|
1758
|
+
if (args.wait) resolveWaitHold(args, { def: 20, cap: 25 });
|
|
1759
|
+
|
|
1551
1760
|
const url = `${apiBase}/api/agent/handoffs`;
|
|
1552
1761
|
const { res, text, json } = await httpJson('POST', url, { body, headers });
|
|
1553
1762
|
if (!res.ok) {
|
|
1554
1763
|
const code = json && json.code;
|
|
1555
|
-
const detail = (
|
|
1764
|
+
const detail = apiDetail(res, json);
|
|
1556
1765
|
// A recipient who isn't reachable yet is a distinct, retriable outcome (4),
|
|
1557
1766
|
// not a generic error — CI may want to wait and retry rather than fail hard.
|
|
1558
1767
|
if (res.status === 409 && code === 'recipient_not_ready') {
|
|
@@ -1587,9 +1796,8 @@ async function handoff(args) {
|
|
|
1587
1796
|
// normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
|
|
1588
1797
|
// must not call fail() (a non-zero exit — 2 especially — would break the run).
|
|
1589
1798
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
const alias = {
|
|
1799
|
+
const parseHookArgs = makeParser({
|
|
1800
|
+
aliases: {
|
|
1593
1801
|
'--room': 'room',
|
|
1594
1802
|
'--ttl': 'ttl',
|
|
1595
1803
|
'--quiet': 'quiet',
|
|
@@ -1598,27 +1806,10 @@ function parseHookArgs(argv) {
|
|
|
1598
1806
|
'--api': 'api',
|
|
1599
1807
|
'--json': 'json',
|
|
1600
1808
|
'-h': 'help', '--help': 'help',
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
const token = argv[i];
|
|
1606
|
-
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
1607
|
-
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
1608
|
-
if (key && booleans.has(key)) {
|
|
1609
|
-
args[key] = true;
|
|
1610
|
-
} else if (key) {
|
|
1611
|
-
const value = argv[++i];
|
|
1612
|
-
if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
1613
|
-
args[key] = value;
|
|
1614
|
-
} else if (token.startsWith('-') && token !== '-') {
|
|
1615
|
-
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
1616
|
-
} else {
|
|
1617
|
-
args._.push(token);
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
return args;
|
|
1621
|
-
}
|
|
1809
|
+
},
|
|
1810
|
+
booleans: ['quiet', 'print_config', 'json', 'help'],
|
|
1811
|
+
bareDashIsPositional: true,
|
|
1812
|
+
});
|
|
1622
1813
|
|
|
1623
1814
|
// Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
|
|
1624
1815
|
// stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
|
|
@@ -1655,7 +1846,7 @@ async function hookFetch(method, url, { body, token } = {}) {
|
|
|
1655
1846
|
let json = null;
|
|
1656
1847
|
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
1657
1848
|
if (!res.ok) {
|
|
1658
|
-
throw new Error((
|
|
1849
|
+
throw new Error(apiDetail(res, json));
|
|
1659
1850
|
}
|
|
1660
1851
|
return json;
|
|
1661
1852
|
}
|
|
@@ -1880,7 +2071,7 @@ ${JSON.stringify(config, null, 2)}
|
|
|
1880
2071
|
}
|
|
1881
2072
|
|
|
1882
2073
|
async function hook(args) {
|
|
1883
|
-
if (args.help) { process.stdout.write(`${
|
|
2074
|
+
if (args.help) { process.stdout.write(`${commandHelp('hook')}\n`); return EXIT.OK; }
|
|
1884
2075
|
if (args.print_config) { printHookConfig(); return EXIT.OK; }
|
|
1885
2076
|
|
|
1886
2077
|
let event = {};
|
|
@@ -1984,12 +2175,16 @@ This command only prints setup instructions and does not modify client config.
|
|
|
1984
2175
|
const CLI_SCOPES = [
|
|
1985
2176
|
'pingroom:rooms:read', // resolve/display the connected room
|
|
1986
2177
|
'pingroom:broadcast:send', // ping
|
|
2178
|
+
'pingroom:attachments:write', // ping --attach (the upload leg)
|
|
2179
|
+
'pingroom:notifications:read',// listen
|
|
1987
2180
|
'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
|
|
1988
2181
|
'pingroom:handoffs:create', // handoff / handoffs
|
|
1989
2182
|
'pingroom:live:write', // live start/update/end/get
|
|
1990
2183
|
];
|
|
1991
2184
|
|
|
1992
|
-
|
|
2185
|
+
// What the human reads on the approval screen. A product name, not a package
|
|
2186
|
+
// id: the phone shows it verbatim ("PingRoom CLI wants to connect").
|
|
2187
|
+
const AGENT_LABEL = 'PingRoom CLI';
|
|
1993
2188
|
// A connect command should prove the phone round-trip, but it must not hold a
|
|
1994
2189
|
// terminal for the onboarding Question's full 24-hour server TTL. The Question
|
|
1995
2190
|
// remains answerable after this local deadline and the credential is already
|
|
@@ -2117,19 +2312,24 @@ async function registerAnonymous(apiBase) {
|
|
|
2117
2312
|
body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
|
|
2118
2313
|
});
|
|
2119
2314
|
if (!res.ok || !json || typeof json.credential !== 'string') {
|
|
2120
|
-
const detail = (
|
|
2315
|
+
const detail = apiDetail(res, json);
|
|
2121
2316
|
fail(`could not start a connection: ${detail}`);
|
|
2122
2317
|
}
|
|
2123
2318
|
return json.credential;
|
|
2124
2319
|
}
|
|
2125
2320
|
|
|
2126
2321
|
/** Persist the active credential plus the bits the status line prints. */
|
|
2127
|
-
function saveCredential({ token, handle, room, account, scopes, apiBase }) {
|
|
2322
|
+
function saveCredential({ token, handle, room, rooms, roomAccess, account, scopes, apiBase }) {
|
|
2128
2323
|
writeJsonFile(credentialsPath(), {
|
|
2129
2324
|
version: 1,
|
|
2130
2325
|
token,
|
|
2131
2326
|
handle: handle || null,
|
|
2327
|
+
// `room` is the delivery room — where handoffs and questions land. `rooms`
|
|
2328
|
+
// is the whole grant, which can be wider; `room_access: "all"` means the
|
|
2329
|
+
// human granted every room they are in, listing none.
|
|
2132
2330
|
room: room || null,
|
|
2331
|
+
rooms: Array.isArray(rooms) ? rooms : [],
|
|
2332
|
+
room_access: roomAccess || null,
|
|
2133
2333
|
account: account || null,
|
|
2134
2334
|
scopes: scopes || [],
|
|
2135
2335
|
api_url: apiBase,
|
|
@@ -2137,11 +2337,22 @@ function saveCredential({ token, handle, room, account, scopes, apiBase }) {
|
|
|
2137
2337
|
});
|
|
2138
2338
|
}
|
|
2139
2339
|
|
|
2140
|
-
/**
|
|
2340
|
+
/**
|
|
2341
|
+
* "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown,
|
|
2342
|
+
* and widened to "→ all rooms" / "→ #Project X +2 more" when the human granted
|
|
2343
|
+
* this agent more than the one delivery room.
|
|
2344
|
+
*/
|
|
2141
2345
|
function connectedLine(cred) {
|
|
2142
2346
|
const who = cred.handle ? `@${cred.handle}` : 'this machine';
|
|
2143
2347
|
const room = cred.room && (cred.room.name || cred.room.invite_code);
|
|
2144
|
-
|
|
2348
|
+
const access = cred.room_access ?? cred.roomAccess;
|
|
2349
|
+
|
|
2350
|
+
if (access === 'all') return `✓ Connected as ${who} → all rooms`;
|
|
2351
|
+
|
|
2352
|
+
if (!room) return `✓ Connected as ${who}`;
|
|
2353
|
+
|
|
2354
|
+
const extra = Math.max(0, (Array.isArray(cred.rooms) ? cred.rooms.length : 0) - 1);
|
|
2355
|
+
return `✓ Connected as ${who} → #${room}${extra > 0 ? ` +${extra} more` : ''}`;
|
|
2145
2356
|
}
|
|
2146
2357
|
|
|
2147
2358
|
function activationFailureDetail(result) {
|
|
@@ -2376,7 +2587,7 @@ async function activateInboxAfterPairing(cred) {
|
|
|
2376
2587
|
|
|
2377
2588
|
/** Retry activation only for the durable credential created by QR pairing. */
|
|
2378
2589
|
async function activateStoredInbox(args) {
|
|
2379
|
-
if (args.help) { process.stdout.write(`${
|
|
2590
|
+
if (args.help) { process.stdout.write(`${commandHelp('activate')}\n`); return EXIT.OK; }
|
|
2380
2591
|
if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
|
|
2381
2592
|
if (args.token !== undefined) {
|
|
2382
2593
|
fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
|
|
@@ -2391,7 +2602,15 @@ async function activateStoredInbox(args) {
|
|
|
2391
2602
|
fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
|
|
2392
2603
|
}
|
|
2393
2604
|
if (!credential.room || !isNonEmptyString(credential.room.invite_code)) {
|
|
2394
|
-
|
|
2605
|
+
// Granting every room is a valid answer that pins no destination, so the
|
|
2606
|
+
// fix there is picking one — not pairing again, which would only offer the
|
|
2607
|
+
// same choice back.
|
|
2608
|
+
fail(
|
|
2609
|
+
credential.room_access === 'all'
|
|
2610
|
+
? 'this agent was granted all rooms but no delivery room; pick one in the PingRoom app under Connected Agents, then run "pingroom activate" again'
|
|
2611
|
+
: 'the saved credential has no QR-selected delivery room; reconnect with QR pairing before running "pingroom activate"',
|
|
2612
|
+
EXIT.USAGE,
|
|
2613
|
+
);
|
|
2395
2614
|
}
|
|
2396
2615
|
if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
|
|
2397
2616
|
fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
|
|
@@ -2499,7 +2718,7 @@ async function connectByPairing(apiBase, ask) {
|
|
|
2499
2718
|
|
|
2500
2719
|
if (!res.ok) {
|
|
2501
2720
|
process.stdout.write('\n');
|
|
2502
|
-
const detail = (
|
|
2721
|
+
const detail = apiDetail(res, json);
|
|
2503
2722
|
fail(`pairing failed: ${detail}`);
|
|
2504
2723
|
}
|
|
2505
2724
|
const status = json && json.status;
|
|
@@ -2516,13 +2735,19 @@ async function connectByPairing(apiBase, ask) {
|
|
|
2516
2735
|
token: json.credential,
|
|
2517
2736
|
handle: json.handle,
|
|
2518
2737
|
room: json.room,
|
|
2738
|
+
rooms: Array.isArray(json.rooms) ? json.rooms : [],
|
|
2739
|
+
roomAccess: typeof json.room_access === 'string' ? json.room_access : null,
|
|
2519
2740
|
account: json.account,
|
|
2520
2741
|
scopes: json.scopes,
|
|
2521
2742
|
apiBase,
|
|
2522
2743
|
};
|
|
2523
2744
|
saveCredential(cred);
|
|
2524
2745
|
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
2525
|
-
|
|
2746
|
+
// Connecting deliberately sends nothing to the human's phone. The
|
|
2747
|
+
// approval they just tapped IS the round-trip; a test Question on top of
|
|
2748
|
+
// it was one more thing to answer before the tool could be used, and it
|
|
2749
|
+
// made a healthy connection look broken whenever the answer was slow.
|
|
2750
|
+
// `pingroom activate` still sends one for anyone who wants the proof.
|
|
2526
2751
|
return cred;
|
|
2527
2752
|
}
|
|
2528
2753
|
if (status === 'expired') break;
|
|
@@ -2715,7 +2940,7 @@ const CONFIG_KEYS = {
|
|
|
2715
2940
|
};
|
|
2716
2941
|
|
|
2717
2942
|
async function config(args) {
|
|
2718
|
-
if (args.help) { process.stdout.write(`${
|
|
2943
|
+
if (args.help) { process.stdout.write(`${commandHelp('config')}\n`); return EXIT.OK; }
|
|
2719
2944
|
|
|
2720
2945
|
const sub = args._[0];
|
|
2721
2946
|
const known = ['list', 'get', 'set'];
|
|
@@ -2773,7 +2998,7 @@ async function config(args) {
|
|
|
2773
2998
|
// --- logout ----------------------------------------------------------------
|
|
2774
2999
|
|
|
2775
3000
|
async function logout(args) {
|
|
2776
|
-
if (args.help) { process.stdout.write(`${
|
|
3001
|
+
if (args.help) { process.stdout.write(`${commandHelp('logout')}\n`); return EXIT.OK; }
|
|
2777
3002
|
|
|
2778
3003
|
const path = credentialsPath();
|
|
2779
3004
|
const stored = readStoredCredential();
|
|
@@ -2795,6 +3020,33 @@ async function logout(args) {
|
|
|
2795
3020
|
return EXIT.OK;
|
|
2796
3021
|
}
|
|
2797
3022
|
|
|
3023
|
+
// config/logout/handoffs used to share parseQArgs, which silently accepted and
|
|
3024
|
+
// ignored flags those commands never read (`logout --wait --prompt x`). Minimal
|
|
3025
|
+
// tables instead, so an irrelevant flag is a usage error like everywhere else.
|
|
3026
|
+
const parseConfigArgs = makeParser({
|
|
3027
|
+
aliases: { '--json': 'json', '-h': 'help', '--help': 'help' },
|
|
3028
|
+
booleans: ['json', 'help'],
|
|
3029
|
+
bareDashIsPositional: true,
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
const parseLogoutArgs = makeParser({
|
|
3033
|
+
aliases: { '-h': 'help', '--help': 'help' },
|
|
3034
|
+
booleans: ['help'],
|
|
3035
|
+
bareDashIsPositional: true,
|
|
3036
|
+
});
|
|
3037
|
+
|
|
3038
|
+
const parseHandoffsArgs = makeParser({
|
|
3039
|
+
aliases: {
|
|
3040
|
+
'--state': 'state',
|
|
3041
|
+
'--token': 'token',
|
|
3042
|
+
'--api': 'api',
|
|
3043
|
+
'--json': 'json',
|
|
3044
|
+
'-h': 'help', '--help': 'help',
|
|
3045
|
+
},
|
|
3046
|
+
booleans: ['json', 'help'],
|
|
3047
|
+
bareDashIsPositional: true,
|
|
3048
|
+
});
|
|
3049
|
+
|
|
2798
3050
|
const COMMANDS = {
|
|
2799
3051
|
ping: (rest) => ping(parseArgs(rest)),
|
|
2800
3052
|
ask: (rest) => ask(parseQArgs(rest)),
|
|
@@ -2803,13 +3055,14 @@ const COMMANDS = {
|
|
|
2803
3055
|
cancel: (rest) => cancel(parseQArgs(rest)),
|
|
2804
3056
|
list: (rest) => list(parseQArgs(rest)),
|
|
2805
3057
|
handoff: (rest) => handoff(parseHandoffArgs(rest)),
|
|
2806
|
-
handoffs: (rest) => listHandoffs(
|
|
3058
|
+
handoffs: (rest) => listHandoffs(parseHandoffsArgs(rest)),
|
|
3059
|
+
listen: (rest) => listen(parseQArgs(rest)),
|
|
2807
3060
|
hook: (rest) => hook(parseHookArgs(rest)),
|
|
2808
3061
|
mcp,
|
|
2809
3062
|
activate: (rest) => activateStoredInbox(parseQArgs(rest)),
|
|
2810
3063
|
live: (rest) => live(parseLiveArgs(rest)),
|
|
2811
|
-
config: (rest) => config(
|
|
2812
|
-
logout: (rest) => logout(
|
|
3064
|
+
config: (rest) => config(parseConfigArgs(rest)),
|
|
3065
|
+
logout: (rest) => logout(parseLogoutArgs(rest)),
|
|
2813
3066
|
};
|
|
2814
3067
|
|
|
2815
3068
|
function waitFrom(handler, rest) {
|
|
@@ -2847,4 +3100,13 @@ async function main() {
|
|
|
2847
3100
|
process.exit(code);
|
|
2848
3101
|
}
|
|
2849
3102
|
|
|
2850
|
-
|
|
3103
|
+
// Anything that escapes a handler is a bug in this tool, not a usage error, but
|
|
3104
|
+
// the operator still gets one clean line instead of a Node stack trace — and the
|
|
3105
|
+
// same exit 1 every other failure uses, so scripts branching on the code are
|
|
3106
|
+
// unaffected. PINGROOM_DEBUG keeps the stack for whoever is fixing it.
|
|
3107
|
+
main().catch((error) => {
|
|
3108
|
+
if (process.env.PINGROOM_DEBUG) {
|
|
3109
|
+
process.stderr.write(`${error?.stack ?? error}\n`);
|
|
3110
|
+
}
|
|
3111
|
+
fail(`unexpected error: ${stripControlChars(error?.message ?? String(error))}`);
|
|
3112
|
+
});
|