@pingroom/cli 0.7.6 → 0.8.1

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 CHANGED
@@ -85,8 +85,9 @@ and is not retried as if history could be rewritten.
85
85
  An incomplete run exits `1`; it never deletes or replaces the saved credential.
86
86
  The command does not fall back to `PINGROOM_TOKEN`, an email-only credential, or
87
87
  a credential without `pingroom:handoffs:create` and a delivery room. A grant of
88
- all rooms pins no delivery room — pick one under Connected Agents in the app
89
- first.
88
+ all rooms pins no delivery room up front — pick one under Connected Agents in
89
+ the app, or run `pingroom rooms create`, which adopts the new room as the
90
+ delivery room when the agent was granted every room.
90
91
 
91
92
  There is deliberately no `login` command: being unconnected is a state the tool
92
93
  resolves, not one you have to discover. Once connected, bare `pingroom` prints
@@ -96,18 +97,26 @@ that status line followed by the usual help.
96
97
 
97
98
  Approving on the phone grants two separate things, and both are enforced:
98
99
 
99
- - **Permissions** — the scopes this CLI asks for: `rooms:read`,
100
- `broadcast:send`, `attachments:write`, `notifications:read`, `questions:ask`,
101
- `handoffs:create`, `live:write`. Nothing widens them later; a command needing
102
- one you did not approve returns `403 insufficient_scope`.
100
+ - **Permissions** — one approval covers every command this CLI ships, so it
101
+ asks for all 16 scopes its commands can need (`lib/scopes.js` is the list):
102
+ send pings, upload attachments, read pings, ask questions, request approvals,
103
+ create handoffs, drive live status; read/create/publish/join rooms and
104
+ set/trigger quick actions; and read/create/delete incoming webhooks. It does
105
+ **not** ask for `profile:write` (no command uses it) or the retired
106
+ `agents:ping`. Nothing widens them later — consent is an intersection, so a
107
+ scope the pairing did not request can never be granted afterwards; a command
108
+ needing one you did not approve returns `403 insufficient_scope`.
103
109
  - **Rooms** — one room, several, or all of them. A room outside that grant
104
110
  returns `403 room_not_granted` on every room-scoped call — writes such as
105
111
  pings, questions and live streams, and reads such as listing a room's quick
106
112
  actions or webhooks. Widen it under Connected Agents in the app.
107
113
 
108
114
  Both refusals print the fix, not just the code. A credential paired by an older
109
- CLI carries the scope set that version asked for — reconnect to re-approve if a
110
- command starts reporting `insufficient_scope`.
115
+ CLI carries the scope set that version asked for — CLIs before 0.8.1 asked for
116
+ only seven — so run `pingroom reconnect` if a command starts reporting
117
+ `insufficient_scope`. That re-approves with the current set, keeps the existing
118
+ connection working until you approve the new one, and revokes the old one only
119
+ afterwards, so cancelling changes nothing.
111
120
 
112
121
  The credential lands in `~/.pingroom/credentials.json` (mode `0600`, inside a
113
122
  `0700` directory). `PINGROOM_HOME` moves that directory; `pingroom logout`
@@ -641,6 +650,35 @@ shared by ChatGPT and Codex.
641
650
  For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
642
651
  See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
643
652
 
653
+ ## Agent skills
654
+
655
+ Two ready-to-install [Claude Code skills](https://github.com/pingroom/skills)
656
+ teach an agent when and how to reach a human — `pingroom-mcp` for conversational
657
+ sessions, `pingroom-cli` for shells, CI, and hooks.
658
+
659
+ ```bash
660
+ pingroom skills # list them and every install route (prints only)
661
+ pingroom skills install # copy both into ~/.claude/skills (needs git)
662
+ ```
663
+
664
+ `install` refuses to replace a skill that is already there; pass `--force` to
665
+ replace it, or `--dir <path>` to install somewhere other than
666
+ `~/.claude/skills`. Inside Claude Code you can instead use the plugin system,
667
+ which keeps them updated:
668
+
669
+ ```
670
+ /plugin marketplace add pingroom/skills
671
+ /plugin install pingroom-mcp
672
+ ```
673
+
674
+ ## Update notifications
675
+
676
+ When a newer `@pingroom/cli` is published the CLI prints a one-line notice on
677
+ stderr, at most once every 24 hours. It is deliberately invisible to automation:
678
+ the check is skipped entirely unless both stdout and stderr are a TTY, and
679
+ whenever `CI` or `PINGROOM_NO_UPDATE_CHECK=1` is set. A failed or slow check is
680
+ silent and can never change a command's output or exit code.
681
+
644
682
  ## License
645
683
 
646
684
  MIT
package/bin/pingroom.js CHANGED
@@ -23,6 +23,7 @@
23
23
  // live Drive a live progress card (iOS Live Activity / Android live
24
24
  // update) on the room members' lock screen: start / update / end.
25
25
  // mcp Print the canonical remote MCP endpoint and client setup snippets.
26
+ // skills List the published agent skills, or install them for Claude Code.
26
27
  // activate Send one optional test Question with the saved QR-paired credential.
27
28
  // config Read/write ~/.pingroom/config.json (default_room, api_url).
28
29
  // logout Forget the credential in ~/.pingroom/credentials.json.
@@ -39,9 +40,11 @@ import { EXIT } from '../lib/constants.js';
39
40
  import { fail, stripControlChars } from '../lib/util.js';
40
41
  import { VERSION } from '../lib/version.js';
41
42
  import { HELP } from '../lib/help.js';
43
+ import { maybeNotifyUpdate } from '../lib/update-check.js';
42
44
  import {
43
45
  parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
44
- parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs,
46
+ parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseReconnectArgs,
47
+ parseSkillsArgs,
45
48
  } from '../lib/parser.js';
46
49
  import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
47
50
  import { ping } from '../lib/commands/ping.js';
@@ -51,7 +54,8 @@ import { listen } from '../lib/commands/listen.js';
51
54
  import { live } from '../lib/commands/live.js';
52
55
  import { hook } from '../lib/commands/hook.js';
53
56
  import { mcp } from '../lib/commands/mcp.js';
54
- import { activateStoredInbox, bare } from '../lib/commands/connect.js';
57
+ import { skills } from '../lib/commands/skills.js';
58
+ import { activateStoredInbox, bare, reconnect } from '../lib/commands/connect.js';
55
59
  import { config, logout } from '../lib/commands/config.js';
56
60
 
57
61
  const COMMANDS = {
@@ -66,6 +70,7 @@ const COMMANDS = {
66
70
  listen: (rest) => listen(parseQArgs(rest)),
67
71
  hook: (rest) => hook(parseHookArgs(rest)),
68
72
  mcp,
73
+ skills: (rest) => skills(parseSkillsArgs(rest)),
69
74
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
70
75
  live: (rest) => live(parseLiveArgs(rest)),
71
76
  rooms: (rest) => rooms(parseManageArgs(rest)),
@@ -74,6 +79,7 @@ const COMMANDS = {
74
79
  approval: (rest) => approval(parseManageArgs(rest)),
75
80
  attachment: (rest) => attachment(parseManageArgs(rest)),
76
81
  config: (rest) => config(parseConfigArgs(rest)),
82
+ reconnect: (rest) => reconnect(parseReconnectArgs(rest)),
77
83
  logout: (rest) => logout(parseLogoutArgs(rest)),
78
84
  };
79
85
 
@@ -100,7 +106,9 @@ async function main() {
100
106
  // A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
101
107
  // configures the connect attempt rather than naming a command.
102
108
  if (!command || command.startsWith('-')) {
103
- process.exit(await bare(parseQArgs(argv)));
109
+ const bareCode = await bare(parseQArgs(argv));
110
+ await maybeNotifyUpdate(VERSION);
111
+ process.exit(bareCode);
104
112
  }
105
113
 
106
114
  const handler = COMMANDS[command];
@@ -109,6 +117,9 @@ async function main() {
109
117
  }
110
118
 
111
119
  const code = await handler(argv.slice(1));
120
+ // After the command's own output, never before, and never in place of it:
121
+ // the notice is advisory and must not lead. It cannot alter `code`.
122
+ await maybeNotifyUpdate(VERSION);
112
123
  process.exit(code);
113
124
  }
114
125
 
@@ -2,41 +2,14 @@
2
2
  // existing one), `cancel`, and `list`.
3
3
 
4
4
  import { EXIT } from '../constants.js';
5
- import { fail, parseDataObject, requireMaxLength, resolveWaitHold, sleep } from '../util.js';
5
+ import { applyIdempotencyKey, fail, parseDataObject, requireMaxLength, resolveWaitHold } from '../util.js';
6
6
  import { commandHelp } from '../help.js';
7
7
  import { apiDetail, httpJson } from '../http.js';
8
8
  import { agentContext } from '../config.js';
9
- import { buildOptions, exitForState, printResolution } from '../render.js';
9
+ import { buildOptions } from '../render.js';
10
+ import { waitForResolution } from '../question-wait.js';
10
11
  import { writeGitHubQuestionOutputs } from '../github-output.js';
11
12
 
12
- // Long-poll the wait endpoint until the question leaves `pending`, then print
13
- // and return the state's exit code. The server expires it at its ttl, so this
14
- // always terminates.
15
- async function waitForResolution(id, args, { token, apiBase }) {
16
- const hold = resolveWaitHold(args, { def: 25, cap: 30 });
17
-
18
- for (;;) {
19
- const started = Date.now();
20
- const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
21
- const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
22
- if (!res.ok) {
23
- const detail = apiDetail(res, json);
24
- fail(`wait failed: ${detail}`);
25
- }
26
- if (json && json.state && json.state !== 'pending') {
27
- if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
28
- if (args.json) process.stdout.write(`${text}\n`);
29
- else printResolution(json);
30
- return exitForState(json.state);
31
- }
32
- // Still pending at the hold timeout — poll again, but never hot-loop: a
33
- // misbehaving server that answers `pending` instantly (ignoring the hold)
34
- // would otherwise be hammered at full speed.
35
- const elapsed = Date.now() - started;
36
- if (elapsed < 1000) await sleep(1000 - elapsed);
37
- }
38
- }
39
-
40
13
  export async function ask(args) {
41
14
  if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
42
15
 
@@ -76,18 +49,7 @@ export async function ask(args) {
76
49
  }
77
50
  if (args.data !== undefined) body.data = parseDataObject(args.data);
78
51
 
79
- const headers = { Authorization: `Bearer ${token}` };
80
- // Question creation is the durable human gate for workflows. A printable,
81
- // bounded key lets a caller safely replay the exact same create request
82
- // after an ambiguous transport failure; the server returns 409 if the key is
83
- // ever reused for a different payload.
84
- if (args.idempotency_key !== undefined) {
85
- const key = String(args.idempotency_key);
86
- if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
87
- fail('--idempotency-key must be 1–255 printable ASCII characters without spaces', EXIT.USAGE);
88
- }
89
- headers['Idempotency-Key'] = key;
90
- }
52
+ const headers = applyIdempotencyKey(args, { Authorization: `Bearer ${token}` });
91
53
 
92
54
  // Pre-flight: reject a bad --timeout before the question exists.
93
55
  if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
@@ -9,8 +9,10 @@ import {
9
9
  import { HELP, commandHelp } from '../help.js';
10
10
  import { apiDetail, httpJson, requireSafeUrl, retryAfterMs } from '../http.js';
11
11
  import {
12
- credentialsPath, readStoredCredential, resolveApiBase, resolveRoom, saveCredential,
12
+ credentialsPath, readStoredCredential, requireStoredCredentialOrigin, resolveApiBase,
13
+ resolveRoom, saveCredential,
13
14
  } from '../config.js';
15
+ import { CLI_SCOPES } from '../scopes.js';
14
16
 
15
17
  // --- connecting (pairing + email fallback) ---------------------------------
16
18
  //
@@ -19,20 +21,6 @@ import {
19
21
  // room, so an agent can never end up connected with nobody's say-so about where
20
22
  // it pings. There is no `login` subcommand: `pingroom` resolves the state.
21
23
 
22
- // The scopes this CLI can actually use, one per command surface. Requested at
23
- // registration so the approval screen shows exactly what it is granting; the
24
- // server intersects, so asking for less is always safe and asking for more than
25
- // the human approves is impossible.
26
- const CLI_SCOPES = [
27
- 'pingroom:rooms:read', // resolve/display the connected room
28
- 'pingroom:broadcast:send', // ping
29
- 'pingroom:attachments:write', // ping --attach (the upload leg)
30
- 'pingroom:notifications:read',// listen
31
- 'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
32
- 'pingroom:handoffs:create', // handoff / handoffs
33
- 'pingroom:live:write', // live start/update/end/get
34
- ];
35
-
36
24
  // What the human reads on the approval screen. A product name, not a package
37
25
  // id: the phone shows it verbatim ("PingRoom CLI wants to connect").
38
26
  const AGENT_LABEL = 'PingRoom CLI';
@@ -724,3 +712,97 @@ export async function bare(args) {
724
712
 
725
713
  return connect(args);
726
714
  }
715
+
716
+ // --- reconnect --------------------------------------------------------------
717
+
718
+ /**
719
+ * Re-pair an existing connection so it carries the scopes this CLI version
720
+ * needs. Ordered so that any failure is a no-op:
721
+ *
722
+ * 1. the old credential stays in place and keeps working throughout;
723
+ * 2. the human approves a NEW pairing — a separate registration, since the
724
+ * server puts no uniqueness on user_id, so both are live at once;
725
+ * 3. the new credential is written atomically (temp file + rename);
726
+ * 4. only THEN is the old one revoked.
727
+ *
728
+ * Cancelling, Ctrl-C, or any error before step 3 leaves the machine exactly as
729
+ * it was. Revocation is last and best-effort on purpose: a crash between the
730
+ * rename and the revoke leaves a stale-but-harmless registration the human can
731
+ * remove from Connected Agents, whereas the reverse order would leave a working
732
+ * credentials file holding a dead token.
733
+ *
734
+ * This is NOT `logout && pingroom`. `logout` only unlinks the local file; the
735
+ * server-side credential stays active forever, so that sequence leaks a live
736
+ * credential every time.
737
+ */
738
+ export async function reconnect(args) {
739
+ if (args.help) { process.stdout.write(`${commandHelp('reconnect')}\n`); return EXIT.OK; }
740
+
741
+ // An env token is not ours to replace: it was pasted here from somewhere else
742
+ // (a CI secret, a password manager), the same registration is probably in use
743
+ // on other machines, and revoking it would break all of them from a terminal
744
+ // whose owner may not even know. Refuse rather than guess.
745
+ if (process.env.PINGROOM_TOKEN) {
746
+ fail(
747
+ 'PINGROOM_TOKEN is set, and reconnect would revoke whatever credential it names.\n'
748
+ + ' Unset it and run "pingroom reconnect" against the stored credential, or re-pair\n'
749
+ + ' from scratch with "pingroom" and update the token where it is configured.',
750
+ EXIT.USAGE,
751
+ );
752
+ }
753
+
754
+ const stored = readStoredCredential();
755
+ if (!stored) {
756
+ fail(`not connected — there is no credential in ${credentialsPath()} to replace. Run "pingroom" to pair.`, EXIT.USAGE);
757
+ }
758
+
759
+ if (!isInteractive()) {
760
+ fail('reconnect needs an interactive terminal to show the QR code.', EXIT.USAGE);
761
+ }
762
+
763
+ const apiBase = resolveApiBase(args);
764
+ requireSafeUrl('--api', apiBase);
765
+ // The stored credential is about to be sent to `apiBase` in the revoke below,
766
+ // so it is bound by the same origin rule every other stored-bearer command
767
+ // obeys. Without this, `--api` (or a stale config.json api_url) redirects a
768
+ // live production credential to an arbitrary host.
769
+ requireStoredCredentialOrigin(args, apiBase);
770
+
771
+ process.stdout.write(' Reconnecting updates the permissions this CLI holds.\n');
772
+ process.stdout.write(' Your current connection keeps working until the new one is approved,\n');
773
+ process.stdout.write(' and is then revoked — any other machine or CI job using that same\n');
774
+ process.stdout.write(' credential will stop working.\n\n');
775
+
776
+ const prompter = createPrompter();
777
+ const ask = (question) => prompter.ask(question);
778
+ let cred;
779
+ try {
780
+ cred = await connectByPairing(apiBase, ask);
781
+ } finally {
782
+ prompter.close();
783
+ }
784
+
785
+ // Declined, expired, or the user closed stdin: nothing was written, so the old
786
+ // credential is still the one on disk and still valid.
787
+ if (!cred) {
788
+ process.stdout.write(' Kept your current connection.\n');
789
+ return EXIT.EXPIRED;
790
+ }
791
+
792
+ // The new credential is already durable (connectByPairing saved it). From
793
+ // here on, failing to revoke is untidy, not dangerous.
794
+ const { res, json, error } = await httpJson('POST', `${apiBase}/api/agent/auth/revoke`, {
795
+ headers: { Authorization: `Bearer ${stored.token}` },
796
+ body: {},
797
+ soft: true,
798
+ });
799
+ if (error || !res || !res.ok) {
800
+ const detail = error ? error.message : apiDetail(res, json);
801
+ process.stdout.write(` Note: the previous connection could not be revoked (${detail}).\n`);
802
+ process.stdout.write(' Remove it from PingRoom → Settings → Connected Agents when convenient.\n');
803
+ return EXIT.OK;
804
+ }
805
+
806
+ process.stdout.write(' Previous connection revoked.\n');
807
+ return EXIT.OK;
808
+ }
@@ -5,6 +5,7 @@ import { truncate } from '../util.js';
5
5
  import { commandHelp } from '../help.js';
6
6
  import { hookFetch, isSafeUrl } from '../http.js';
7
7
  import { resolveApiBase, resolveRoom, resolveToken, storedCredentialOriginError } from '../config.js';
8
+ import { forgetContinuation, recordContinuation } from '../continuations.js';
8
9
  import { VERSION } from '../version.js';
9
10
 
10
11
  // --- hook (Claude Code integration) ----------------------------------------
@@ -139,8 +140,11 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
139
140
  process.on('SIGTERM', onSignal);
140
141
 
141
142
  try {
143
+ // `data` is echoed into every room member's push AND into the room's
144
+ // outgoing webhook, so it carries only what a recipient should see. The
145
+ // working directory is a local filesystem path: it goes to
146
+ // ~/.pingroom/continuations.json below, never on the wire.
142
147
  const data = { tool_name: String(toolName) };
143
- if (event.cwd) data.cwd = String(event.cwd);
144
148
  const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
145
149
  token,
146
150
  body: {
@@ -161,7 +165,16 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
161
165
  return EXIT.OK;
162
166
  }
163
167
 
168
+ // Where this session would need to resume from, kept locally. Best-effort:
169
+ // a failure here must not change the tool decision.
170
+ recordContinuation(questionId, {
171
+ sessionId: event.session_id,
172
+ cwd: event.cwd,
173
+ transcriptPath: event.transcript_path,
174
+ });
175
+
164
176
  const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
177
+ forgetContinuation(questionId);
165
178
  if (resolved.state === 'answered') {
166
179
  const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
167
180
  if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
@@ -210,9 +223,11 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
210
223
  return EXIT.OK; // unknown event — stay silent rather than send noise
211
224
  }
212
225
 
226
+ // Same rule as the question path: nothing here is private to this machine.
227
+ // `session_id` is already the ping's correlation_id, so it is on the wire by
228
+ // design; `cwd` is a local path and stays local.
213
229
  const data = { event: name };
214
230
  if (event.session_id) data.session_id = String(event.session_id);
215
- if (event.cwd) data.cwd = String(event.cwd);
216
231
 
217
232
  try {
218
233
  await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
@@ -4,10 +4,12 @@
4
4
  // response; the default prints a compact human line per record).
5
5
 
6
6
  import { EXIT } from '../constants.js';
7
- import { fail } from '../util.js';
7
+ import { applyIdempotencyKey, fail, requireMaxLength, resolveWaitHold } from '../util.js';
8
8
  import { commandHelp } from '../help.js';
9
9
  import { apiDetail, httpJson } from '../http.js';
10
10
  import { agentContext } from '../config.js';
11
+ import { APPROVAL_OPTIONS, exitForApproval, printApproval } from '../render.js';
12
+ import { waitForResolution } from '../question-wait.js';
11
13
 
12
14
  function sub(args, allowed, noun) {
13
15
  const name = args._[0];
@@ -182,43 +184,48 @@ export async function actions(args) {
182
184
 
183
185
  // ------------------------------------------------------------- approval
184
186
 
185
- const APPROVAL_EXIT = { approved: EXIT.OK, denied: EXIT.CANCELLED, expired: EXIT.EXPIRED };
186
-
187
+ /**
188
+ * The deploy gate. An approval is the canonical two-option Question, so this
189
+ * creates one rather than using the older `/approvals` endpoint: only Questions
190
+ * reach the phone with real Approve/Deny buttons on the lock screen, and only
191
+ * Questions get idempotency, the expiry sweep and the resolution webhook.
192
+ */
187
193
  export async function approval(args) {
188
194
  if (args.help) { process.stdout.write(`${commandHelp('approval')}\n`); return EXIT.OK; }
189
195
 
190
196
  if (!args.prompt) fail('an approval needs --prompt', EXIT.USAGE);
197
+ requireMaxLength(args.prompt, 500, '--prompt');
198
+ requireMaxLength(args.context, 40, '--context');
199
+
191
200
  const { token, apiBase, room } = agentContext(args, { needRoom: true });
192
201
 
193
- const body = { prompt: args.prompt };
202
+ const body = { prompt: args.prompt, options: APPROVAL_OPTIONS };
194
203
  if (args.context) body.context = args.context;
195
204
  if (args.ttl !== undefined) {
196
205
  if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
197
206
  body.ttl = Number(args.ttl);
198
207
  }
199
208
 
209
+ const headers = applyIdempotencyKey(args, auth(token));
210
+
211
+ // Pre-flight: reject a bad --timeout before the approval is on someone's phone.
212
+ if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
213
+
200
214
  const { text, json } = await requireOk(
201
- httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/approvals`, { headers: auth(token), body }),
215
+ httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, { headers, body }),
202
216
  'approval',
203
217
  );
204
218
 
205
219
  if (!args.wait) {
206
- process.stdout.write(`${text}\n`);
220
+ if (args.json) process.stdout.write(`${text}\n`);
221
+ else process.stdout.write(`${json.id}\n`);
207
222
  return EXIT.OK;
208
223
  }
209
224
 
210
- for (;;) {
211
- const { res, text: waitText, json: state } = await httpJson(
212
- 'GET',
213
- `${apiBase}/api/agent/approvals/${encodeURIComponent(json.id)}/wait?timeout=25`,
214
- { headers: auth(token) },
215
- );
216
- if (!res.ok) fail(`approval wait failed: ${apiDetail(res, state)}`);
217
- if (state && state.state && state.state !== 'pending') {
218
- process.stdout.write(`${args.json ? waitText : state.state}\n`);
219
- return APPROVAL_EXIT[state.state] ?? EXIT.OK;
220
- }
221
- }
225
+ return waitForResolution(json.id, args, { token, apiBase }, {
226
+ exitFor: exitForApproval,
227
+ print: printApproval,
228
+ });
222
229
  }
223
230
 
224
231
  // ----------------------------------------------------------- attachment
@@ -0,0 +1,178 @@
1
+ // `skills` — the agent skills published at github.com/pingroom/skills.
2
+ //
3
+ // Bare `skills` prints the catalog and every install route, the same
4
+ // output-only contract `mcp` keeps. `skills install` is the one command in this
5
+ // CLI that writes outside ~/.pingroom, so it is explicit, refuses to clobber,
6
+ // and names every path it touched.
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
10
+ import { homedir, tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { EXIT } from '../constants.js';
14
+ import { fail } from '../util.js';
15
+ import { commandHelp } from '../help.js';
16
+
17
+ export const SKILLS_REPO = 'https://github.com/pingroom/skills';
18
+ const SKILLS_CLONE_URL = `${SKILLS_REPO}.git`;
19
+
20
+ // Path in the repo -> the skill directory name it installs as.
21
+ //
22
+ // The repo is laid out as two Claude Code plugins (`mcp/`, `cli/`), each with a
23
+ // `skills/<name>/` directory whose name already matches the skill's frontmatter
24
+ // `name` — that agreement is what lets the same tree also be installed with
25
+ // `/plugin marketplace add`. So the source path is deep and the install name is
26
+ // simply its last segment; a copy install and a plugin install land the same
27
+ // directory name either way.
28
+ const SKILLS = [
29
+ {
30
+ source: ['mcp', 'skills', 'pingroom-mcp'],
31
+ install: 'pingroom-mcp',
32
+ summary: 'conversational agents — the hosted MCP connector',
33
+ },
34
+ {
35
+ source: ['cli', 'skills', 'pingroom-cli'],
36
+ install: 'pingroom-cli',
37
+ summary: 'shells, CI, and Claude Code hooks',
38
+ },
39
+ ];
40
+
41
+ export function claudeSkillsDir() {
42
+ return process.env.CLAUDE_SKILLS_DIR || join(homedir(), '.claude', 'skills');
43
+ }
44
+
45
+ function catalogLines() {
46
+ const width = Math.max(...SKILLS.map((s) => s.install.length));
47
+ return SKILLS.map((s) => ` ${s.install.padEnd(width)} ${s.summary}`).join('\n');
48
+ }
49
+
50
+ function printCatalog() {
51
+ process.stdout.write(
52
+ `PingRoom agent skills — ${SKILLS_REPO}
53
+
54
+ ${catalogLines()}
55
+
56
+ Install with this CLI (copies into ${claudeSkillsDir()}):
57
+ pingroom skills install
58
+
59
+ Install as a Claude Code plugin (auto-updates, no copy):
60
+ /plugin marketplace add pingroom/skills
61
+ /plugin install pingroom-mcp
62
+ /plugin install pingroom-cli
63
+
64
+ Or by hand:
65
+ git clone ${SKILLS_CLONE_URL} /tmp/pingroom-skills
66
+ ${SKILLS.map((s) => ` cp -r /tmp/pingroom-skills/${s.source.join('/')} ${claudeSkillsDir()}`).join('\n')}
67
+
68
+ Only "pingroom skills install" writes anything; this listing does not.
69
+ `);
70
+ return EXIT.OK;
71
+ }
72
+
73
+ function directoryExists(path) {
74
+ try {
75
+ return statSync(path).isDirectory();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Clone the skills repo into a fresh temp directory and return its path.
83
+ *
84
+ * `git` rather than a tarball fetch: the repo is public and shallow-clones in
85
+ * one round trip, Node ships no tar reader, and vendoring one would put an
86
+ * archive parser in the dependency-free path every ping goes through. When git
87
+ * is missing the manual recipe above is still exact, so this fails with that
88
+ * rather than half-installing.
89
+ */
90
+ function cloneSkills() {
91
+ const probe = spawnSync('git', ['--version'], { stdio: 'ignore' });
92
+ if (probe.error || probe.status !== 0) {
93
+ fail(`git is required for "skills install".\nInstall git, or copy the skills by hand:\n pingroom skills`, EXIT.ERROR);
94
+ }
95
+
96
+ const workspace = mkdtempSync(join(tmpdir(), 'pingroom-skills-'));
97
+ const clone = spawnSync(
98
+ 'git',
99
+ ['clone', '--depth', '1', '--quiet', SKILLS_CLONE_URL, workspace],
100
+ { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' },
101
+ );
102
+
103
+ if (clone.error || clone.status !== 0) {
104
+ rmSync(workspace, { recursive: true, force: true });
105
+ const detail = (clone.stderr || clone.error?.message || 'git clone failed').trim().split('\n')[0];
106
+ fail(`could not fetch ${SKILLS_REPO}: ${detail}`, EXIT.ERROR);
107
+ }
108
+
109
+ return workspace;
110
+ }
111
+
112
+ function install(args) {
113
+ const target = args.dir ? String(args.dir) : claudeSkillsDir();
114
+ const force = Boolean(args.force);
115
+
116
+ // Resolve collisions BEFORE the network call. Cloning first and refusing
117
+ // afterwards would spend the round trip to tell the operator something that
118
+ // was knowable from the filesystem alone.
119
+ if (!force) {
120
+ const existing = SKILLS.filter((s) => directoryExists(join(target, s.install)));
121
+ if (existing.length > 0) {
122
+ const names = existing.map((s) => join(target, s.install)).join('\n ');
123
+ fail(
124
+ `already installed:\n ${names}\nRe-run with --force to replace ${existing.length === 1 ? 'it' : 'them'}.`,
125
+ EXIT.USAGE,
126
+ );
127
+ }
128
+ }
129
+
130
+ const workspace = cloneSkills();
131
+ const installed = [];
132
+ try {
133
+ for (const skill of SKILLS) {
134
+ const from = join(workspace, ...skill.source);
135
+ if (!directoryExists(from)) {
136
+ fail(`${SKILLS_REPO} has no "${skill.source.join('/')}" directory — the repo layout changed.`, EXIT.ERROR);
137
+ }
138
+ const to = join(target, skill.install);
139
+ mkdirSync(target, { recursive: true });
140
+ // Replace rather than merge: a stale SKILL.md left beside a new one is a
141
+ // skill that half-describes two versions, and Claude Code would load it.
142
+ rmSync(to, { recursive: true, force: true });
143
+ cpSync(from, to, { recursive: true });
144
+ installed.push({ name: skill.install, path: to, files: countFiles(to) });
145
+ }
146
+ } finally {
147
+ rmSync(workspace, { recursive: true, force: true });
148
+ }
149
+
150
+ const lines = installed.map((s) => ` ${s.name} -> ${s.path} (${s.files} file${s.files === 1 ? '' : 's'})`);
151
+ process.stdout.write(
152
+ `Installed ${installed.length} skill${installed.length === 1 ? '' : 's'}:
153
+ ${lines.join('\n')}
154
+
155
+ Restart Claude Code (or start a new session) to load them.
156
+ Connect the MCP server so the pingroom-mcp skill has tools to call:
157
+ pingroom mcp
158
+ `);
159
+ return EXIT.OK;
160
+ }
161
+
162
+ function countFiles(dir) {
163
+ let total = 0;
164
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
165
+ total += entry.isDirectory() ? countFiles(join(dir, entry.name)) : 1;
166
+ }
167
+ return total;
168
+ }
169
+
170
+ export function skills(args) {
171
+ if (args.help) { process.stdout.write(`${commandHelp('skills')}\n`); return EXIT.OK; }
172
+
173
+ const [sub] = args._;
174
+ if (sub === undefined || sub === 'list') return printCatalog();
175
+ if (sub === 'install') return install(args);
176
+
177
+ fail('usage: pingroom skills [list|install] [--dir <path>] [--force]', EXIT.USAGE);
178
+ }
@@ -0,0 +1,106 @@
1
+ // Where a blocked agent left off, kept on THIS machine only.
2
+ //
3
+ // When a hook turns a tool-permission prompt into a PingRoom question, the
4
+ // thing that will eventually need to resume — the Claude Code session, its
5
+ // working directory, its transcript — is local. The server never needs to know
6
+ // any of it: the question already carries `correlation_id = session_id`, which
7
+ // is enough to match an answer back to a row here.
8
+ //
9
+ // That is deliberate, not incidental. A question's `data` object is echoed into
10
+ // every room member's push payload AND into the room's outgoing webhook, so a
11
+ // `cwd` put there would publish a local filesystem path to everyone in the room
12
+ // and to whatever URL the room forwards to. Keeping the record local leaks
13
+ // nothing and needs no wire contract.
14
+ //
15
+ // Nothing consumes this yet. It exists so that resuming an ended session is a
16
+ // matter of writing the consumer, not of changing the protocol — and until that
17
+ // consumer exists, PingRoom only claims to block an agent that is still running.
18
+
19
+ import { randomBytes } from 'node:crypto';
20
+ import { chmodSync, closeSync, fchmodSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ import { pingroomHome, readJsonFile } from './config.js';
24
+
25
+ /** Enough for any plausible backlog of open questions on one machine. */
26
+ const MAX_ENTRIES = 100;
27
+ /** A question cannot outlive its TTL by this much; anything older is dead. */
28
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
29
+
30
+ export function continuationsPath() { return join(pingroomHome(), 'continuations.json'); }
31
+
32
+ /**
33
+ * Atomic write that REPORTS failure instead of exiting.
34
+ *
35
+ * config.js's writeJsonFile calls fail() — correct when a human ran `config
36
+ * set` and needs to know it didn't take, fatal here: this is called from the
37
+ * hook, which must never break the agent it is advising. A full disk loses a
38
+ * resume hint; it must not kill the session.
39
+ */
40
+ function writeQuietly(path, value) {
41
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
42
+ let fd;
43
+ try {
44
+ const created = mkdirSync(pingroomHome(), { recursive: true, mode: 0o700 });
45
+ if (created !== undefined) chmodSync(pingroomHome(), 0o700);
46
+ fd = openSync(tmp, 'wx', 0o600);
47
+ fchmodSync(fd, 0o600);
48
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
49
+ closeSync(fd);
50
+ fd = undefined;
51
+ renameSync(tmp, path);
52
+ return true;
53
+ } catch {
54
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
55
+ try { unlinkSync(tmp); } catch { /* never created */ }
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function load() {
61
+ const stored = readJsonFile(continuationsPath());
62
+ const entries = stored && typeof stored.entries === 'object' && !Array.isArray(stored.entries)
63
+ ? stored.entries
64
+ : {};
65
+ return entries;
66
+ }
67
+
68
+ /** Drop expired rows, then the oldest ones, so the file cannot grow forever. */
69
+ function bound(entries, now) {
70
+ const live = Object.entries(entries).filter(([, e]) => {
71
+ const at = Date.parse(e && e.recorded_at);
72
+ return Number.isFinite(at) && now - at < MAX_AGE_MS;
73
+ });
74
+ live.sort((a, b) => Date.parse(a[1].recorded_at) - Date.parse(b[1].recorded_at));
75
+ return Object.fromEntries(live.slice(-MAX_ENTRIES));
76
+ }
77
+
78
+ /**
79
+ * Remember where to come back to for one question. Best-effort by design: the
80
+ * caller is a hook that has already decided the agent proceeds either way.
81
+ */
82
+ export function recordContinuation(questionId, { sessionId, cwd, transcriptPath } = {}) {
83
+ if (!questionId || typeof questionId !== 'string') return false;
84
+ const now = Date.now();
85
+ const entries = bound(load(), now);
86
+ entries[questionId] = {
87
+ recorded_at: new Date(now).toISOString(),
88
+ ...(sessionId ? { session_id: String(sessionId) } : {}),
89
+ ...(cwd ? { cwd: String(cwd) } : {}),
90
+ ...(transcriptPath ? { transcript_path: String(transcriptPath) } : {}),
91
+ };
92
+ return writeQuietly(continuationsPath(), { version: 1, entries: bound(entries, now) });
93
+ }
94
+
95
+ /** The record for one question, or null. */
96
+ export function readContinuation(questionId) {
97
+ return load()[questionId] ?? null;
98
+ }
99
+
100
+ /** Forget one question once it has resolved — the hint has done its job. */
101
+ export function forgetContinuation(questionId) {
102
+ const entries = load();
103
+ if (!(questionId in entries)) return false;
104
+ delete entries[questionId];
105
+ return writeQuietly(continuationsPath(), { version: 1, entries: bound(entries, Date.now()) });
106
+ }
package/lib/help.js CHANGED
@@ -30,6 +30,7 @@ Commands:
30
30
  permission prompts to a PingRoom question you answer from your phone
31
31
  mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
32
32
  Claude Desktop
33
+ skills List the PingRoom agent skills, or install them for Claude Code
33
34
  activate Retry Agent Inbox activation with the saved QR-paired credential
34
35
  rooms List, inspect, create, or join rooms (rooms list|get|create|join)
35
36
  webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
@@ -37,6 +38,7 @@ Commands:
37
38
  approval Send an approve/deny request; with --wait, block on the decision
38
39
  attachment Download or delete an attachment by id (attachment get|delete)
39
40
  config Read/write local settings (config list | get <key> | set <key> <val>)
41
+ reconnect Re-approve this CLI so it holds every permission it needs
40
42
  logout Forget the stored credential`;
41
43
 
42
44
  export const HELP_PING = `ping options:
@@ -156,6 +158,13 @@ export const HELP_MCP = `mcp:
156
158
  pingroom mcp add claude-code Print the Claude Code setup command
157
159
  (output-only; does not change client config)`;
158
160
 
161
+ export const HELP_SKILLS = `skills:
162
+ pingroom skills List the published agent skills and every
163
+ install route (output-only)
164
+ pingroom skills install Copy them into ~/.claude/skills (needs git)
165
+ --dir <path> Install somewhere other than ~/.claude/skills
166
+ --force Replace skills that are already installed`;
167
+
159
168
  export const HELP_ACTIVATE = `activate:
160
169
  pingroom activate Send one test Question to your phone to prove the
161
170
  saved QR-paired credential works (optional —
@@ -278,7 +287,7 @@ human decision is not an infrastructure failure.`;
278
287
 
279
288
  export const HELP = [
280
289
  HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
281
- HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
290
+ HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_SKILLS, HELP_ACTIVATE, HELP_CONFIG,
282
291
  HELP_SHARED, HELP_TAIL,
283
292
  ].join('\n\n');
284
293
 
@@ -299,6 +308,7 @@ export const COMMAND_HELP_SECTIONS = {
299
308
  listen: HELP_LISTEN,
300
309
  live: HELP_LIVE,
301
310
  hook: HELP_HOOK,
311
+ skills: HELP_SKILLS,
302
312
  activate: HELP_ACTIVATE,
303
313
  rooms: `rooms (agent token required):
304
314
  pingroom rooms list List the rooms this account belongs to
@@ -323,18 +333,34 @@ export const COMMAND_HELP_SECTIONS = {
323
333
  pingroom actions trigger <1-4> --room <code>`,
324
334
  approval: `approval (agent token + --room required):
325
335
  pingroom approval -p <prompt> Send an approve/deny request
336
+ Prints the id; --wait prints the decision
326
337
  -c, --context <text> Secondary line (<= 40 chars)
327
338
  --ttl <seconds> Expiry; omit for the server default
328
- --wait Block until approved/denied/expired
329
- (exit 0 approved · 4 denied · 3 expired)`,
339
+ --idempotency-key <k> Safe replay of the create after a network failure
340
+ --wait Block until the human decides
341
+ (exit 0 approve · 4 deny/cancelled · 3 expired)
342
+ --timeout <0-30> Seconds per long-poll while waiting (default 25)`,
330
343
  attachment: `attachment (agent token required):
331
344
  pingroom attachment get <id> [--out <path>]
332
345
  Download; bytes go to --out or stdout
333
346
  pingroom attachment delete <id> Delete an unclaimed upload`,
334
347
  config: HELP_CONFIG,
348
+ reconnect: `reconnect:
349
+ pingroom reconnect Re-approve this CLI with the permissions the
350
+ current version needs, then revoke the old
351
+ connection. Your existing connection keeps
352
+ working until you approve the new one, and
353
+ cancelling changes nothing.
354
+ Any other machine or CI job sharing the same
355
+ credential will stop working once it is revoked.
356
+ --api <url> API base URL (default https://api.pingroom.io)`,
335
357
  logout: `logout:
336
358
  pingroom logout Forget the stored credential (PINGROOM_TOKEN
337
- in the environment is unaffected)`,
359
+ in the environment is unaffected).
360
+ This is LOCAL ONLY — the connection stays
361
+ active on the server. To replace it, use
362
+ "pingroom reconnect"; to end it, revoke it in
363
+ PingRoom -> Settings -> Connected Agents.`,
338
364
  };
339
365
 
340
366
  // config and logout are local-only commands that reject --token/--api (and,
@@ -346,6 +372,14 @@ export const COMMAND_HELP_FOOTERS = {
346
372
  -h, --help Show this help`,
347
373
  logout: `Shared:
348
374
  -h, --help Show this help`,
375
+ // reconnect drives the QR pairing flow; a --token would have nothing to do
376
+ // with the credential it is about to replace.
377
+ reconnect: `Shared:
378
+ -h, --help Show this help`,
379
+ // `skills` reaches GitHub, never the PingRoom API, so the shared credential
380
+ // and --json flags would all be lies here.
381
+ skills: `Shared:
382
+ -h, --help Show this help`,
349
383
  };
350
384
 
351
385
  // `<command> --help`: that command's section plus the shared flags, instead of
package/lib/http.js CHANGED
@@ -13,11 +13,16 @@ import { readStrictJsonResponse } from './strict-json.js';
13
13
  */
14
14
  const API_HINTS = {
15
15
  room_not_granted:
16
- '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.',
16
+ 'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom reconnect" to re-approve and pick it.',
17
+ // The remedy has to be `reconnect`, not `pingroom`: on a machine that is
18
+ // already connected, bare `pingroom` just prints the status line and the help,
19
+ // so it reads as a fix and does nothing. Consent is an intersection
20
+ // server-side, so a scope the old pairing never requested can only be gained
21
+ // by approving a new one.
17
22
  insufficient_scope:
18
- 'This credential was approved before the command needed that permission. Run "pingroom" to reconnect and re-approve.',
23
+ 'This credential was approved before the CLI needed that permission. Run "pingroom reconnect" to re-approve with the current permissions — your existing connection keeps working until you approve the new one.',
19
24
  no_room_configured:
20
- 'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
25
+ 'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app, or — if it was granted every room — let it create one with "pingroom rooms create".',
21
26
  };
22
27
 
23
28
  /**
package/lib/parser.js CHANGED
@@ -205,6 +205,15 @@ export const parseConfigArgs = makeParser({
205
205
  bareDashIsPositional: true,
206
206
  });
207
207
 
208
+ // reconnect re-runs the pairing flow against the stored credential. It takes no
209
+ // --token deliberately: the credential it replaces is the one on disk, and an
210
+ // env token is refused outright by the command.
211
+ export const parseReconnectArgs = makeParser({
212
+ aliases: { '--api': 'api', '-h': 'help', '--help': 'help' },
213
+ booleans: ['help'],
214
+ bareDashIsPositional: true,
215
+ });
216
+
208
217
  export const parseLogoutArgs = makeParser({
209
218
  aliases: { '-h': 'help', '--help': 'help' },
210
219
  booleans: ['help'],
@@ -214,6 +223,18 @@ export const parseLogoutArgs = makeParser({
214
223
  // Parser for the management nouns (rooms / webhooks / actions / approval /
215
224
  // attachment). One shared vocabulary: each sub-command reads the flags it
216
225
  // needs and the rest are usage errors at the command layer, same as everywhere.
226
+ // `skills` takes no credential and touches no API: only where to install and
227
+ // whether to replace what is already there. A dedicated vocabulary keeps
228
+ // --force from leaking into the parsers whose commands send real pings.
229
+ export const parseSkillsArgs = makeParser({
230
+ aliases: {
231
+ '--dir': 'dir',
232
+ '--force': 'force',
233
+ '-h': 'help', '--help': 'help',
234
+ },
235
+ booleans: ['force', 'help'],
236
+ });
237
+
217
238
  export const parseManageArgs = makeParser({
218
239
  aliases: {
219
240
  '-n': 'name', '--name': 'name',
@@ -235,6 +256,8 @@ export const parseManageArgs = makeParser({
235
256
  '--require-ack': 'require_ack',
236
257
  '--out': 'out',
237
258
  '--wait': 'wait',
259
+ '--timeout': 'timeout',
260
+ '--idempotency-key': 'idempotency_key',
238
261
  '--token': 'token',
239
262
  '--room': 'room',
240
263
  '--expected-room-sha256': 'expected_room_sha256',
@@ -0,0 +1,45 @@
1
+ // The one long-poll loop behind every command that blocks on a human: `ask`,
2
+ // `watch`, and `approval`. It lives here rather than in ask.js so the hot-loop
3
+ // floor and the terminal-state handling cannot drift apart — `approval` shipped
4
+ // its own copy of this loop once, and that copy read a field the server never
5
+ // sends, so it spun forever after the human had already decided.
6
+
7
+ import { fail, resolveWaitHold, sleep } from './util.js';
8
+ import { apiDetail, httpJson } from './http.js';
9
+ import { exitForState, printResolution } from './render.js';
10
+ import { writeGitHubQuestionOutputs } from './github-output.js';
11
+
12
+ /**
13
+ * Long-poll until the question leaves `pending`, then print and return its exit
14
+ * code. The server expires it at its ttl, so this always terminates.
15
+ *
16
+ * `exitFor` and `print` take the whole resolved question, not just its state:
17
+ * a deploy gate has to read the chosen *value* to tell approve from deny, which
18
+ * the state alone ("answered") cannot express.
19
+ */
20
+ export async function waitForResolution(id, args, { token, apiBase }, opts = {}) {
21
+ const exitFor = opts.exitFor ?? ((q) => exitForState(q.state));
22
+ const print = opts.print ?? printResolution;
23
+ const hold = resolveWaitHold(args, { def: 25, cap: 30 });
24
+
25
+ for (;;) {
26
+ const started = Date.now();
27
+ const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
28
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
29
+ if (!res.ok) {
30
+ const detail = apiDetail(res, json);
31
+ fail(`wait failed: ${detail}`);
32
+ }
33
+ if (json && json.state && json.state !== 'pending') {
34
+ if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
35
+ if (args.json) process.stdout.write(`${text}\n`);
36
+ else print(json);
37
+ return exitFor(json);
38
+ }
39
+ // Still pending at the hold timeout — poll again, but never hot-loop: a
40
+ // misbehaving server that answers `pending` instantly (ignoring the hold)
41
+ // would otherwise be hammered at full speed.
42
+ const elapsed = Date.now() - started;
43
+ if (elapsed < 1000) await sleep(1000 - elapsed);
44
+ }
45
+ }
package/lib/render.js CHANGED
@@ -116,6 +116,45 @@ export function printResolution(q) {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * The two options behind `pingroom approval`. An approval IS a two-option
121
+ * Question — same wire shape, same lock-screen buttons, same first-answer-wins
122
+ * — so the command builds one rather than reaching for the older Approvals API,
123
+ * which delivers no answer actions to the phone at all.
124
+ */
125
+ export const APPROVAL_OPTIONS = Object.freeze([
126
+ Object.freeze({ value: 'approve', label: 'Approve', style: 'primary' }),
127
+ Object.freeze({ value: 'deny', label: 'Deny', style: 'danger' }),
128
+ ]);
129
+
130
+ /**
131
+ * A deploy gate reads the chosen VALUE, not just the state.
132
+ *
133
+ * `exitForState` maps every answer to 0, which is right for `ask` (a human
134
+ * answering "hold" is not an infra failure) and catastrophic for a gate:
135
+ * `if pingroom approval --wait; then deploy; fi` must refuse on a denial.
136
+ * So `approve` is 0 and anything else answered is 4, and only the non-answered
137
+ * states fall through to the shared mapping.
138
+ */
139
+ export function exitForApproval(q) {
140
+ if (q.state === 'answered') {
141
+ const value = (q.answer && (q.answer.value ?? q.answer.text)) || '';
142
+ return value === 'approve' ? EXIT.OK : EXIT.CANCELLED;
143
+ }
144
+ return exitForState(q.state);
145
+ }
146
+
147
+ // Same split as printResolution: the decision goes to stdout so `$(...)` can
148
+ // capture it, every other outcome reports to stderr and leaves stdout empty.
149
+ export function printApproval(q) {
150
+ if (q.state === 'answered') {
151
+ const value = (q.answer && (q.answer.value ?? q.answer.text)) || '';
152
+ process.stdout.write(`${value}\n`);
153
+ } else {
154
+ process.stderr.write(`pingroom: approval ${q.state}\n`);
155
+ }
156
+ }
157
+
119
158
  /** One readable line per incoming ping. */
120
159
  export function formatIncoming(item) {
121
160
  const room = item?.room?.name || item?.room?.code || '?';
package/lib/scopes.js ADDED
@@ -0,0 +1,95 @@
1
+ // What the official CLI asks for, and what each command actually needs.
2
+ //
3
+ // One pairing approval enables every command PingRoom ships. That is a
4
+ // deliberate reading of OAuth's minimum-required principle (RFC 9700 §2.3): the
5
+ // unit being authorized here is the complete official CLI, not the server's
6
+ // whole capability surface. Consent is an intersection server-side
7
+ // (AgentAuthController::pairingClaim), so this list is a hard CEILING — a scope
8
+ // missing here can never be granted, and the only remedy is a fresh pairing.
9
+ // That is why the set is not trimmed to "what you happen to run today".
10
+ //
11
+ // Two catalog scopes are deliberately absent:
12
+ // pingroom:profile:write — no CLI command sets the agent avatar or handle.
13
+ // pingroom:agents:ping — retired; the route answers 410.
14
+ //
15
+ // Ordered by the group the consent screen shows, so the approval reads as three
16
+ // coherent sections even on a client that has not shipped the grouping yet.
17
+ export const CLI_SCOPES = [
18
+ // Communication and human decisions
19
+ 'pingroom:broadcast:send', // ping
20
+ 'pingroom:attachments:write', // ping --attach (upload), attachment delete
21
+ 'pingroom:notifications:read',// listen, attachment get
22
+ 'pingroom:questions:ask', // ask / watch / cancel / list, approval, the hook
23
+ 'pingroom:approvals:request', // the legacy approvals surface (SDK/MCP parity)
24
+ 'pingroom:handoffs:create', // handoff / handoffs / activate
25
+ 'pingroom:live:write', // live start/update/end/get
26
+ // Rooms and quick actions
27
+ 'pingroom:rooms:read', // rooms list/get, actions list, room icons
28
+ 'pingroom:rooms:write', // rooms create
29
+ 'pingroom:rooms:publish', // rooms create --public
30
+ 'pingroom:rooms:join', // rooms join
31
+ 'pingroom:actions:trigger', // actions trigger
32
+ 'pingroom:actions:write', // actions set
33
+ // Webhooks
34
+ 'pingroom:webhooks:read', // webhooks list
35
+ 'pingroom:webhooks:write', // webhooks create/update
36
+ 'pingroom:webhooks:delete', // webhooks delete
37
+ ];
38
+
39
+ /**
40
+ * Command -> every scope any of its verbs can need, taken from the route
41
+ * middleware in laravel/routes/api.php.
42
+ *
43
+ * This exists to be TESTED, not consulted at runtime for authorization: the
44
+ * server is the authority. `test/cli.test.mjs` asserts both directions — every
45
+ * dispatched command appears here, and every scope named here is in
46
+ * CLI_SCOPES — so a new command whose scope nobody requested cannot ship. That
47
+ * check is the thing that would have caught `approval` 403ing on a fresh
48
+ * pairing for as long as it did.
49
+ *
50
+ * An empty array means the command touches no agent-authenticated route.
51
+ */
52
+ export const COMMAND_SCOPES = {
53
+ ping: ['pingroom:broadcast:send', 'pingroom:attachments:write'],
54
+ ask: ['pingroom:questions:ask'],
55
+ watch: ['pingroom:questions:ask'],
56
+ await: ['pingroom:questions:ask'],
57
+ cancel: ['pingroom:questions:ask'],
58
+ list: ['pingroom:questions:ask'],
59
+ approval: ['pingroom:questions:ask'],
60
+ handoff: ['pingroom:handoffs:create'],
61
+ handoffs: ['pingroom:handoffs:create'],
62
+ activate: ['pingroom:handoffs:create'],
63
+ listen: ['pingroom:notifications:read'],
64
+ hook: ['pingroom:questions:ask', 'pingroom:broadcast:send'],
65
+ live: ['pingroom:live:write'],
66
+ rooms: [
67
+ 'pingroom:rooms:read', 'pingroom:rooms:write',
68
+ 'pingroom:rooms:publish', 'pingroom:rooms:join',
69
+ ],
70
+ actions: ['pingroom:rooms:read', 'pingroom:actions:write', 'pingroom:actions:trigger'],
71
+ webhooks: ['pingroom:webhooks:read', 'pingroom:webhooks:write', 'pingroom:webhooks:delete'],
72
+ attachment: ['pingroom:notifications:read', 'pingroom:attachments:write'],
73
+ // Local-only: no agent-authenticated route, so nothing to request.
74
+ mcp: [],
75
+ skills: [],
76
+ config: [],
77
+ logout: [],
78
+ reconnect: [],
79
+ };
80
+
81
+ /**
82
+ * Scopes a command needs that this credential was never granted.
83
+ *
84
+ * Returns [] whenever the answer is not knowable — no stored scope list at all
85
+ * (a PINGROOM_TOKEN in CI has none), or an unrecognized command. That is the
86
+ * whole contract: this is a courtesy that turns a 403 into a sentence naming
87
+ * the fix, never a gate. Refusing on unknown scopes would break every CI job
88
+ * holding a perfectly good token.
89
+ */
90
+ export function missingScopesFor(command, grantedScopes) {
91
+ if (!Array.isArray(grantedScopes) || grantedScopes.length === 0) return [];
92
+ const needed = COMMAND_SCOPES[command];
93
+ if (!needed || needed.length === 0) return [];
94
+ return needed.filter((scope) => !grantedScopes.includes(scope));
95
+ }
@@ -0,0 +1,153 @@
1
+ // "A newer @pingroom/cli is available" — the one piece of output this tool
2
+ // prints that nobody asked for. Everything here exists to make that safe.
3
+ //
4
+ // The hard rule: this must never change what a command does. Not its exit code,
5
+ // not its stdout, not whether it succeeds. A version notice that breaks a
6
+ // deploy pipeline is worse than never shipping the notice at all, so every
7
+ // failure path below is a silent return.
8
+
9
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { join } from 'node:path';
12
+
13
+ import { pingroomHome, readJsonFile } from './config.js';
14
+
15
+ const REGISTRY_URL = 'https://registry.npmjs.org/@pingroom/cli/latest';
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+
18
+ // Awaited before exit, so this is a real ceiling on how long a successful
19
+ // command can be delayed by a check nobody asked for. Measured round trips to
20
+ // the registry ran 0.63-1.19s warm, so a tighter budget (1.2s was tried) times
21
+ // out often enough to look like "there is never an update".
22
+ const TIMEOUT_MS = 2500;
23
+
24
+ export function updateCachePath() { return join(pingroomHome(), 'update-check.json'); }
25
+
26
+ /**
27
+ * Write the cache, or give up without a word.
28
+ *
29
+ * Deliberately NOT config.js's writeJsonFile: that one calls fail() on an
30
+ * unwritable path, and fail() calls process.exit() — which no try/catch can
31
+ * intercept. Borrowing it here would mean a read-only or full ~/.pingroom turns
32
+ * an advisory version check into the thing that kills the operator's ping. The
33
+ * write is still atomic (temp file + rename) so a crash mid-write cannot leave
34
+ * a torn file for the next run to parse.
35
+ */
36
+ function writeCacheQuietly(path, value) {
37
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
38
+ try {
39
+ mkdirSync(pingroomHome(), { recursive: true, mode: 0o700 });
40
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
41
+ renameSync(tmp, path);
42
+ } catch {
43
+ try { unlinkSync(tmp); } catch { /* never created */ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Why an env var AND a TTY test:
49
+ *
50
+ * `CI` covers the graders that set it (GitHub Actions, GitLab, CircleCI). The
51
+ * TTY test covers everything else — cron, systemd units, Docker builds, a
52
+ * pipeline that forgot to set CI, and `pingroom ... | jq`. Neither alone is
53
+ * enough, and the notice is worthless to a machine either way.
54
+ */
55
+ function suppressed() {
56
+ if (process.env.PINGROOM_NO_UPDATE_CHECK === '1') return true;
57
+ if (process.env.CI) return true;
58
+ if (process.env.NODE_ENV === 'test') return true;
59
+ return !process.stdout.isTTY || !process.stderr.isTTY;
60
+ }
61
+
62
+ /**
63
+ * Compare two dotted release numbers. Returns true when `candidate` is strictly
64
+ * newer than `current`.
65
+ *
66
+ * Anything carrying a prerelease or build suffix (`-beta.1`, `+sha`) is refused
67
+ * outright rather than guessed at: npm's `latest` tag should never point at one,
68
+ * and a wrong guess here nags every single run. Only the numeric release line is
69
+ * compared, and only when both sides parse.
70
+ */
71
+ export function isNewer(candidate, current) {
72
+ const parse = (value) => {
73
+ if (typeof value !== 'string') return null;
74
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
75
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
76
+ };
77
+ const a = parse(candidate);
78
+ const b = parse(current);
79
+ if (!a || !b) return false;
80
+ for (let i = 0; i < 3; i++) {
81
+ if (a[i] > b[i]) return true;
82
+ if (a[i] < b[i]) return false;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * Fetch the published `latest` version, or null for any failure at all — no
89
+ * network, DNS refusal, a 5xx, a proxy returning HTML, a body without a version
90
+ * string. The caller cannot distinguish these and should not try to.
91
+ */
92
+ async function fetchLatest() {
93
+ try {
94
+ // Plain application/json, NOT npm's abbreviated-metadata type: that one is
95
+ // only accepted on the full packument, and asking for it here gets a 406
96
+ // that this function would swallow into a permanent "no update available".
97
+ // The single-version document is ~1.5 KB, smaller than the packument the
98
+ // abbreviated type would have saved us from.
99
+ const res = await fetch(REGISTRY_URL, {
100
+ headers: { Accept: 'application/json' },
101
+ signal: AbortSignal.timeout(TIMEOUT_MS),
102
+ });
103
+ if (!res.ok) return null;
104
+ const json = await res.json();
105
+ return typeof json?.version === 'string' ? json.version : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check at most once every 24h and print a notice when a newer release exists.
113
+ *
114
+ * The cache timestamp is written on every completed check, including one that
115
+ * found nothing and one whose fetch failed. Writing it only on success would
116
+ * turn an offline machine into a machine that retries the registry on every
117
+ * single invocation.
118
+ *
119
+ * Note this is awaited by the caller rather than detached: the CLI ends in an
120
+ * explicit process.exit(), which would kill a floating promise mid-flight and
121
+ * leave the cache unwritten — so a detached check would re-fetch forever while
122
+ * appearing to cost nothing.
123
+ */
124
+ export async function maybeNotifyUpdate(currentVersion) {
125
+ try {
126
+ if (suppressed()) return;
127
+
128
+ const path = updateCachePath();
129
+ const cached = readJsonFile(path);
130
+ const checkedAt = Number(cached?.checked_at);
131
+ const fresh = Number.isFinite(checkedAt) && Date.now() - checkedAt < CHECK_INTERVAL_MS;
132
+
133
+ // Inside the window, still report what the last check found: the notice
134
+ // should persist until the operator actually upgrades, not appear once a day
135
+ // and vanish.
136
+ const latest = fresh ? cached?.latest : await fetchLatest();
137
+ if (!fresh) {
138
+ writeCacheQuietly(path, { checked_at: Date.now(), latest: latest ?? null });
139
+ }
140
+
141
+ if (typeof latest !== 'string' || !isNewer(latest, currentVersion)) return;
142
+
143
+ process.stderr.write(
144
+ `\nnote: @pingroom/cli ${latest} is available (you have ${currentVersion})\n`
145
+ + ' npm i -g @pingroom/cli\n'
146
+ + ' set PINGROOM_NO_UPDATE_CHECK=1 to silence this\n',
147
+ );
148
+ } catch {
149
+ // Unreachable by design — every step above already swallows its own
150
+ // failures. This is the backstop that guarantees the promise this function
151
+ // returns can never reject into the caller's exit path.
152
+ }
153
+ }
package/lib/util.js CHANGED
@@ -73,6 +73,24 @@ export function resolveWaitHold(args, { def, cap }) {
73
73
  return Math.min(hold, cap);
74
74
  }
75
75
 
76
+ /**
77
+ * Validate --idempotency-key and stamp it on the outgoing headers.
78
+ *
79
+ * Creating a human gate is the one CLI write a caller genuinely wants to replay
80
+ * after an ambiguous transport failure, so the key must survive a shell round
81
+ * trip intact: printable ASCII, no spaces. The server returns 409 if the same
82
+ * key is ever reused for a different payload.
83
+ */
84
+ export function applyIdempotencyKey(args, headers) {
85
+ if (args.idempotency_key === undefined) return headers;
86
+ const key = String(args.idempotency_key);
87
+ if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
88
+ fail('--idempotency-key must be 1–255 printable ASCII characters without spaces', EXIT.USAGE);
89
+ }
90
+ headers['Idempotency-Key'] = key;
91
+ return headers;
92
+ }
93
+
76
94
  export function numberOption(raw, flag, { min, max, integer = false } = {}) {
77
95
  if (raw === undefined) return undefined;
78
96
  const value = Number(raw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.7.6",
3
+ "version": "0.8.1",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {