@pingroom/cli 0.7.3 → 0.7.5

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
@@ -153,12 +153,15 @@ URL (it embeds its own secret — treat it like a password and store it as a CI
153
153
  ```
154
154
  pingroom ping [options]
155
155
 
156
- -m, --message <text> Ping body text (required)
156
+ -m, --message <text> Ping body (required; <= 120 private / <= 160 public)
157
157
  -t, --title <text> Ping title (<= 40 chars)
158
158
  -a, --action <1-4> Quick-action slot to attribute the ping to
159
159
  -d, --data <json> Extra JSON data, e.g. '{"commit":"abc123"}'
160
160
  --url <https-url> Make the ping a tappable link (absolute http(s) URL)
161
161
  --button-label <t> Link button text (<= 26 chars; requires --url)
162
+ --location <lat,lng> Attach a map location (latitude,longitude)
163
+ --location-label <t> Location label (<= 100 chars; requires --location)
164
+ --location-address <t> Address (<= 255 chars; requires --location)
162
165
  --require-ack Keep the ping open until an eligible recipient acknowledges it
163
166
  --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
164
167
  --attach <path> Attach a file; repeat for up to 4 (requires --token)
@@ -169,6 +172,28 @@ pingroom ping [options]
169
172
  --json Print the raw JSON response
170
173
  ```
171
174
 
175
+ Ping titles are limited to 40 characters. Bodies are limited to 120 characters
176
+ in private rooms and 160 in public rooms. A room code or webhook URL does not
177
+ reveal room visibility, so the CLI rejects only bodies over 160 locally; the
178
+ server applies the tighter 120-character private-room limit.
179
+
180
+ To send a location, pass decimal latitude and longitude as one comma-separated
181
+ value. Optional map text rides inside the same reserved `data.location` object:
182
+
183
+ ```bash
184
+ pingroom ping --room ab12cd -m "Meet me here" \
185
+ --location "25.2048,55.2708" \
186
+ --location-label "Dubai Mall" \
187
+ --location-address "Downtown Dubai"
188
+ ```
189
+
190
+ Latitude is inclusive -90..90 and longitude is inclusive -180..180. Labels are
191
+ limited to 100 Unicode characters and addresses to 255. The recipient can share
192
+ the point or open it in Waze, Google Maps, Apple Maps, or another installed map
193
+ app. These flags work with agent-token and incoming-webhook sends. When combined
194
+ with `--data`, the explicit flags replace only `data.location`; sibling keys are
195
+ preserved.
196
+
172
197
  To make the ping actionable, add `--require-ack`. The first eligible recipient to
173
198
  acknowledge it wins; `--ack-timeout` optionally expires it if nobody responds:
174
199
 
@@ -370,13 +395,15 @@ Full protocol: <https://pingroom.io/liveactivities.md>
370
395
  run: ./deploy-prod.sh
371
396
 
372
397
  # Or ask the whole room instead of one person. Same outputs, plus question-id.
373
- # Requires v0.7.3 or newer see the note below before copying this block.
398
+ # `scope: room` is what opens it to the room without it a question is
399
+ # answerable only by the connecting account. Requires v0.7.5 or newer.
374
400
  - id: env
375
- uses: pingroom/cli@v0.7.3
401
+ uses: pingroom/cli@v0.7.5
376
402
  with:
377
403
  token: ${{ secrets.PINGROOM_TOKEN }}
378
404
  room: ab12cd
379
405
  ask: 'true'
406
+ scope: 'room'
380
407
  message: 'Which environment?'
381
408
  context: 'build ${{ github.run_number }}'
382
409
  options: |
@@ -392,6 +419,19 @@ Full protocol: <https://pingroom.io/liveactivities.md>
392
419
  including `@v0` before it is moved to v0.7.3 — GitHub treats them as unexpected
393
420
  inputs, warns, and drops them; the step then falls through to the plain `ping`
394
421
  path, `outputs.answer` is never set, and any job gated on it silently proceeds.
422
+
423
+ `scope` was added in **v0.7.5**, and without it every `ask` goes out as a direct
424
+ question to the connecting account — the step still succeeds, so an Action meant
425
+ to poll the room silently polls one person instead.
426
+
427
+ `urgent` shipped in the **0.7.4 npm package**, but no `v0.7.4` Action tag was
428
+ ever cut, so **v0.7.5 is the first tag that carries it**. It has the same failure
429
+ mode on an older pin:
430
+ GitHub drops the unknown input and the Ping goes out at normal priority, which
431
+ looks like it worked. `require-ack` is not a substitute — as of the same release
432
+ it opens the acknowledgement lifecycle without raising the interruption level,
433
+ so a workflow that used it to break through Focus needs `urgent: true`.
434
+
395
435
  Everything else on this page (ping, `require-ack`, and the whole `handoff`
396
436
  family) works on `@v0`.
397
437
 
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
 
3
- import { EXIT } from '../constants.js';
3
+ import { EXIT, PRIVATE_PING_MESSAGE_MAX_LENGTH } from '../constants.js';
4
4
  import { truncate } from '../util.js';
5
5
  import { commandHelp } from '../help.js';
6
6
  import { hookFetch, isSafeUrl } from '../http.js';
@@ -62,7 +62,14 @@ function summarizeTranscript(path) {
62
62
  const msg = entry && entry.message;
63
63
  if (!msg || msg.role !== 'assistant') continue;
64
64
  const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
65
- if (text) return truncate(text, 500);
65
+ // The tighter PRIVATE bound, not the public one. `ping --message` may sit at
66
+ // the public ceiling because the caller typed that text and the server is
67
+ // entitled to reject it. Here the CLI COMPOSES the body, and a hook room is
68
+ // private (max 120), so truncating at 160 would hand the server a body it
69
+ // rejects — and hookPing swallows the 422, silently dropping the ping. The
70
+ // text is already being truncated, so the tighter cut costs nothing and is
71
+ // valid in a public room too.
72
+ if (text) return truncate(text, PRIVATE_PING_MESSAGE_MAX_LENGTH);
66
73
  }
67
74
  return '';
68
75
  }
@@ -185,7 +192,7 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
185
192
  title = 'Claude finished';
186
193
  message = summarizeTranscript(event.transcript_path) || 'Session finished — waiting for you.';
187
194
  } else if (name === 'Notification') {
188
- message = truncate(event.message || 'Claude is waiting for your input.', 500);
195
+ message = truncate(event.message || 'Claude is waiting for your input.', PRIVATE_PING_MESSAGE_MAX_LENGTH);
189
196
  // A PreToolUse hook already turns permission prompts into a question; skip
190
197
  // the duplicate "needs your permission" Notification so you aren't paged twice.
191
198
  if (/permission/i.test(message)) return EXIT.OK;
@@ -193,7 +200,12 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
193
200
  } else if (name === 'SessionEnd') {
194
201
  if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
195
202
  title = 'Session ended';
196
- message = `Claude Code session ended (${event.reason || 'unknown'}).`;
203
+ // `reason` is whatever the host put in the event, so bound the composed
204
+ // line rather than trusting it to stay short.
205
+ message = truncate(
206
+ `Claude Code session ended (${event.reason || 'unknown'}).`,
207
+ PRIVATE_PING_MESSAGE_MAX_LENGTH,
208
+ );
197
209
  } else {
198
210
  return EXIT.OK; // unknown event — stay silent rather than send noise
199
211
  }
@@ -51,7 +51,8 @@ export async function live(args) {
51
51
  state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
52
52
  };
53
53
 
54
- // 256, not the 500 a ping body gets: this is the card's one live line.
54
+ // This live-status payload field has its own 256-character contract; ordinary
55
+ // Ping bodies are 120 in private rooms and 160 in public rooms.
55
56
  requireMaxLength(args.message, 256, '--message');
56
57
  requireMaxLength(args.title, 40, '--title');
57
58
  requireMaxLength(args.prompt, 256, '--prompt');
@@ -1,16 +1,51 @@
1
- import { EXIT } from '../constants.js';
1
+ import {
2
+ EXIT,
3
+ LOCATION_ADDRESS_MAX_LENGTH,
4
+ LOCATION_LABEL_MAX_LENGTH,
5
+ PING_TITLE_MAX_LENGTH,
6
+ PUBLIC_PING_MESSAGE_MAX_LENGTH,
7
+ } from '../constants.js';
2
8
  import { fail, parseDataObject, requireMaxLength } from '../util.js';
3
9
  import { commandHelp } from '../help.js';
4
10
  import { apiDetail, httpJson, requireSafeUrl, uploadAttachments } from '../http.js';
5
11
  import { requireStoredCredentialOrigin, resolveApiBase, resolveRoom, resolveToken } from '../config.js';
6
12
 
13
+ const DECIMAL_COORDINATE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
14
+
15
+ function parseLocation(value) {
16
+ const parts = String(value).split(',');
17
+ if (parts.length !== 2) {
18
+ fail('--location must contain exactly two coordinates formatted "latitude,longitude"', EXIT.USAGE);
19
+ }
20
+
21
+ const [latitudeText, longitudeText] = parts.map((part) => part.trim());
22
+ if (!DECIMAL_COORDINATE.test(latitudeText) || !DECIMAL_COORDINATE.test(longitudeText)) {
23
+ fail('--location coordinates must be finite numbers formatted "latitude,longitude"', EXIT.USAGE);
24
+ }
25
+
26
+ const latitude = Number(latitudeText);
27
+ const longitude = Number(longitudeText);
28
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
29
+ fail('--location coordinates must be finite numbers formatted "latitude,longitude"', EXIT.USAGE);
30
+ }
31
+ if (latitude < -90 || latitude > 90) {
32
+ fail('--location latitude must be between -90 and 90', EXIT.USAGE);
33
+ }
34
+ if (longitude < -180 || longitude > 180) {
35
+ fail('--location longitude must be between -180 and 180', EXIT.USAGE);
36
+ }
37
+ return { latitude, longitude };
38
+ }
39
+
7
40
  export async function ping(args) {
8
41
  if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
9
42
 
10
43
  const message = args.message;
11
44
  if (!message) fail('a --message is required', EXIT.USAGE);
12
- requireMaxLength(message, 500, '--message');
13
- requireMaxLength(args.title, 40, '--title');
45
+ // Room visibility is not encoded in a room code or webhook URL. Validate the
46
+ // public ceiling here; the API applies 120 for private rooms and 160 for public.
47
+ requireMaxLength(message, PUBLIC_PING_MESSAGE_MAX_LENGTH, '--message');
48
+ requireMaxLength(args.title, PING_TITLE_MAX_LENGTH, '--title');
14
49
 
15
50
  if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
16
51
  fail('--action must be an integer 1–4', EXIT.USAGE);
@@ -57,6 +92,23 @@ export async function ping(args) {
57
92
  if (args.button_label !== undefined) data.button_label = args.button_label;
58
93
  }
59
94
 
95
+ // Location ping: explicit flags own the reserved data.location object. They
96
+ // replace a raw --data location while leaving every sibling key untouched.
97
+ if (args.location_label !== undefined && args.location === undefined) {
98
+ fail('--location-label requires --location', EXIT.USAGE);
99
+ }
100
+ if (args.location_address !== undefined && args.location === undefined) {
101
+ fail('--location-address requires --location', EXIT.USAGE);
102
+ }
103
+ if (args.location !== undefined) {
104
+ requireMaxLength(args.location_label, LOCATION_LABEL_MAX_LENGTH, '--location-label');
105
+ requireMaxLength(args.location_address, LOCATION_ADDRESS_MAX_LENGTH, '--location-address');
106
+ const location = parseLocation(args.location);
107
+ if (args.location_label !== undefined) location.label = args.location_label;
108
+ if (args.location_address !== undefined) location.address = args.location_address;
109
+ data = { ...(data || {}), location };
110
+ }
111
+
60
112
  const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
61
113
  const token = resolveToken(args);
62
114
  const apiBase = resolveApiBase(args);
@@ -81,6 +133,7 @@ export async function ping(args) {
81
133
  if (args.action !== undefined) body.action = Number(args.action);
82
134
  if (data) body.data = data;
83
135
  if (args.require_ack) body.requires_ack = true;
136
+ if (args.urgent) body.is_urgent = true;
84
137
  if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
85
138
  result = await httpJson('POST', webhook, { body });
86
139
  } else if (token) {
@@ -96,6 +149,7 @@ export async function ping(args) {
96
149
  if (args.action !== undefined) body.action_number = Number(args.action);
97
150
  if (data) body.data = data;
98
151
  if (args.require_ack) body.requires_ack = true;
152
+ if (args.urgent) body.is_urgent = true;
99
153
  if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
100
154
  if (attachPaths.length) {
101
155
  body.attachment_ids = await uploadAttachments(attachPaths, apiBase, token);
package/lib/constants.js CHANGED
@@ -5,3 +5,15 @@ export const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
5
5
  export const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
6
6
 
7
7
  export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
8
+
9
+ // A caller holding only a room code or webhook URL cannot know the room's
10
+ // visibility without another request. Keep the CLI's local ceiling at the
11
+ // public-room limit; Laravel applies the tighter private-room limit.
12
+ export const PING_TITLE_MAX_LENGTH = 40;
13
+ export const PRIVATE_PING_MESSAGE_MAX_LENGTH = 120;
14
+ export const PUBLIC_PING_MESSAGE_MAX_LENGTH = 160;
15
+
16
+ // Reserved data.location display metadata. Coordinate ranges are part of the
17
+ // numeric format itself; these caps mirror the API's Unicode-aware string rules.
18
+ export const LOCATION_LABEL_MAX_LENGTH = 100;
19
+ export const LOCATION_ADDRESS_MAX_LENGTH = 255;
package/lib/help.js CHANGED
@@ -35,13 +35,20 @@ Commands:
35
35
  logout Forget the stored credential`;
36
36
 
37
37
  export const HELP_PING = `ping options:
38
- -m, --message <text> Ping body text (required)
38
+ -m, --message <text> Ping body (required; <= 120 private / <= 160 public)
39
39
  -t, --title <text> Ping title (<= 40 chars)
40
40
  -a, --action <1-4> Quick-action slot to attribute the ping to
41
41
  -d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
42
42
  --url <https-url> Make the ping a tappable link (absolute http(s) URL)
43
43
  --button-label <t> Link button text (<= 26 chars; requires --url)
44
- --require-ack Keep the ping open until an eligible recipient acknowledges it
44
+ --location <lat,lng> Attach a map location (latitude,longitude)
45
+ --location-label <t> Location label (<= 100 chars; requires --location)
46
+ --location-address <t> Address (<= 255 chars; requires --location)
47
+ --urgent Deliver time-sensitive so it breaks through Focus / Do Not
48
+ Disturb. Delivery only - asks nothing of the recipient
49
+ --require-ack Keep the ping open until an eligible recipient acknowledges it,
50
+ showing a lock-screen card with an Acknowledge button. Does
51
+ not raise the interruption level; combine with --urgent
45
52
  --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
46
53
  --attach <path> Attach a file (md/pdf/html/txt/jpg/jpeg/png/zip, <= 5 MiB);
47
54
  repeat for up to 4. Requires --token and a Pro account
@@ -105,7 +112,7 @@ export const HELP_LIVE = `live <start|update|end|get> options (agent token, or a
105
112
  'question', which is still accepted)
106
113
  --category <name> start only: status | steps | alert. Legacy, but
107
114
  'alert' has no template equivalent and is the only
108
- way to start time-sensitive without --require-ack
115
+ way to start a stream time-sensitive
109
116
  --steps <a,b,c> start only: 2-8 comma-separated step labels
110
117
  -m, --message <text> The card's live message line
111
118
  --progress <0..1> Progress bar / Dynamic Island gauge
@@ -124,7 +131,8 @@ export const HELP_LIVE = `live <start|update|end|get> options (agent token, or a
124
131
  -d, --data <json> Structured data object carried on this frame
125
132
  -t, --title <text> Card title (<= 40 chars)
126
133
  -a, --action <1-4> Quick-action slot supplying the icon and sound
127
- --require-ack Add an Acknowledge button
134
+ --require-ack Add an Acknowledge button (does not raise the
135
+ interruption level; see --category alert)
128
136
  --ack-timeout <s> Ack deadline in seconds
129
137
  --room <code> Room invite code (used with --token)
130
138
  -w, --webhook <url> Room webhook URL instead of a token`;
@@ -202,6 +210,10 @@ Examples:
202
210
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
203
211
  --url https://ci.example.com/builds/512 --button-label "Open build"
204
212
 
213
+ # Location ping — recipients can share it or open it in a maps app:
214
+ pingroom ping --room ab12cd -m "Meet me here" \\
215
+ --location "25.2048,55.2708" --location-label "Dubai Mall"
216
+
205
217
  # Gate a deploy on a human tap — the chosen value prints to stdout:
206
218
  if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
207
219
  -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
package/lib/parser.js CHANGED
@@ -61,7 +61,11 @@ export const parseArgs = makeParser({
61
61
  '-w': 'webhook', '--webhook': 'webhook',
62
62
  '--url': 'url',
63
63
  '--button-label': 'button_label',
64
+ '--location': 'location',
65
+ '--location-label': 'location_label',
66
+ '--location-address': 'location_address',
64
67
  '--require-ack': 'require_ack',
68
+ '--urgent': 'urgent',
65
69
  '--ack-timeout': 'ack_timeout',
66
70
  '--attach': 'attach',
67
71
  '--token': 'token',
@@ -70,7 +74,7 @@ export const parseArgs = makeParser({
70
74
  '--json': 'json',
71
75
  '-h': 'help', '--help': 'help',
72
76
  },
73
- booleans: ['require_ack', 'json', 'help'],
77
+ booleans: ['require_ack', 'urgent', 'json', 'help'],
74
78
  repeatable: ['attach'],
75
79
  });
76
80
 
@@ -159,6 +163,9 @@ export const parseLiveArgs = makeParser({
159
163
  '-a': 'action', '--action': 'action',
160
164
  '-d': 'data', '--data': 'data',
161
165
  '--require-ack': 'require_ack',
166
+ // No --urgent here on purpose. A STREAM starts time-sensitive via
167
+ // `--category alert` (fixed at creation); the live-status endpoint does not
168
+ // accept `is_urgent`, so the flag would parse and then be silently dropped.
162
169
  '--ack-timeout': 'ack_timeout',
163
170
  '-w': 'webhook', '--webhook': 'webhook',
164
171
  '--token': 'token',
package/lib/util.js CHANGED
@@ -39,7 +39,8 @@ export function stripControlChars(value) {
39
39
 
40
40
  export function truncate(value, max) {
41
41
  const str = String(value ?? '');
42
- return str.length <= max ? str : `${str.slice(0, max - 1)}…`;
42
+ const characters = Array.from(str);
43
+ return characters.length <= max ? str : `${characters.slice(0, max - 1).join('')}…`;
43
44
  }
44
45
 
45
46
  /**
@@ -51,8 +52,11 @@ export function truncate(value, max) {
51
52
  * it is, with the limit and the actual length named.
52
53
  */
53
54
  export function requireMaxLength(value, max, flag) {
54
- if (typeof value === 'string' && value.length > max) {
55
- fail(`${flag} must be at most ${max} characters (got ${value.length})`, EXIT.USAGE);
55
+ if (typeof value === 'string') {
56
+ const length = Array.from(value).length;
57
+ if (length > max) {
58
+ fail(`${flag} must be at most ${max} characters (got ${length})`, EXIT.USAGE);
59
+ }
56
60
  }
57
61
  }
58
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {