@pingroom/cli 0.8.0 → 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`
package/bin/pingroom.js CHANGED
@@ -43,7 +43,8 @@ import { HELP } from '../lib/help.js';
43
43
  import { maybeNotifyUpdate } from '../lib/update-check.js';
44
44
  import {
45
45
  parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
46
- parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseSkillsArgs,
46
+ parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseReconnectArgs,
47
+ parseSkillsArgs,
47
48
  } from '../lib/parser.js';
48
49
  import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
49
50
  import { ping } from '../lib/commands/ping.js';
@@ -54,7 +55,7 @@ import { live } from '../lib/commands/live.js';
54
55
  import { hook } from '../lib/commands/hook.js';
55
56
  import { mcp } from '../lib/commands/mcp.js';
56
57
  import { skills } from '../lib/commands/skills.js';
57
- import { activateStoredInbox, bare } from '../lib/commands/connect.js';
58
+ import { activateStoredInbox, bare, reconnect } from '../lib/commands/connect.js';
58
59
  import { config, logout } from '../lib/commands/config.js';
59
60
 
60
61
  const COMMANDS = {
@@ -78,6 +79,7 @@ const COMMANDS = {
78
79
  approval: (rest) => approval(parseManageArgs(rest)),
79
80
  attachment: (rest) => attachment(parseManageArgs(rest)),
80
81
  config: (rest) => config(parseConfigArgs(rest)),
82
+ reconnect: (rest) => reconnect(parseReconnectArgs(rest)),
81
83
  logout: (rest) => logout(parseLogoutArgs(rest)),
82
84
  };
83
85
 
@@ -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,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
@@ -38,6 +38,7 @@ Commands:
38
38
  approval Send an approve/deny request; with --wait, block on the decision
39
39
  attachment Download or delete an attachment by id (attachment get|delete)
40
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
41
42
  logout Forget the stored credential`;
42
43
 
43
44
  export const HELP_PING = `ping options:
@@ -332,18 +333,34 @@ export const COMMAND_HELP_SECTIONS = {
332
333
  pingroom actions trigger <1-4> --room <code>`,
333
334
  approval: `approval (agent token + --room required):
334
335
  pingroom approval -p <prompt> Send an approve/deny request
336
+ Prints the id; --wait prints the decision
335
337
  -c, --context <text> Secondary line (<= 40 chars)
336
338
  --ttl <seconds> Expiry; omit for the server default
337
- --wait Block until approved/denied/expired
338
- (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)`,
339
343
  attachment: `attachment (agent token required):
340
344
  pingroom attachment get <id> [--out <path>]
341
345
  Download; bytes go to --out or stdout
342
346
  pingroom attachment delete <id> Delete an unclaimed upload`,
343
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)`,
344
357
  logout: `logout:
345
358
  pingroom logout Forget the stored credential (PINGROOM_TOKEN
346
- 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.`,
347
364
  };
348
365
 
349
366
  // config and logout are local-only commands that reject --token/--api (and,
@@ -355,6 +372,10 @@ export const COMMAND_HELP_FOOTERS = {
355
372
  -h, --help Show this help`,
356
373
  logout: `Shared:
357
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`,
358
379
  // `skills` reaches GitHub, never the PingRoom API, so the shared credential
359
380
  // and --json flags would all be lies here.
360
381
  skills: `Shared:
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'],
@@ -247,6 +256,8 @@ export const parseManageArgs = makeParser({
247
256
  '--require-ack': 'require_ack',
248
257
  '--out': 'out',
249
258
  '--wait': 'wait',
259
+ '--timeout': 'timeout',
260
+ '--idempotency-key': 'idempotency_key',
250
261
  '--token': 'token',
251
262
  '--room': 'room',
252
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
+ }
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.8.0",
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": {