@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.
- package/LICENSE +21 -0
- package/README.md +147 -2
- package/bin/pingroom.js +1025 -48
- package/package.json +5 -2
package/bin/pingroom.js
CHANGED
|
@@ -1,39 +1,156 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @pingroom/cli —
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
30
|
+
const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
|
|
14
31
|
|
|
15
32
|
Usage:
|
|
16
|
-
pingroom
|
|
33
|
+
pingroom <command> [options]
|
|
17
34
|
|
|
18
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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] =
|
|
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}`,
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
321
|
+
async function httpJson(method, url, { body, headers = {} } = {}) {
|
|
77
322
|
let res;
|
|
78
323
|
try {
|
|
79
324
|
res = await fetch(url, {
|
|
80
|
-
method
|
|
81
|
-
headers: {
|
|
82
|
-
|
|
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
|
|
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',
|
|
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',
|
|
351
|
+
fail('--action must be an integer 1–4', EXIT.USAGE);
|
|
103
352
|
}
|
|
104
353
|
|
|
105
|
-
let
|
|
106
|
-
if (args.
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
113
|
-
fail('--
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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)',
|
|
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
|
|
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(
|
|
1140
|
+
process.exit(EXIT.OK);
|
|
165
1141
|
}
|
|
166
1142
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
1143
|
+
const handler = COMMANDS[command];
|
|
1144
|
+
if (!handler) {
|
|
1145
|
+
fail(`unknown command: ${command}\nRun "pingroom --help".`, EXIT.USAGE);
|
|
170
1146
|
}
|
|
171
1147
|
|
|
172
|
-
|
|
1148
|
+
const code = await handler(argv.slice(1));
|
|
1149
|
+
process.exit(code);
|
|
173
1150
|
}
|
|
174
1151
|
|
|
175
1152
|
main();
|