@pingroom/cli 0.1.0 → 0.4.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -2
  3. package/bin/pingroom.js +1025 -48
  4. package/package.json +5 -2
package/bin/pingroom.js CHANGED
@@ -1,39 +1,156 @@
1
1
  #!/usr/bin/env node
2
- // @pingroom/cli — send a PingRoom ping from CI, a script, or an agent.
2
+ // @pingroom/cli — pings and human-in-the-loop questions for CI, scripts, agents.
3
3
  // Zero dependencies: uses Node's built-in fetch (Node >= 20).
4
4
  //
5
- // Two delivery modes:
6
- // 1. Webhook (best for CI) a room webhook URL carries its own secret, so no
7
- // browser auth is needed. `pingroom ping -w <url> -m "deploy ok"`.
8
- // 2. Agent token a Bearer credential + room invite code hits the agent API.
9
- // `pingroom ping --token <t> --room <code> -m "deploy ok"`.
5
+ // Commands:
6
+ // ping Send a ping to a room. Webhook mode (a room URL carries its own
7
+ // secret best for CI) or agent-token mode (Bearer + room code).
8
+ // ask Ask a human a question in a room and, with --wait, block until they
9
+ // tap an answer turning a human decision into a shell gate.
10
+ // watch Block until a question resolves and print the outcome.
11
+ // list List the agent's questions by state.
12
+ // cancel Withdraw a pending question.
13
+ // handoff Hand a decision to a specific human (ack or question) and, with
14
+ // --wait, block until they acknowledge / answer.
15
+ // handoffs List the agent's open handoffs or bounded recent history.
16
+ //
17
+ // Exit codes: 0 success/answered/acked · 1 error · 2 bad usage · 3 expired ·
18
+ // 4 cancelled/recipient-not-ready.
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+ import { appendFileSync, readFileSync } from 'node:fs';
22
+
23
+ // Kept in lockstep with package.json / package-lock.json / action.yml (a test
24
+ // asserts the GitHub Action pins this exact version). `hook --print-config`
25
+ // emits an `npx @pingroom/cli@<VERSION>` command, so it must match too.
26
+ const VERSION = '0.4.0';
10
27
 
11
28
  const DEFAULT_API = process.env.PINGROOM_API_URL || 'https://api.pingroom.io';
12
29
 
13
- const HELP = `pingroom — send a ping to a PingRoom room
30
+ const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
14
31
 
15
32
  Usage:
16
- pingroom ping [options]
33
+ pingroom <command> [options]
17
34
 
18
- Options:
35
+ Commands:
36
+ ping Send a ping to a room (webhook URL, or agent token + room)
37
+ ask Ask a human a question; with --wait, block until they answer
38
+ watch Block until a question resolves and print the outcome
39
+ list List the agent's questions by state
40
+ cancel Withdraw a pending question
41
+ handoff Hand a decision (ack or question) to a specific human; with --wait,
42
+ block until they acknowledge or answer
43
+ handoffs List the agent's open handoffs or bounded recent history
44
+ hook Claude Code hook: ping on Stop/Notification, and route tool
45
+ permission prompts to a PingRoom question you answer from your phone
46
+
47
+ ping options:
19
48
  -m, --message <text> Ping body text (required)
20
49
  -t, --title <text> Ping title (<= 40 chars)
21
50
  -a, --action <1-4> Quick-action slot to attribute the ping to
22
51
  -d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
52
+ --require-ack Keep the ping open until an eligible recipient acknowledges it
53
+ --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
23
54
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
24
55
  --token <token> Agent access token (or env PINGROOM_TOKEN)
25
56
  --room <code> Room invite code (used with --token)
57
+
58
+ ask options (agent token required):
59
+ -p, --prompt <text> The question a human reads (required)
60
+ -o, --option <v:label> An answer option; repeat for 2–4. Omit for Approve/Deny
61
+ -c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
62
+ --scope <s> Who answers: 'direct' (default) or 'room'
63
+ --target <uuid> For --scope direct: a specific room member
64
+ --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
65
+ --wait Block until answered/expired/cancelled
66
+ --timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
67
+ -d, --data <json> Structured data object echoed back on the answer
68
+ --correlation-id <id> Opaque id echoed on every read of this question
69
+ --room <code> Room invite code (required for ask)
70
+
71
+ list options:
72
+ --state <s> pending | answered | expired | cancelled | all
73
+
74
+ handoff options (agent token required; consent scope pingroom:handoffs:create):
75
+ -m, --message <text> The prompt a human reads (required)
76
+ --question Make it a question (else a simple acknowledge). Also
77
+ implied whenever one or more --option is given.
78
+ -o, --option <v:label> A question option; repeat for 2–4. Requires --question.
79
+ --target <id> Recipient: 'me' (default) or a specific user uuid
80
+ --expires-in <s> Expiry in seconds (120..86400, default 900)
81
+ --urgency <u> 'active' (default) or 'passive'
82
+ --idempotency-key <key> Dedupe key; retries reuse it (Idempotency-Key)
83
+ --correlation-id <id> Opaque id echoed on every read of this handoff
84
+ --reply-to <id> Opaque reply-to id echoed back
85
+ -d, --data <json> Structured data object echoed on the handoff
86
+ --wait Block until acked / answered / expired / cancelled
87
+ --timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
88
+ --github-output <path> Safely append handoff outputs for GitHub Actions
89
+
90
+ handoffs options (agent token required; consent scope pingroom:handoffs:create):
91
+ --state <s> open | all (default open)
92
+
93
+ hook options (agent token required; reads a Claude Code hook event on stdin):
94
+ --room <code> Room invite code (or env PINGROOM_ROOM)
95
+ --ttl <seconds> Approval-question expiry for PreToolUse (default 900)
96
+ --quiet Suppress the informational stderr lines
97
+ --print-config Print a ready-to-paste ~/.claude/settings.json block
98
+
99
+ Shared:
100
+ --token <token> Agent access token (or env PINGROOM_TOKEN)
26
101
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
27
102
  --json Print the raw JSON response
28
103
  -h, --help Show this help
29
104
 
30
105
  Examples:
31
106
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
32
- pingroom ping -w "$PINGROOM_WEBHOOK_URL" -t "CI" -m "Build #42 failed" -a 2
33
- pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped" \\
34
- -d '{"version":"1.4.0","env":"prod"}'
107
+ pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
108
+
109
+ # Gate a deploy on a human tap — the chosen value prints to stdout:
110
+ if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
111
+ -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
112
+
113
+ # Multi-option question, blocking:
114
+ pingroom ask --token "$T" --room ab12cd --scope room --wait \\
115
+ -p 'Which environment?' -o prod:Production -o staging:Staging
116
+
117
+ pingroom list --token "$T" --state pending
118
+ pingroom watch --token "$T" q_01H... # block on an existing question
119
+ pingroom cancel --token "$T" q_01H...
120
+
121
+ # Hand a deploy decision to yourself and block on the acknowledgement:
122
+ pingroom handoff --token "$T" -m "Prod deploy 1.4.0 — ack to proceed" --wait
123
+
124
+ # A blocking question handed to a specific human; branch in CI on exit code:
125
+ pingroom handoff --token "$T" -m "Ship 1.4.0?" --question \\
126
+ -o deploy:Deploy -o hold:Hold --wait
127
+ # -> exit 0 (answered, any value incl. 'hold'); 3 expired; 4 recipient-not-ready
128
+
129
+ pingroom handoffs --token "$T" --state all # recent history (up to 200/kind)
35
130
 
36
- Exit codes: 0 on success, 1 on delivery failure, 2 on bad usage.`;
131
+ # Connect Claude Code to your phone (prints the settings.json to paste):
132
+ pingroom hook --print-config
133
+
134
+ Security:
135
+ Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
136
+ secrets as --webhook / --token flags: argv is visible to other users via the
137
+ process table (ps) and may be captured in shell history. URLs must use https
138
+ (loopback http is allowed for local dev).
139
+
140
+ Exit codes: 0 on success (answered / acked), 1 on error (network/auth/5xx),
141
+ 2 on bad usage, 3 when a handoff or question expired, 4 when it was cancelled
142
+ or the recipient was not ready (409 recipient_not_ready). A question answered
143
+ with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
144
+ human decision is not an infrastructure failure.`;
145
+
146
+ const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
147
+
148
+ function fail(message, code = EXIT.ERROR) {
149
+ process.stderr.write(`pingroom: ${message}\n`);
150
+ process.exit(code);
151
+ }
152
+
153
+ // --- ping (unchanged wire behaviour) ---------------------------------------
37
154
 
38
155
  function parseArgs(argv) {
39
156
  const args = { _: [] };
@@ -43,24 +160,29 @@ function parseArgs(argv) {
43
160
  '-a': 'action', '--action': 'action',
44
161
  '-d': 'data', '--data': 'data',
45
162
  '-w': 'webhook', '--webhook': 'webhook',
163
+ '--require-ack': 'require_ack',
164
+ '--ack-timeout': 'ack_timeout',
46
165
  '--token': 'token',
47
166
  '--room': 'room',
48
167
  '--api': 'api',
49
168
  '--json': 'json',
50
169
  '-h': 'help', '--help': 'help',
51
170
  };
171
+ const booleans = new Set(['require_ack', 'json', 'help']);
52
172
 
53
173
  for (let i = 0; i < argv.length; i++) {
54
174
  const token = argv[i];
55
- if (token === '--json' || token === '-h' || token === '--help') {
56
- args[alias[token]] = true;
57
- continue;
58
- }
59
175
  const key = alias[token];
60
- if (key) {
61
- args[key] = argv[++i];
176
+ if (key && booleans.has(key)) {
177
+ args[key] = true;
178
+ } else if (key) {
179
+ const value = argv[++i];
180
+ if (value === undefined) {
181
+ fail(`option ${token} needs a value`, EXIT.USAGE);
182
+ }
183
+ args[key] = value;
62
184
  } else if (token.startsWith('-')) {
63
- fail(`Unknown option: ${token}`, 2);
185
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
64
186
  } else {
65
187
  args._.push(token);
66
188
  }
@@ -68,18 +190,145 @@ function parseArgs(argv) {
68
190
  return args;
69
191
  }
70
192
 
71
- function fail(message, code = 1) {
72
- process.stderr.write(`pingroom: ${message}\n`);
73
- process.exit(code);
193
+ // Parser for the question commands: supports repeatable --option and a trailing
194
+ // positional (a question id). Unknown flags fail like the ping parser.
195
+ function parseQArgs(argv) {
196
+ const args = { _: [] };
197
+ const alias = {
198
+ '-p': 'prompt', '--prompt': 'prompt',
199
+ '-o': 'option', '--option': 'option',
200
+ '-c': 'context', '--context': 'context',
201
+ '--scope': 'scope',
202
+ '--target': 'target',
203
+ '--ttl': 'ttl',
204
+ '-d': 'data', '--data': 'data',
205
+ '--correlation-id': 'correlation_id',
206
+ '--timeout': 'timeout',
207
+ '--state': 'state',
208
+ '--token': 'token',
209
+ '--room': 'room',
210
+ '--api': 'api',
211
+ '--wait': 'wait',
212
+ '--json': 'json',
213
+ '-h': 'help', '--help': 'help',
214
+ };
215
+ const booleans = new Set(['wait', 'json', 'help']);
216
+ const multi = new Set(['option']);
217
+
218
+ for (let i = 0; i < argv.length; i++) {
219
+ const token = argv[i];
220
+ const key = alias[token];
221
+ if (key && booleans.has(key)) {
222
+ args[key] = true;
223
+ } else if (key) {
224
+ const value = argv[++i];
225
+ if (value === undefined) {
226
+ fail(`option ${token} needs a value`, EXIT.USAGE);
227
+ }
228
+ if (multi.has(key)) {
229
+ (args[key] ||= []).push(value);
230
+ } else {
231
+ args[key] = value;
232
+ }
233
+ } else if (token.startsWith('-') && token !== '-') {
234
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
235
+ } else {
236
+ args._.push(token);
237
+ }
238
+ }
239
+ return args;
240
+ }
241
+
242
+ // Parser for `handoff`: --message plus repeatable --option, boolean --question,
243
+ // and the handoff-specific flags. Unknown flags fail like the other parsers.
244
+ function parseHandoffArgs(argv) {
245
+ const args = { _: [] };
246
+ const alias = {
247
+ '-m': 'message', '--message': 'message',
248
+ '--question': 'question',
249
+ '-o': 'option', '--option': 'option',
250
+ '--target': 'target',
251
+ '--expires-in': 'expires_in',
252
+ '--urgency': 'urgency',
253
+ '--idempotency-key': 'idempotency_key',
254
+ '--correlation-id': 'correlation_id',
255
+ '--reply-to': 'reply_to',
256
+ '-d': 'data', '--data': 'data',
257
+ '--timeout': 'timeout',
258
+ '--github-output': 'github_output',
259
+ '--token': 'token',
260
+ '--api': 'api',
261
+ '--wait': 'wait',
262
+ '--json': 'json',
263
+ '-h': 'help', '--help': 'help',
264
+ };
265
+ const booleans = new Set(['question', 'wait', 'json', 'help']);
266
+ const multi = new Set(['option']);
267
+
268
+ for (let i = 0; i < argv.length; i++) {
269
+ const token = argv[i];
270
+ const key = alias[token];
271
+ if (key && booleans.has(key)) {
272
+ args[key] = true;
273
+ } else if (key) {
274
+ const value = argv[++i];
275
+ if (value === undefined) {
276
+ fail(`option ${token} needs a value`, EXIT.USAGE);
277
+ }
278
+ if (multi.has(key)) {
279
+ (args[key] ||= []).push(value);
280
+ } else {
281
+ args[key] = value;
282
+ }
283
+ } else if (token.startsWith('-') && token !== '-') {
284
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
285
+ } else {
286
+ args._.push(token);
287
+ }
288
+ }
289
+ return args;
290
+ }
291
+
292
+ // Refuse to send a bearer token or webhook secret over cleartext http. A
293
+ // loopback host is allowed so local dev against http://localhost still works.
294
+ function requireSafeUrl(kind, raw) {
295
+ let u;
296
+ try {
297
+ u = new URL(raw);
298
+ } catch {
299
+ fail(`${kind} is not a valid URL`, EXIT.USAGE);
300
+ }
301
+ const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
302
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopback)) {
303
+ fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
304
+ }
305
+ return raw;
306
+ }
307
+
308
+ function parseDataObject(raw) {
309
+ let data;
310
+ try {
311
+ data = JSON.parse(raw);
312
+ } catch {
313
+ fail('--data must be valid JSON', EXIT.USAGE);
314
+ }
315
+ if (typeof data !== 'object' || Array.isArray(data) || data === null) {
316
+ fail('--data must be a JSON object', EXIT.USAGE);
317
+ }
318
+ return data;
74
319
  }
75
320
 
76
- async function postJson(url, body, headers = {}) {
321
+ async function httpJson(method, url, { body, headers = {} } = {}) {
77
322
  let res;
78
323
  try {
79
324
  res = await fetch(url, {
80
- method: 'POST',
81
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
82
- body: JSON.stringify(body),
325
+ method,
326
+ headers: {
327
+ Accept: 'application/json',
328
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
329
+ ...headers,
330
+ },
331
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
83
332
  });
84
333
  } catch (err) {
85
334
  fail(`network error: ${err.message}`);
@@ -93,25 +342,29 @@ async function postJson(url, body, headers = {}) {
93
342
  }
94
343
 
95
344
  async function ping(args) {
96
- if (args.help) { process.stdout.write(`${HELP}\n`); return 0; }
345
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
97
346
 
98
347
  const message = args.message;
99
- if (!message) fail('a --message is required', 2);
348
+ if (!message) fail('a --message is required', EXIT.USAGE);
100
349
 
101
350
  if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
102
- fail('--action must be an integer 1–4', 2);
351
+ fail('--action must be an integer 1–4', EXIT.USAGE);
103
352
  }
104
353
 
105
- let data;
106
- if (args.data !== undefined) {
107
- try {
108
- data = JSON.parse(args.data);
109
- } catch {
110
- fail('--data must be valid JSON', 2);
354
+ let ackTimeout;
355
+ if (args.ack_timeout !== undefined) {
356
+ if (!args.require_ack) {
357
+ fail('--ack-timeout requires --require-ack', EXIT.USAGE);
111
358
  }
112
- if (typeof data !== 'object' || Array.isArray(data) || data === null) {
113
- fail('--data must be a JSON object', 2);
359
+ if (!/^\d+$/.test(String(args.ack_timeout))) {
360
+ fail('--ack-timeout must be an integer number of seconds', EXIT.USAGE);
114
361
  }
362
+ ackTimeout = Number(args.ack_timeout);
363
+ }
364
+
365
+ let data;
366
+ if (args.data !== undefined) {
367
+ data = parseDataObject(args.data);
115
368
  }
116
369
 
117
370
  const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
@@ -121,21 +374,33 @@ async function ping(args) {
121
374
  let result;
122
375
 
123
376
  if (webhook) {
377
+ if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
378
+ fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
379
+ }
380
+ requireSafeUrl('--webhook', webhook);
124
381
  const body = { message };
125
382
  if (args.title) body.title = args.title;
126
383
  if (args.action !== undefined) body.action = Number(args.action);
127
384
  if (data) body.data = data;
128
- result = await postJson(webhook, body);
385
+ if (args.require_ack) body.requires_ack = true;
386
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
387
+ result = await httpJson('POST', webhook, { body });
129
388
  } else if (token) {
130
- if (!args.room) fail('--room is required when using --token', 2);
389
+ if (!args.room) fail('--room is required when using --token', EXIT.USAGE);
390
+ if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
391
+ fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
392
+ }
393
+ requireSafeUrl('--api', apiBase);
131
394
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(args.room)}/notifications`;
132
395
  const body = { message };
133
396
  if (args.title) body.title = args.title;
134
397
  if (args.action !== undefined) body.action_number = Number(args.action);
135
398
  if (data) body.data = data;
136
- result = await postJson(url, body, { Authorization: `Bearer ${token}` });
399
+ if (args.require_ack) body.requires_ack = true;
400
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
401
+ result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
137
402
  } else {
138
- fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', 2);
403
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
139
404
  }
140
405
 
141
406
  const { res, text, json } = result;
@@ -152,7 +417,718 @@ async function ping(args) {
152
417
  }
153
418
 
154
419
  if (!args.json) process.stdout.write('ping sent ✅\n');
155
- return 0;
420
+ return EXIT.OK;
421
+ }
422
+
423
+ // --- questions -------------------------------------------------------------
424
+
425
+ function agentContext(args, { needRoom = false } = {}) {
426
+ const token = args.token || process.env.PINGROOM_TOKEN;
427
+ if (!token) fail('an agent token is required (--token or PINGROOM_TOKEN)', EXIT.USAGE);
428
+ const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
429
+ requireSafeUrl('--api', apiBase);
430
+ if (needRoom && !args.room) fail('--room is required', EXIT.USAGE);
431
+ return { token, apiBase, room: args.room };
432
+ }
433
+
434
+ // value:label -> {value, label}. Labels may contain colons (only the first
435
+ // splits). A bare token is both value and label. Omit all for Approve/Deny.
436
+ function buildOptions(list) {
437
+ if (!list || list.length === 0) return undefined;
438
+ return list.map((spec) => {
439
+ const idx = spec.indexOf(':');
440
+ const value = idx === -1 ? spec : spec.slice(0, idx);
441
+ const label = idx === -1 ? spec : spec.slice(idx + 1);
442
+ if (!value) fail(`--option must be "value" or "value:label" (got "${spec}")`, EXIT.USAGE);
443
+ return { value, label };
444
+ });
445
+ }
446
+
447
+ function exitForState(state) {
448
+ switch (state) {
449
+ case 'answered': return EXIT.OK;
450
+ case 'expired': return EXIT.EXPIRED;
451
+ case 'cancelled': return EXIT.CANCELLED;
452
+ default: return EXIT.ERROR;
453
+ }
454
+ }
455
+
456
+ // Print the outcome. On `answered`, the chosen value (or typed text) goes to
457
+ // stdout so `$(pingroom ask --wait ...)` captures it; other outcomes report to
458
+ // stderr and leave stdout empty.
459
+ function printResolution(q) {
460
+ if (q.state === 'answered') {
461
+ const out = q.answer && (q.answer.text || q.answer.value) || '';
462
+ process.stdout.write(`${out}\n`);
463
+ } else {
464
+ process.stderr.write(`pingroom: question ${q.state}\n`);
465
+ }
466
+ }
467
+
468
+ // Long-poll the wait endpoint until the question leaves `pending`, then print
469
+ // and return the state's exit code. The server expires it at its ttl, so this
470
+ // always terminates.
471
+ async function waitForResolution(id, args, { token, apiBase }) {
472
+ let hold = args.timeout !== undefined ? Number(args.timeout) : 25;
473
+ if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
474
+ hold = Math.min(hold, 30);
475
+
476
+ for (;;) {
477
+ const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
478
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
479
+ if (!res.ok) {
480
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
481
+ fail(`wait failed: ${detail}`);
482
+ }
483
+ if (json && json.state && json.state !== 'pending') {
484
+ if (args.json) process.stdout.write(`${text}\n`);
485
+ else printResolution(json);
486
+ return exitForState(json.state);
487
+ }
488
+ // Still pending at the hold timeout — poll again.
489
+ }
490
+ }
491
+
492
+ async function ask(args) {
493
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
494
+
495
+ const prompt = args.prompt;
496
+ if (!prompt) fail('a --prompt is required', EXIT.USAGE);
497
+
498
+ const { token, apiBase, room } = agentContext(args, { needRoom: true });
499
+
500
+ const body = { prompt };
501
+ const options = buildOptions(args.option);
502
+ if (options) body.options = options;
503
+ if (args.context) body.context = args.context;
504
+ if (args.scope !== undefined) {
505
+ if (args.scope !== 'direct' && args.scope !== 'room') fail("--scope must be 'direct' or 'room'", EXIT.USAGE);
506
+ body.responder_scope = args.scope;
507
+ }
508
+ if (args.target !== undefined) body.target_user_id = args.target;
509
+ if (args.ttl !== undefined) {
510
+ if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
511
+ body.ttl = Number(args.ttl);
512
+ }
513
+ if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
514
+ if (args.data !== undefined) body.data = parseDataObject(args.data);
515
+
516
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
517
+ const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
518
+ if (!res.ok) {
519
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
520
+ fail(`ask failed: ${detail}`);
521
+ }
522
+
523
+ if (!args.wait) {
524
+ if (args.json) process.stdout.write(`${text}\n`);
525
+ else process.stdout.write(`${json.id}\n`);
526
+ return EXIT.OK;
527
+ }
528
+
529
+ return waitForResolution(json.id, args, { token, apiBase });
530
+ }
531
+
532
+ async function watch(args) {
533
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
534
+ const id = args._[0];
535
+ if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
536
+ const { token, apiBase } = agentContext(args);
537
+ return waitForResolution(id, args, { token, apiBase });
538
+ }
539
+
540
+ async function cancel(args) {
541
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
542
+ const id = args._[0];
543
+ if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
544
+ const { token, apiBase } = agentContext(args);
545
+ const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
546
+ const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
547
+ if (!res.ok) {
548
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
549
+ fail(`cancel failed: ${detail}`);
550
+ }
551
+ if (args.json) process.stdout.write(`${text}\n`);
552
+ else process.stdout.write(`cancelled (${json && json.state})\n`);
553
+ return EXIT.OK;
554
+ }
555
+
556
+ async function list(args) {
557
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
558
+ const { token, apiBase } = agentContext(args);
559
+ const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
560
+ const url = `${apiBase}/api/agent/questions${qs}`;
561
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
562
+ if (!res.ok) {
563
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
564
+ fail(`list failed: ${detail}`);
565
+ }
566
+ if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
567
+
568
+ const questions = (json && json.questions) || [];
569
+ if (questions.length === 0) { process.stdout.write('no questions\n'); return EXIT.OK; }
570
+ for (const q of questions) {
571
+ const answer = q.answer && q.answer.value ? ` → ${q.answer.value}` : '';
572
+ process.stdout.write(`${q.id} ${String(q.state).padEnd(9)} ${q.prompt}${answer}\n`);
573
+ }
574
+ return EXIT.OK;
575
+ }
576
+
577
+ async function listHandoffs(args) {
578
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
579
+ const { token, apiBase } = agentContext(args);
580
+ const state = args.state || 'open';
581
+ if (state !== 'open' && state !== 'all') {
582
+ fail("--state must be 'open' or 'all' for handoffs", EXIT.USAGE);
583
+ }
584
+
585
+ const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
586
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
587
+ if (!res.ok) {
588
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
589
+ fail(`handoffs list failed: ${detail}`);
590
+ }
591
+ if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
592
+
593
+ const handoffs = (json && json.handoffs) || [];
594
+ if (handoffs.length === 0) { process.stdout.write('no handoffs\n'); return EXIT.OK; }
595
+ for (const h of handoffs) {
596
+ const answer = h.answer && (h.answer.value ?? h.answer.text);
597
+ const outcome = answer !== undefined && answer !== null ? ` → ${answer}` : '';
598
+ process.stdout.write(
599
+ `${h.id} ${String(h.kind || '').padEnd(8)} ${String(h.state || '').padEnd(9)} ${h.prompt || ''}${outcome}\n`,
600
+ );
601
+ }
602
+ return EXIT.OK;
603
+ }
604
+
605
+ // --- handoff ---------------------------------------------------------------
606
+
607
+ // Terminal wire states across both kinds. ack: open→acked|expired.
608
+ // question: pending→answered|expired|cancelled. `open`/`pending` are the only
609
+ // non-terminal states, so a wait loop against these always terminates.
610
+ const HANDOFF_PENDING = new Set(['open', 'pending']);
611
+
612
+ // Map a terminal handoff state to an exit code. A `question` answered with ANY
613
+ // value is a success (0) — a negative human decision ('hold'/'deny') is NOT an
614
+ // infra failure. `acked` is likewise 0. `expired` is a distinct 3 so CI can
615
+ // branch; `cancelled` shares 4 with recipient_not_ready.
616
+ function exitForHandoffState(state) {
617
+ switch (state) {
618
+ case 'acked': return EXIT.OK;
619
+ case 'answered': return EXIT.OK;
620
+ case 'expired': return EXIT.EXPIRED;
621
+ case 'cancelled': return EXIT.CANCELLED;
622
+ default: return EXIT.ERROR;
623
+ }
624
+ }
625
+
626
+ // Print a machine-readable summary of a handoff: id, state, delivery-state, and
627
+ // the answer value / acked-by when present, one `key=value` per line to stdout.
628
+ function printHandoff(h) {
629
+ const lines = [`id=${h.id ?? ''}`, `state=${h.state ?? ''}`];
630
+ if (h.delivery_state != null) lines.push(`delivery-state=${h.delivery_state}`);
631
+ if (h.correlation_id) lines.push(`correlation-id=${h.correlation_id}`);
632
+ if (h.state === 'answered') {
633
+ const value = h.answer && (h.answer.value ?? h.answer.text) || '';
634
+ lines.push(`answer=${value}`);
635
+ }
636
+ if (h.state === 'acked') {
637
+ // The Handoff API returns a privacy-aware actor object. Only expose its id
638
+ // in the machine-readable CLI/GitHub Action output; a redacted actor yields
639
+ // an empty value instead of the unhelpful "[object Object]" string.
640
+ const ackerId = h.acked_by && typeof h.acked_by === 'object'
641
+ ? h.acked_by.id
642
+ : h.acked_by;
643
+ lines.push(`acked-by=${ackerId ?? ''}`);
644
+ if (h.acked_at) lines.push(`acked-at=${h.acked_at}`);
645
+ }
646
+ process.stdout.write(`${lines.join('\n')}\n`);
647
+ }
648
+
649
+ /**
650
+ * Append the composite Action's declared outputs without interpreting stdout.
651
+ * Values use GitHub's multiline protocol with a fresh random delimiter. Output
652
+ * names are a fixed allowlist; untrusted answer text can never create a key.
653
+ */
654
+ function writeGitHubHandoffOutputs(path, h) {
655
+ if (typeof path !== 'string' || path.length === 0) {
656
+ fail('--github-output must be a non-empty path', EXIT.USAGE);
657
+ }
658
+
659
+ const ackerId = h.acked_by && typeof h.acked_by === 'object'
660
+ ? h.acked_by.id
661
+ : h.acked_by;
662
+ const fields = [
663
+ ['handoff-id', h.id ?? ''],
664
+ ['state', h.state ?? ''],
665
+ ];
666
+ if (h.delivery_state != null) fields.push(['delivery-state', h.delivery_state]);
667
+ if (h.state === 'answered') {
668
+ fields.push(['answer', h.answer && (h.answer.value ?? h.answer.text) || '']);
669
+ }
670
+ if (h.state === 'acked') fields.push(['acknowledged-by', ackerId ?? '']);
671
+
672
+ const blocks = fields.map(([name, rawValue]) => {
673
+ const value = String(rawValue ?? '');
674
+ let delimiter;
675
+ do {
676
+ delimiter = `pingroom_${randomBytes(24).toString('hex')}`;
677
+ } while (value.includes(delimiter));
678
+ // Keep the collision check next to serialization: a delimiter must never
679
+ // occur in an untrusted value, even though a 192-bit collision is remote.
680
+ if (value.includes(delimiter)) {
681
+ fail('could not create a safe GitHub output delimiter');
682
+ }
683
+ return `${name}<<${delimiter}\n${value}\n${delimiter}\n`;
684
+ });
685
+
686
+ try {
687
+ appendFileSync(path, blocks.join(''), { encoding: 'utf8' });
688
+ } catch {
689
+ fail('could not write GitHub outputs');
690
+ }
691
+ }
692
+
693
+ // Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
694
+ // print it and return the state's exit code. Reuses the shared bounded hold.
695
+ async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
696
+ let hold = args.timeout !== undefined ? Number(args.timeout) : 20;
697
+ if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
698
+ hold = Math.min(hold, 25);
699
+
700
+ for (;;) {
701
+ const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
702
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
703
+ if (!res.ok) {
704
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
705
+ fail(`wait failed: ${detail}`);
706
+ }
707
+ if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
708
+ // Read/wait responses intentionally carry delivery_state=null. Preserve
709
+ // the create response's durable delivery result so --wait callers and
710
+ // the GitHub Action do not lose it at the terminal read boundary.
711
+ const resolved = json.delivery_state == null && initialDeliveryState != null
712
+ ? { ...json, delivery_state: initialDeliveryState }
713
+ : json;
714
+ if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, resolved);
715
+ if (args.json) process.stdout.write(`${text}\n`);
716
+ else printHandoff(resolved);
717
+ return exitForHandoffState(resolved.state);
718
+ }
719
+ // Still open/pending at the hold timeout — poll again.
720
+ }
721
+ }
722
+
723
+ async function handoff(args) {
724
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
725
+
726
+ const message = args.message;
727
+ if (!message) fail('a --message is required', EXIT.USAGE);
728
+
729
+ const { token, apiBase } = agentContext(args);
730
+
731
+ const options = buildOptions(args.option);
732
+ // Any --option (or an explicit --question) makes this a question handoff.
733
+ const isQuestion = Boolean(args.question) || Boolean(options);
734
+ if (isQuestion && (!options || options.length < 2)) {
735
+ fail('a question handoff needs at least 2 --option values', EXIT.USAGE);
736
+ }
737
+ if (isQuestion && options && options.length > 4) {
738
+ fail('a question handoff accepts at most 4 --option values', EXIT.USAGE);
739
+ }
740
+ if (!isQuestion && options) {
741
+ fail('--option requires --question', EXIT.USAGE);
742
+ }
743
+
744
+ const body = { kind: isQuestion ? 'question' : 'ack', prompt: message };
745
+
746
+ const target = args.target || 'me';
747
+ body.audience = { type: 'direct', user_id: target };
748
+
749
+ if (options) body.options = options;
750
+
751
+ if (args.expires_in !== undefined) {
752
+ if (!/^\d+$/.test(String(args.expires_in))) fail('--expires-in must be an integer number of seconds', EXIT.USAGE);
753
+ const secs = Number(args.expires_in);
754
+ if (secs < 120 || secs > 86_400) fail('--expires-in must be between 120 and 86400 seconds', EXIT.USAGE);
755
+ body.expires_in = secs;
756
+ }
757
+ if (args.urgency !== undefined) {
758
+ if (args.urgency !== 'active' && args.urgency !== 'passive') fail("--urgency must be 'active' or 'passive'", EXIT.USAGE);
759
+ body.urgency = args.urgency;
760
+ }
761
+ if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
762
+ if (args.reply_to !== undefined) body.reply_to = args.reply_to;
763
+ if (args.data !== undefined) body.data = parseDataObject(args.data);
764
+
765
+ const headers = { Authorization: `Bearer ${token}` };
766
+ // A stable Idempotency-Key lets network retries collapse to one resource; the
767
+ // server returns the same handoff for a matching key+hash (409 on conflict).
768
+ if (args.idempotency_key !== undefined) {
769
+ if (!args.idempotency_key) fail('--idempotency-key must be non-empty', EXIT.USAGE);
770
+ headers['Idempotency-Key'] = args.idempotency_key;
771
+ }
772
+
773
+ const url = `${apiBase}/api/agent/handoffs`;
774
+ const { res, text, json } = await httpJson('POST', url, { body, headers });
775
+ if (!res.ok) {
776
+ const code = json && json.code;
777
+ const detail = (json && (json.message || code)) || `HTTP ${res.status}`;
778
+ // A recipient who isn't reachable yet is a distinct, retriable outcome (4),
779
+ // not a generic error — CI may want to wait and retry rather than fail hard.
780
+ if (res.status === 409 && code === 'recipient_not_ready') {
781
+ if (args.json) process.stdout.write(`${text}\n`);
782
+ else process.stderr.write(`pingroom: recipient not ready\n`);
783
+ return EXIT.CANCELLED;
784
+ }
785
+ fail(`handoff failed: ${detail}`);
786
+ }
787
+
788
+ if (!args.wait) {
789
+ if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, json);
790
+ if (args.json) process.stdout.write(`${text}\n`);
791
+ else printHandoff(json);
792
+ return EXIT.OK;
793
+ }
794
+
795
+ return waitForHandoff(json.id, args, { token, apiBase }, json.delivery_state);
796
+ }
797
+
798
+ // --- hook (Claude Code integration) ----------------------------------------
799
+ //
800
+ // A single command wired into several Claude Code hook events. It reads the
801
+ // hook's JSON payload on stdin and switches on `hook_event_name`:
802
+ // Stop / SubagentStop / SessionEnd -> ping the room ("Claude finished")
803
+ // Notification -> ping the room (idle / needs-input)
804
+ // PreToolUse -> ask a PingRoom question and gate the
805
+ // tool call on the phone's Approve/Deny.
806
+ //
807
+ // Safety: the hook FAILS OPEN. It never blocks the agent and never
808
+ // auto-approves. Any missing config / network error / non-answer defers to the
809
+ // normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
810
+ // must not call fail() (a non-zero exit — 2 especially — would break the run).
811
+
812
+ function parseHookArgs(argv) {
813
+ const args = { _: [] };
814
+ const alias = {
815
+ '--room': 'room',
816
+ '--ttl': 'ttl',
817
+ '--quiet': 'quiet',
818
+ '--print-config': 'print_config',
819
+ '--token': 'token',
820
+ '--api': 'api',
821
+ '--json': 'json',
822
+ '-h': 'help', '--help': 'help',
823
+ };
824
+ const booleans = new Set(['quiet', 'print_config', 'json', 'help']);
825
+
826
+ for (let i = 0; i < argv.length; i++) {
827
+ const token = argv[i];
828
+ const key = alias[token];
829
+ if (key && booleans.has(key)) {
830
+ args[key] = true;
831
+ } else if (key) {
832
+ const value = argv[++i];
833
+ if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
834
+ args[key] = value;
835
+ } else if (token.startsWith('-') && token !== '-') {
836
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
837
+ } else {
838
+ args._.push(token);
839
+ }
840
+ }
841
+ return args;
842
+ }
843
+
844
+ // Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
845
+ // stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
846
+ function readStdin() {
847
+ return new Promise((resolve) => {
848
+ if (process.stdin.isTTY) { resolve(''); return; }
849
+ let data = '';
850
+ process.stdin.setEncoding('utf8');
851
+ process.stdin.on('data', (chunk) => { data += chunk; });
852
+ process.stdin.on('end', () => resolve(data));
853
+ process.stdin.on('error', () => resolve(data));
854
+ });
855
+ }
856
+
857
+ function truncate(value, max) {
858
+ const str = String(value ?? '');
859
+ return str.length <= max ? str : `${str.slice(0, max - 1)}…`;
860
+ }
861
+
862
+ // A minimal HTTP helper for the hook path that THROWS instead of calling fail(),
863
+ // so every failure funnels into a fail-open decision. Mirrors httpJson's header
864
+ // handling but leaves control flow to the caller.
865
+ async function hookFetch(method, url, { body, token } = {}) {
866
+ const res = await fetch(url, {
867
+ method,
868
+ headers: {
869
+ Accept: 'application/json',
870
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
871
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
872
+ },
873
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
874
+ });
875
+ const text = await res.text();
876
+ let json = null;
877
+ try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
878
+ if (!res.ok) {
879
+ throw new Error((json && (json.message || json.code)) || `HTTP ${res.status}`);
880
+ }
881
+ return json;
882
+ }
883
+
884
+ // Pull the readable text out of a Claude transcript message's content, which is
885
+ // either a plain string or an array of typed blocks.
886
+ function extractAssistantText(content) {
887
+ if (typeof content === 'string') return content;
888
+ if (Array.isArray(content)) {
889
+ return content
890
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
891
+ .map((b) => b.text)
892
+ .join(' ');
893
+ }
894
+ return '';
895
+ }
896
+
897
+ // Tail a Claude Code transcript (JSONL) and return the last assistant message as
898
+ // a single truncated line. Best-effort: any read/parse failure yields ''.
899
+ function summarizeTranscript(path) {
900
+ if (!path || typeof path !== 'string') return '';
901
+ let content;
902
+ try { content = readFileSync(path, 'utf8'); } catch { return ''; }
903
+ const lines = content.split('\n');
904
+ for (let i = lines.length - 1; i >= 0; i--) {
905
+ const line = lines[i].trim();
906
+ if (!line) continue;
907
+ let entry;
908
+ try { entry = JSON.parse(line); } catch { continue; }
909
+ const msg = entry && entry.message;
910
+ if (!msg || msg.role !== 'assistant') continue;
911
+ const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
912
+ if (text) return truncate(text, 500);
913
+ }
914
+ return '';
915
+ }
916
+
917
+ // A short, single-line description of the tool call for the question prompt.
918
+ // Never emits more than a truncated line, and strips whitespace/newlines so an
919
+ // untrusted command can't reshape the message.
920
+ function summarizeToolInput(input) {
921
+ if (!input || typeof input !== 'object') return '';
922
+ let raw = '';
923
+ if (typeof input.command === 'string') raw = input.command; // Bash
924
+ else if (typeof input.file_path === 'string') raw = input.file_path; // Read/Write/Edit
925
+ else if (typeof input.path === 'string') raw = input.path;
926
+ else if (typeof input.url === 'string') raw = input.url; // WebFetch
927
+ else if (typeof input.pattern === 'string') raw = input.pattern; // Grep/Glob
928
+ else { try { raw = JSON.stringify(input); } catch { raw = ''; } }
929
+ return truncate(String(raw).replace(/\s+/g, ' ').trim(), 160);
930
+ }
931
+
932
+ function emitPreToolUseDecision(decision, reason) {
933
+ process.stdout.write(`${JSON.stringify({
934
+ hookSpecificOutput: {
935
+ hookEventName: 'PreToolUse',
936
+ permissionDecision: decision,
937
+ permissionDecisionReason: reason,
938
+ },
939
+ })}\n`);
940
+ }
941
+
942
+ // Long-poll the wait endpoint until the question leaves `pending`. The server
943
+ // expires it at its ttl, so this always terminates; a mid-poll throw propagates
944
+ // to the caller's fail-open handler.
945
+ async function hookWaitForAnswer(id, { token, apiBase }) {
946
+ for (;;) {
947
+ const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=25`;
948
+ const json = await hookFetch('GET', url, { token });
949
+ if (json && json.state && json.state !== 'pending') return json;
950
+ }
951
+ }
952
+
953
+ async function hookPreToolUse(event, { token, room, apiBase, args }) {
954
+ if (!token || !room) {
955
+ emitPreToolUseDecision('ask', 'PingRoom not configured (set PINGROOM_TOKEN and PINGROOM_ROOM)');
956
+ return EXIT.OK;
957
+ }
958
+
959
+ const toolName = event.tool_name || 'a tool';
960
+ const summary = summarizeToolInput(event.tool_input);
961
+ const prompt = truncate(`Run ${toolName}${summary ? `: ${summary}` : ''}?`, 500);
962
+
963
+ let ttl = 900;
964
+ if (args.ttl !== undefined && /^\d+$/.test(String(args.ttl))) ttl = Number(args.ttl);
965
+
966
+ let questionId;
967
+ let cancelled = false;
968
+ const cancelQuestion = async () => {
969
+ if (!questionId || cancelled) return;
970
+ cancelled = true;
971
+ try {
972
+ await hookFetch('POST', `${apiBase}/api/agent/questions/${encodeURIComponent(questionId)}/cancel`, { body: {}, token });
973
+ } catch { /* best-effort — a leftover question expires on its own ttl */ }
974
+ };
975
+ // If the agent aborts the tool call, withdraw the question so it doesn't linger
976
+ // on the phone. Exit 0 so the abort itself isn't reported as a hook failure.
977
+ const onSignal = () => { cancelQuestion().finally(() => process.exit(EXIT.OK)); };
978
+ process.on('SIGINT', onSignal);
979
+ process.on('SIGTERM', onSignal);
980
+
981
+ try {
982
+ const data = { tool_name: String(toolName) };
983
+ if (event.cwd) data.cwd = String(event.cwd);
984
+ const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
985
+ token,
986
+ body: {
987
+ prompt,
988
+ context: 'Claude Code',
989
+ options: [
990
+ { value: 'allow', label: 'Approve', style: 'primary' },
991
+ { value: 'deny', label: 'Deny', style: 'danger' },
992
+ ],
993
+ ttl,
994
+ data,
995
+ ...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
996
+ },
997
+ });
998
+ questionId = created && created.id;
999
+ if (!questionId) {
1000
+ emitPreToolUseDecision('ask', 'PingRoom did not return a question — deferring to local prompt');
1001
+ return EXIT.OK;
1002
+ }
1003
+
1004
+ const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
1005
+ if (resolved.state === 'answered') {
1006
+ const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
1007
+ if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
1008
+ if (value === 'deny') { emitPreToolUseDecision('deny', 'Denied via PingRoom'); return EXIT.OK; }
1009
+ emitPreToolUseDecision('ask', `PingRoom answer "${value}" — deferring to local prompt`);
1010
+ return EXIT.OK;
1011
+ }
1012
+ emitPreToolUseDecision('ask', `PingRoom question ${resolved.state} — deferring to local prompt`);
1013
+ return EXIT.OK;
1014
+ } catch (err) {
1015
+ emitPreToolUseDecision('ask', `PingRoom unavailable (${err.message}) — deferring to local prompt`);
1016
+ return EXIT.OK;
1017
+ } finally {
1018
+ process.removeListener('SIGINT', onSignal);
1019
+ process.removeListener('SIGTERM', onSignal);
1020
+ }
1021
+ }
1022
+
1023
+ async function hookNotify(event, name, { token, room, apiBase, args }) {
1024
+ if (!token || !room) {
1025
+ if (!args.quiet) process.stderr.write('pingroom: hook skipped (set PINGROOM_TOKEN and PINGROOM_ROOM)\n');
1026
+ return EXIT.OK;
1027
+ }
1028
+
1029
+ let title;
1030
+ let message;
1031
+ if (name === 'Stop' || name === 'SubagentStop') {
1032
+ title = 'Claude finished';
1033
+ message = summarizeTranscript(event.transcript_path) || 'Session finished — waiting for you.';
1034
+ } else if (name === 'Notification') {
1035
+ message = truncate(event.message || 'Claude is waiting for your input.', 500);
1036
+ // A PreToolUse hook already turns permission prompts into a question; skip
1037
+ // the duplicate "needs your permission" Notification so you aren't paged twice.
1038
+ if (/permission/i.test(message)) return EXIT.OK;
1039
+ title = 'Claude needs you';
1040
+ } else if (name === 'SessionEnd') {
1041
+ if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
1042
+ title = 'Session ended';
1043
+ message = `Claude Code session ended (${event.reason || 'unknown'}).`;
1044
+ } else {
1045
+ return EXIT.OK; // unknown event — stay silent rather than send noise
1046
+ }
1047
+
1048
+ const data = { event: name };
1049
+ if (event.session_id) data.session_id = String(event.session_id);
1050
+ if (event.cwd) data.cwd = String(event.cwd);
1051
+
1052
+ try {
1053
+ await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
1054
+ token,
1055
+ body: {
1056
+ message,
1057
+ title,
1058
+ data,
1059
+ ...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
1060
+ },
1061
+ });
1062
+ if (!args.quiet) process.stderr.write('pingroom: pinged ✅\n');
1063
+ } catch (err) {
1064
+ // A broken ping must never break the agent — report to stderr and exit 0.
1065
+ if (!args.quiet) process.stderr.write(`pingroom: hook ping failed (${err.message})\n`);
1066
+ }
1067
+ return EXIT.OK;
1068
+ }
1069
+
1070
+ function printHookConfig() {
1071
+ const command = `npx --yes @pingroom/cli@${VERSION} hook`;
1072
+ const config = {
1073
+ hooks: {
1074
+ Stop: [{ hooks: [{ type: 'command', command }] }],
1075
+ Notification: [{ hooks: [{ type: 'command', command }] }],
1076
+ PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command, timeout: 960 }] }],
1077
+ },
1078
+ };
1079
+ process.stdout.write(
1080
+ `# PingRoom × Claude Code — merge this into ~/.claude/settings.json
1081
+ #
1082
+ # 1. Set your credentials in the environment (e.g. in your shell profile):
1083
+ # export PINGROOM_TOKEN="<your agent token>"
1084
+ # export PINGROOM_ROOM="<room invite code>"
1085
+ #
1086
+ # 2. Merge the "hooks" block below into ~/.claude/settings.json.
1087
+ # Stop / Notification -> ping your phone.
1088
+ # PreToolUse (Bash) -> ask a question you Approve/Deny from the lock
1089
+ # screen before the command runs. Add or change the
1090
+ # matcher to gate other tools.
1091
+ #
1092
+ # If PingRoom is unreachable the hook defers to the normal local prompt — it
1093
+ # never auto-approves and never blocks the agent.
1094
+
1095
+ ${JSON.stringify(config, null, 2)}
1096
+ `);
1097
+ }
1098
+
1099
+ async function hook(args) {
1100
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
1101
+ if (args.print_config) { printHookConfig(); return EXIT.OK; }
1102
+
1103
+ let event = {};
1104
+ const raw = await readStdin();
1105
+ if (raw) { try { event = JSON.parse(raw); } catch { event = {}; } }
1106
+ const name = event.hook_event_name || '';
1107
+
1108
+ const token = args.token || process.env.PINGROOM_TOKEN;
1109
+ const room = args.room || process.env.PINGROOM_ROOM;
1110
+ const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
1111
+
1112
+ if (name === 'PreToolUse') {
1113
+ return hookPreToolUse(event, { token, room, apiBase, args });
1114
+ }
1115
+ return hookNotify(event, name, { token, room, apiBase, args });
1116
+ }
1117
+
1118
+ const COMMANDS = {
1119
+ ping: (rest) => ping(parseArgs(rest)),
1120
+ ask: (rest) => ask(parseQArgs(rest)),
1121
+ watch: (rest) => waitFrom(watch, rest),
1122
+ await: (rest) => waitFrom(watch, rest),
1123
+ cancel: (rest) => cancel(parseQArgs(rest)),
1124
+ list: (rest) => list(parseQArgs(rest)),
1125
+ handoff: (rest) => handoff(parseHandoffArgs(rest)),
1126
+ handoffs: (rest) => listHandoffs(parseQArgs(rest)),
1127
+ hook: (rest) => hook(parseHookArgs(rest)),
1128
+ };
1129
+
1130
+ function waitFrom(handler, rest) {
1131
+ return handler(parseQArgs(rest));
156
1132
  }
157
1133
 
158
1134
  async function main() {
@@ -161,15 +1137,16 @@ async function main() {
161
1137
 
162
1138
  if (!command || command === '-h' || command === '--help' || command === 'help') {
163
1139
  process.stdout.write(`${HELP}\n`);
164
- process.exit(0);
1140
+ process.exit(EXIT.OK);
165
1141
  }
166
1142
 
167
- if (command === 'ping') {
168
- const code = await ping(parseArgs(argv.slice(1)));
169
- process.exit(code);
1143
+ const handler = COMMANDS[command];
1144
+ if (!handler) {
1145
+ fail(`unknown command: ${command}\nRun "pingroom --help".`, EXIT.USAGE);
170
1146
  }
171
1147
 
172
- fail(`unknown command: ${command}\nRun "pingroom --help".`, 2);
1148
+ const code = await handler(argv.slice(1));
1149
+ process.exit(code);
173
1150
  }
174
1151
 
175
1152
  main();