@pingroom/cli 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PingRoom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,7 +10,9 @@ npx @pingroom/cli ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
10
10
  ```
11
11
 
12
12
  Commands: `ping` (send), `ask` (ask a human), `watch` (block on an existing
13
- question), `list`, `cancel`. Run `pingroom --help` for the full reference.
13
+ question), `list`, `cancel`, `handoff` (hand a decision to a specific human),
14
+ and `handoffs` (list open or recent Handoffs).
15
+ Run `pingroom --help` for the full reference.
14
16
 
15
17
  ## Getting a webhook URL
16
18
 
@@ -26,6 +28,8 @@ pingroom ping [options]
26
28
  -t, --title <text> Ping title (<= 40 chars)
27
29
  -a, --action <1-4> Quick-action slot to attribute the ping to
28
30
  -d, --data <json> Extra JSON data, e.g. '{"commit":"abc123"}'
31
+ --require-ack Keep the ping open until an eligible recipient acknowledges it
32
+ --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
29
33
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
30
34
  --token <token> Agent access token (or env PINGROOM_TOKEN)
31
35
  --room <code> Room invite code (used with --token)
@@ -33,6 +37,17 @@ pingroom ping [options]
33
37
  --json Print the raw JSON response
34
38
  ```
35
39
 
40
+ To make the ping actionable, add `--require-ack`. The first eligible recipient to
41
+ acknowledge it wins; `--ack-timeout` optionally expires it if nobody responds:
42
+
43
+ ```bash
44
+ pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Production health check failed" \
45
+ --require-ack --ack-timeout 300
46
+ ```
47
+
48
+ Webhook timeouts accept 1–86400 seconds. Agent-token room pings accept
49
+ 60–86400 seconds.
50
+
36
51
  Exit codes: `0` success · `1` delivery failed · `2` bad usage. So CI fails loudly if a
37
52
  ping doesn't land.
38
53
 
@@ -55,8 +70,28 @@ ping doesn't land.
55
70
  title: 'CI failed'
56
71
  message: '❌ ${{ github.workflow }} failed on ${{ github.ref_name }}'
57
72
  action: '2'
73
+ require-ack: 'true'
74
+ ack-timeout: '300'
75
+
76
+ # Gate a job on a human handoff — the step fails (non-zero) on expiry, so the
77
+ # job stops unless someone answers. Read the decision from the step outputs.
78
+ - id: gate
79
+ uses: pingroom/cli@v0
80
+ with:
81
+ token: ${{ secrets.PINGROOM_TOKEN }}
82
+ message: 'Ship ${{ github.sha }} to production?'
83
+ handoff: 'true'
84
+ question: 'true'
85
+ options: 'deploy:Deploy,hold:Hold'
86
+ idempotency-key: 'deploy-${{ github.run_id }}'
87
+ wait: 'true'
88
+ - if: steps.gate.outputs.answer == 'deploy'
89
+ run: ./deploy-prod.sh
58
90
  ```
59
91
 
92
+ The handoff action exposes outputs `handoff-id`, `state`, `acknowledged-by`,
93
+ `answer`, and `delivery-state`.
94
+
60
95
  ## GitLab CI
61
96
 
62
97
  ```yaml
@@ -120,6 +155,81 @@ Approve/Deny default — two options is the lock-screen fast path. `--ttl` sets
120
155
  expiry in seconds (default 1h; 30–86400). `--scope room` lets any eligible member
121
156
  answer (first tap wins); the default `direct` asks your bound user.
122
157
 
158
+ ## Handoffs (agent → human)
159
+
160
+ `handoff` hands a single decision to a specific human — either a simple
161
+ **acknowledge** ("ack to proceed") or a **question** with options. It needs an
162
+ agent token whose consent grants `pingroom:handoffs:create`. Unlike `ask`, a
163
+ handoff targets a user directly (default `me`, the bound user) rather than a
164
+ room, and prints machine-readable `key=value` lines.
165
+
166
+ ```bash
167
+ # Ack handoff — block until the human acknowledges (exit 0), or it expires (3):
168
+ pingroom handoff --token "$PINGROOM_TOKEN" -m "Prod deploy 1.4.0 — ack to proceed" --wait
169
+
170
+ # Question handoff, blocking, branch in CI on the exit code:
171
+ pingroom handoff --token "$PINGROOM_TOKEN" --wait \
172
+ -m "Ship 1.4.0 to production?" --question -o deploy:Deploy -o hold:Hold
173
+ # exit 0 = answered (ANY value, incl. 'hold' — a negative human decision is not a failure)
174
+ # exit 3 = expired exit 4 = cancelled / recipient not ready exit 1 = error
175
+ ```
176
+
177
+ Flags: `--question` (or any `-o value:label`, 2–4) makes it a question, else it's
178
+ an ack. `--target me|<uuid>` picks the recipient. `--expires-in <s>` (120–86400,
179
+ default 900). `--urgency active|passive`. `--idempotency-key <key>` is sent as
180
+ the `Idempotency-Key` header so network retries collapse to one handoff (the
181
+ server 409s on a key reused with a different payload). `--correlation-id` /
182
+ `--reply-to` / `-d '{...}'` are echoed back. Add `--wait` to long-poll to a
183
+ terminal state; without it the command prints the created handoff and returns 0.
184
+
185
+ List unresolved Handoffs or bounded recent history without changing the legacy
186
+ question-only `list` command:
187
+
188
+ ```bash
189
+ pingroom handoffs --token "$PINGROOM_TOKEN" # open only
190
+ pingroom handoffs --token "$PINGROOM_TOKEN" --state all # recent, up to 200 per kind
191
+ ```
192
+
193
+ A negative answer (`hold`, `deny`, …) is a **successful** `answered` state and
194
+ exits `0` — branch on the printed `answer=` line, not on the exit code.
195
+
196
+ ## Claude Code integration (get pinged by your agent)
197
+
198
+ Wire PingRoom into [Claude Code](https://claude.com/claude-code) hooks so your
199
+ agent pings your phone when it finishes — and asks for your approval, on your
200
+ lock screen, before it runs a command. Approve or Deny with a tap; the agent
201
+ waits for your answer and continues.
202
+
203
+ Print a ready-to-paste config:
204
+
205
+ ```bash
206
+ pingroom hook --print-config
207
+ ```
208
+
209
+ Then set your credentials and merge the printed `hooks` block into
210
+ `~/.claude/settings.json`:
211
+
212
+ ```bash
213
+ export PINGROOM_TOKEN="<your agent token>" # a room the agent belongs to
214
+ export PINGROOM_ROOM="<room invite code>"
215
+ ```
216
+
217
+ `pingroom hook` reads the Claude Code hook event on stdin and reacts by event:
218
+
219
+ | Hook event | What happens |
220
+ | --- | --- |
221
+ | `Stop` / `SubagentStop` | Pings the room with the agent's last message (“Claude finished”). |
222
+ | `Notification` | Pings when the agent is idle or waiting for input (permission prompts are skipped — the `PreToolUse` question already covers those). |
223
+ | `SessionEnd` | Pings when a session ends (except `/clear`). |
224
+ | `PreToolUse` | Asks a PingRoom **question** and gates the tool call on your Approve/Deny tap. Which tools are gated is the settings.json `matcher` (default `Bash`) — not the CLI. |
225
+
226
+ **It always fails open.** If PingRoom is unreachable, the token/room is missing,
227
+ or the question expires, the hook defers to the normal local prompt
228
+ (`permissionDecision: "ask"`) and exits 0. It never auto-approves and never
229
+ blocks the agent. Because the `PreToolUse` hook holds the tool call open while it
230
+ waits for you, give it a generous `timeout` (the printed config uses 960s) and
231
+ tune the approval-question expiry with `--ttl <seconds>` (default 900).
232
+
123
233
  For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
124
234
  See <https://pingroom.io/connect-mcp.md> to connect Cursor, Claude Desktop, or Claude Code.
125
235
 
package/bin/pingroom.js CHANGED
@@ -10,8 +10,20 @@
10
10
  // watch Block until a question resolves and print the outcome.
11
11
  // list List the agent's questions by state.
12
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.
13
16
  //
14
- // Exit codes: 0 success/answered · 1 error · 2 bad usage · 3 expired · 4 cancelled.
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';
15
27
 
16
28
  const DEFAULT_API = process.env.PINGROOM_API_URL || 'https://api.pingroom.io';
17
29
 
@@ -26,12 +38,19 @@ Commands:
26
38
  watch Block until a question resolves and print the outcome
27
39
  list List the agent's questions by state
28
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
29
46
 
30
47
  ping options:
31
48
  -m, --message <text> Ping body text (required)
32
49
  -t, --title <text> Ping title (<= 40 chars)
33
50
  -a, --action <1-4> Quick-action slot to attribute the ping to
34
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)
35
54
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
36
55
  --token <token> Agent access token (or env PINGROOM_TOKEN)
37
56
  --room <code> Room invite code (used with --token)
@@ -52,6 +71,31 @@ ask options (agent token required):
52
71
  list options:
53
72
  --state <s> pending | answered | expired | cancelled | all
54
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
+
55
99
  Shared:
56
100
  --token <token> Agent access token (or env PINGROOM_TOKEN)
57
101
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
@@ -74,14 +118,30 @@ Examples:
74
118
  pingroom watch --token "$T" q_01H... # block on an existing question
75
119
  pingroom cancel --token "$T" q_01H...
76
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)
130
+
131
+ # Connect Claude Code to your phone (prints the settings.json to paste):
132
+ pingroom hook --print-config
133
+
77
134
  Security:
78
135
  Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
79
136
  secrets as --webhook / --token flags: argv is visible to other users via the
80
137
  process table (ps) and may be captured in shell history. URLs must use https
81
138
  (loopback http is allowed for local dev).
82
139
 
83
- Exit codes: 0 on success (answered), 1 on error, 2 on bad usage,
84
- 3 when a question expired, 4 when it was cancelled.`;
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.`;
85
145
 
86
146
  const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
87
147
 
@@ -100,22 +160,27 @@ function parseArgs(argv) {
100
160
  '-a': 'action', '--action': 'action',
101
161
  '-d': 'data', '--data': 'data',
102
162
  '-w': 'webhook', '--webhook': 'webhook',
163
+ '--require-ack': 'require_ack',
164
+ '--ack-timeout': 'ack_timeout',
103
165
  '--token': 'token',
104
166
  '--room': 'room',
105
167
  '--api': 'api',
106
168
  '--json': 'json',
107
169
  '-h': 'help', '--help': 'help',
108
170
  };
171
+ const booleans = new Set(['require_ack', 'json', 'help']);
109
172
 
110
173
  for (let i = 0; i < argv.length; i++) {
111
174
  const token = argv[i];
112
- if (token === '--json' || token === '-h' || token === '--help') {
113
- args[alias[token]] = true;
114
- continue;
115
- }
116
175
  const key = alias[token];
117
- if (key) {
118
- 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;
119
184
  } else if (token.startsWith('-')) {
120
185
  fail(`Unknown option: ${token}`, EXIT.USAGE);
121
186
  } else {
@@ -174,6 +239,56 @@ function parseQArgs(argv) {
174
239
  return args;
175
240
  }
176
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
+
177
292
  // Refuse to send a bearer token or webhook secret over cleartext http. A
178
293
  // loopback host is allowed so local dev against http://localhost still works.
179
294
  function requireSafeUrl(kind, raw) {
@@ -236,6 +351,17 @@ async function ping(args) {
236
351
  fail('--action must be an integer 1–4', EXIT.USAGE);
237
352
  }
238
353
 
354
+ let ackTimeout;
355
+ if (args.ack_timeout !== undefined) {
356
+ if (!args.require_ack) {
357
+ fail('--ack-timeout requires --require-ack', EXIT.USAGE);
358
+ }
359
+ if (!/^\d+$/.test(String(args.ack_timeout))) {
360
+ fail('--ack-timeout must be an integer number of seconds', EXIT.USAGE);
361
+ }
362
+ ackTimeout = Number(args.ack_timeout);
363
+ }
364
+
239
365
  let data;
240
366
  if (args.data !== undefined) {
241
367
  data = parseDataObject(args.data);
@@ -248,20 +374,30 @@ async function ping(args) {
248
374
  let result;
249
375
 
250
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
+ }
251
380
  requireSafeUrl('--webhook', webhook);
252
381
  const body = { message };
253
382
  if (args.title) body.title = args.title;
254
383
  if (args.action !== undefined) body.action = Number(args.action);
255
384
  if (data) body.data = data;
385
+ if (args.require_ack) body.requires_ack = true;
386
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
256
387
  result = await httpJson('POST', webhook, { body });
257
388
  } else if (token) {
258
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
+ }
259
393
  requireSafeUrl('--api', apiBase);
260
394
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(args.room)}/notifications`;
261
395
  const body = { message };
262
396
  if (args.title) body.title = args.title;
263
397
  if (args.action !== undefined) body.action_number = Number(args.action);
264
398
  if (data) body.data = data;
399
+ if (args.require_ack) body.requires_ack = true;
400
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
265
401
  result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
266
402
  } else {
267
403
  fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
@@ -438,6 +574,547 @@ async function list(args) {
438
574
  return EXIT.OK;
439
575
  }
440
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
+
441
1118
  const COMMANDS = {
442
1119
  ping: (rest) => ping(parseArgs(rest)),
443
1120
  ask: (rest) => ask(parseQArgs(rest)),
@@ -445,6 +1122,9 @@ const COMMANDS = {
445
1122
  await: (rest) => waitFrom(watch, rest),
446
1123
  cancel: (rest) => cancel(parseQArgs(rest)),
447
1124
  list: (rest) => list(parseQArgs(rest)),
1125
+ handoff: (rest) => handoff(parseHandoffArgs(rest)),
1126
+ handoffs: (rest) => listHandoffs(parseQArgs(rest)),
1127
+ hook: (rest) => hook(parseHookArgs(rest)),
448
1128
  };
449
1129
 
450
1130
  function waitFrom(handler, rest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Send PingRoom pings and ask humans blocking questions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {