@pingroom/cli 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -11
- package/bin/pingroom.js +22 -3016
- package/lib/commands/ask.js +146 -0
- package/lib/commands/config.js +114 -0
- package/lib/commands/connect.js +726 -0
- package/lib/commands/handoff.js +149 -0
- package/lib/commands/hook.js +301 -0
- package/lib/commands/listen.js +83 -0
- package/lib/commands/live.js +165 -0
- package/lib/commands/mcp.js +47 -0
- package/lib/commands/ping.js +123 -0
- package/lib/config.js +206 -0
- package/lib/constants.js +7 -0
- package/lib/github-output.js +76 -0
- package/lib/help.js +305 -0
- package/lib/http.js +214 -0
- package/lib/parser.js +214 -0
- package/lib/render.js +174 -0
- package/lib/util.js +105 -0
- package/lib/version.js +10 -0
- package/package.json +3 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `handoff` and `handoffs` — hand a decision to a specific human (ack or
|
|
2
|
+
// question) and, with --wait, block until they answer.
|
|
3
|
+
|
|
4
|
+
import { EXIT } from '../constants.js';
|
|
5
|
+
import { fail, parseDataObject, requireMaxLength, resolveWaitHold, sleep } from '../util.js';
|
|
6
|
+
import { commandHelp } from '../help.js';
|
|
7
|
+
import { apiDetail, httpJson } from '../http.js';
|
|
8
|
+
import { agentContext } from '../config.js';
|
|
9
|
+
import { buildOptions, exitForHandoffState, HANDOFF_PENDING, printHandoff } from '../render.js';
|
|
10
|
+
import { writeGitHubHandoffOutputs } from '../github-output.js';
|
|
11
|
+
|
|
12
|
+
export async function listHandoffs(args) {
|
|
13
|
+
if (args.help) { process.stdout.write(`${commandHelp('handoffs')}\n`); return EXIT.OK; }
|
|
14
|
+
const { token, apiBase } = agentContext(args);
|
|
15
|
+
const state = args.state || 'open';
|
|
16
|
+
if (state !== 'open' && state !== 'all') {
|
|
17
|
+
fail("--state must be 'open' or 'all' for handoffs", EXIT.USAGE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
|
|
21
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const detail = apiDetail(res, json);
|
|
24
|
+
fail(`handoffs list failed: ${detail}`);
|
|
25
|
+
}
|
|
26
|
+
if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
|
|
27
|
+
|
|
28
|
+
const handoffs = (json && json.handoffs) || [];
|
|
29
|
+
if (handoffs.length === 0) { process.stdout.write('no handoffs\n'); return EXIT.OK; }
|
|
30
|
+
for (const h of handoffs) {
|
|
31
|
+
const answer = h.answer && (h.answer.value ?? h.answer.text);
|
|
32
|
+
const outcome = answer !== undefined && answer !== null ? ` → ${answer}` : '';
|
|
33
|
+
process.stdout.write(
|
|
34
|
+
`${h.id} ${String(h.kind || '').padEnd(8)} ${String(h.state || '').padEnd(9)} ${h.prompt || ''}${outcome}\n`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return EXIT.OK;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
|
|
41
|
+
// print it and return the state's exit code. Reuses the shared bounded hold.
|
|
42
|
+
async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
|
|
43
|
+
const hold = resolveWaitHold(args, { def: 20, cap: 25 });
|
|
44
|
+
|
|
45
|
+
for (;;) {
|
|
46
|
+
const started = Date.now();
|
|
47
|
+
const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
48
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const detail = apiDetail(res, json);
|
|
51
|
+
fail(`wait failed: ${detail}`);
|
|
52
|
+
}
|
|
53
|
+
if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
|
|
54
|
+
// Read/wait responses intentionally carry delivery_state=null. Preserve
|
|
55
|
+
// the create response's durable delivery result so --wait callers and
|
|
56
|
+
// the GitHub Action do not lose it at the terminal read boundary.
|
|
57
|
+
const resolved = json.delivery_state == null && initialDeliveryState != null
|
|
58
|
+
? { ...json, delivery_state: initialDeliveryState }
|
|
59
|
+
: json;
|
|
60
|
+
if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, resolved);
|
|
61
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
62
|
+
else printHandoff(resolved);
|
|
63
|
+
return exitForHandoffState(resolved.state);
|
|
64
|
+
}
|
|
65
|
+
// Still open/pending at the hold timeout — poll again, with the same
|
|
66
|
+
// hot-loop floor as waitForResolution.
|
|
67
|
+
const elapsed = Date.now() - started;
|
|
68
|
+
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function handoff(args) {
|
|
73
|
+
if (args.help) { process.stdout.write(`${commandHelp('handoff')}\n`); return EXIT.OK; }
|
|
74
|
+
|
|
75
|
+
const message = args.message;
|
|
76
|
+
if (!message) fail('a --message is required', EXIT.USAGE);
|
|
77
|
+
requireMaxLength(message, 500, '--message');
|
|
78
|
+
|
|
79
|
+
const { token, apiBase } = agentContext(args);
|
|
80
|
+
|
|
81
|
+
const options = buildOptions(args.option);
|
|
82
|
+
// Any --option (or an explicit --question) makes this a question handoff.
|
|
83
|
+
const isQuestion = Boolean(args.question) || Boolean(options);
|
|
84
|
+
if (isQuestion && (!options || options.length < 2)) {
|
|
85
|
+
fail('a question handoff needs at least 2 --option values', EXIT.USAGE);
|
|
86
|
+
}
|
|
87
|
+
if (isQuestion && options && options.length > 4) {
|
|
88
|
+
fail('a question handoff accepts at most 4 --option values', EXIT.USAGE);
|
|
89
|
+
}
|
|
90
|
+
if (!isQuestion && options) {
|
|
91
|
+
fail('--option requires --question', EXIT.USAGE);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body = { kind: isQuestion ? 'question' : 'ack', prompt: message };
|
|
95
|
+
|
|
96
|
+
const target = args.target || 'me';
|
|
97
|
+
body.audience = { type: 'direct', user_id: target };
|
|
98
|
+
|
|
99
|
+
if (options) body.options = options;
|
|
100
|
+
|
|
101
|
+
if (args.expires_in !== undefined) {
|
|
102
|
+
if (!/^\d+$/.test(String(args.expires_in))) fail('--expires-in must be an integer number of seconds', EXIT.USAGE);
|
|
103
|
+
const secs = Number(args.expires_in);
|
|
104
|
+
if (secs < 120 || secs > 86_400) fail('--expires-in must be between 120 and 86400 seconds', EXIT.USAGE);
|
|
105
|
+
body.expires_in = secs;
|
|
106
|
+
}
|
|
107
|
+
if (args.urgency !== undefined) {
|
|
108
|
+
if (args.urgency !== 'active' && args.urgency !== 'passive') fail("--urgency must be 'active' or 'passive'", EXIT.USAGE);
|
|
109
|
+
body.urgency = args.urgency;
|
|
110
|
+
}
|
|
111
|
+
if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
|
|
112
|
+
if (args.reply_to !== undefined) body.reply_to = args.reply_to;
|
|
113
|
+
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
114
|
+
|
|
115
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
116
|
+
// A stable Idempotency-Key lets network retries collapse to one resource; the
|
|
117
|
+
// server returns the same handoff for a matching key+hash (409 on conflict).
|
|
118
|
+
if (args.idempotency_key !== undefined) {
|
|
119
|
+
if (!args.idempotency_key) fail('--idempotency-key must be non-empty', EXIT.USAGE);
|
|
120
|
+
headers['Idempotency-Key'] = args.idempotency_key;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Pre-flight: reject a bad --timeout before the handoff exists.
|
|
124
|
+
if (args.wait) resolveWaitHold(args, { def: 20, cap: 25 });
|
|
125
|
+
|
|
126
|
+
const url = `${apiBase}/api/agent/handoffs`;
|
|
127
|
+
const { res, text, json } = await httpJson('POST', url, { body, headers });
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
const code = json && json.code;
|
|
130
|
+
const detail = apiDetail(res, json);
|
|
131
|
+
// A recipient who isn't reachable yet is a distinct, retriable outcome (4),
|
|
132
|
+
// not a generic error — CI may want to wait and retry rather than fail hard.
|
|
133
|
+
if (res.status === 409 && code === 'recipient_not_ready') {
|
|
134
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
135
|
+
else process.stderr.write(`pingroom: recipient not ready\n`);
|
|
136
|
+
return EXIT.CANCELLED;
|
|
137
|
+
}
|
|
138
|
+
fail(`handoff failed: ${detail}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!args.wait) {
|
|
142
|
+
if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, json);
|
|
143
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
144
|
+
else printHandoff(json);
|
|
145
|
+
return EXIT.OK;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return waitForHandoff(json.id, args, { token, apiBase }, json.delivery_state);
|
|
149
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { EXIT } from '../constants.js';
|
|
4
|
+
import { truncate } from '../util.js';
|
|
5
|
+
import { commandHelp } from '../help.js';
|
|
6
|
+
import { hookFetch, isSafeUrl } from '../http.js';
|
|
7
|
+
import { resolveApiBase, resolveRoom, resolveToken, storedCredentialOriginError } from '../config.js';
|
|
8
|
+
import { VERSION } from '../version.js';
|
|
9
|
+
|
|
10
|
+
// --- hook (Claude Code integration) ----------------------------------------
|
|
11
|
+
//
|
|
12
|
+
// A single command wired into several Claude Code hook events. It reads the
|
|
13
|
+
// hook's JSON payload on stdin and switches on `hook_event_name`:
|
|
14
|
+
// Stop / SubagentStop / SessionEnd -> ping the room ("Claude finished")
|
|
15
|
+
// Notification -> ping the room (idle / needs-input)
|
|
16
|
+
// PreToolUse -> ask a PingRoom question and gate the
|
|
17
|
+
// tool call on the phone's Approve/Deny.
|
|
18
|
+
//
|
|
19
|
+
// Safety: the hook FAILS OPEN. It never blocks the agent and never
|
|
20
|
+
// auto-approves. Any missing config / network error / non-answer defers to the
|
|
21
|
+
// normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
|
|
22
|
+
// must not call fail() (a non-zero exit — 2 especially — would break the run).
|
|
23
|
+
|
|
24
|
+
// Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
|
|
25
|
+
// stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
|
|
26
|
+
function readStdin() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
if (process.stdin.isTTY) { resolve(''); return; }
|
|
29
|
+
let data = '';
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
32
|
+
process.stdin.on('end', () => resolve(data));
|
|
33
|
+
process.stdin.on('error', () => resolve(data));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Pull the readable text out of a Claude transcript message's content, which is
|
|
38
|
+
// either a plain string or an array of typed blocks.
|
|
39
|
+
function extractAssistantText(content) {
|
|
40
|
+
if (typeof content === 'string') return content;
|
|
41
|
+
if (Array.isArray(content)) {
|
|
42
|
+
return content
|
|
43
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
44
|
+
.map((b) => b.text)
|
|
45
|
+
.join(' ');
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Tail a Claude Code transcript (JSONL) and return the last assistant message as
|
|
51
|
+
// a single truncated line. Best-effort: any read/parse failure yields ''.
|
|
52
|
+
function summarizeTranscript(path) {
|
|
53
|
+
if (!path || typeof path !== 'string') return '';
|
|
54
|
+
let content;
|
|
55
|
+
try { content = readFileSync(path, 'utf8'); } catch { return ''; }
|
|
56
|
+
const lines = content.split('\n');
|
|
57
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
58
|
+
const line = lines[i].trim();
|
|
59
|
+
if (!line) continue;
|
|
60
|
+
let entry;
|
|
61
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
62
|
+
const msg = entry && entry.message;
|
|
63
|
+
if (!msg || msg.role !== 'assistant') continue;
|
|
64
|
+
const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
|
|
65
|
+
if (text) return truncate(text, 500);
|
|
66
|
+
}
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A short, single-line description of the tool call for the question prompt.
|
|
71
|
+
// Never emits more than a truncated line, and strips whitespace/newlines so an
|
|
72
|
+
// untrusted command can't reshape the message.
|
|
73
|
+
function summarizeToolInput(input) {
|
|
74
|
+
if (!input || typeof input !== 'object') return '';
|
|
75
|
+
let raw = '';
|
|
76
|
+
if (typeof input.command === 'string') raw = input.command; // Bash
|
|
77
|
+
else if (typeof input.file_path === 'string') raw = input.file_path; // Read/Write/Edit
|
|
78
|
+
else if (typeof input.path === 'string') raw = input.path;
|
|
79
|
+
else if (typeof input.url === 'string') raw = input.url; // WebFetch
|
|
80
|
+
else if (typeof input.pattern === 'string') raw = input.pattern; // Grep/Glob
|
|
81
|
+
else { try { raw = JSON.stringify(input); } catch { raw = ''; } }
|
|
82
|
+
return truncate(String(raw).replace(/\s+/g, ' ').trim(), 160);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emitPreToolUseDecision(decision, reason) {
|
|
86
|
+
process.stdout.write(`${JSON.stringify({
|
|
87
|
+
hookSpecificOutput: {
|
|
88
|
+
hookEventName: 'PreToolUse',
|
|
89
|
+
permissionDecision: decision,
|
|
90
|
+
permissionDecisionReason: reason,
|
|
91
|
+
},
|
|
92
|
+
})}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Long-poll the wait endpoint until the question leaves `pending`. The server
|
|
96
|
+
// expires it at its ttl, so this always terminates; a mid-poll throw propagates
|
|
97
|
+
// to the caller's fail-open handler.
|
|
98
|
+
async function hookWaitForAnswer(id, { token, apiBase }) {
|
|
99
|
+
for (;;) {
|
|
100
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=25`;
|
|
101
|
+
const json = await hookFetch('GET', url, { token });
|
|
102
|
+
if (json && json.state && json.state !== 'pending') return json;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
107
|
+
if (!token || !room) {
|
|
108
|
+
emitPreToolUseDecision('ask', 'PingRoom not configured (pair by QR, or configure both a token and room)');
|
|
109
|
+
return EXIT.OK;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toolName = event.tool_name || 'a tool';
|
|
113
|
+
const summary = summarizeToolInput(event.tool_input);
|
|
114
|
+
const prompt = truncate(`Run ${toolName}${summary ? `: ${summary}` : ''}?`, 500);
|
|
115
|
+
|
|
116
|
+
let ttl = 900;
|
|
117
|
+
if (args.ttl !== undefined && /^\d+$/.test(String(args.ttl))) ttl = Number(args.ttl);
|
|
118
|
+
|
|
119
|
+
let questionId;
|
|
120
|
+
let cancelled = false;
|
|
121
|
+
const cancelQuestion = async () => {
|
|
122
|
+
if (!questionId || cancelled) return;
|
|
123
|
+
cancelled = true;
|
|
124
|
+
try {
|
|
125
|
+
await hookFetch('POST', `${apiBase}/api/agent/questions/${encodeURIComponent(questionId)}/cancel`, { body: {}, token });
|
|
126
|
+
} catch { /* best-effort — a leftover question expires on its own ttl */ }
|
|
127
|
+
};
|
|
128
|
+
// If the agent aborts the tool call, withdraw the question so it doesn't linger
|
|
129
|
+
// on the phone. Exit 0 so the abort itself isn't reported as a hook failure.
|
|
130
|
+
const onSignal = () => { cancelQuestion().finally(() => process.exit(EXIT.OK)); };
|
|
131
|
+
process.on('SIGINT', onSignal);
|
|
132
|
+
process.on('SIGTERM', onSignal);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const data = { tool_name: String(toolName) };
|
|
136
|
+
if (event.cwd) data.cwd = String(event.cwd);
|
|
137
|
+
const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
|
|
138
|
+
token,
|
|
139
|
+
body: {
|
|
140
|
+
prompt,
|
|
141
|
+
context: 'Claude Code',
|
|
142
|
+
options: [
|
|
143
|
+
{ value: 'allow', label: 'Approve', style: 'primary' },
|
|
144
|
+
{ value: 'deny', label: 'Deny', style: 'danger' },
|
|
145
|
+
],
|
|
146
|
+
ttl,
|
|
147
|
+
data,
|
|
148
|
+
...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
questionId = created && created.id;
|
|
152
|
+
if (!questionId) {
|
|
153
|
+
emitPreToolUseDecision('ask', 'PingRoom did not return a question — deferring to local prompt');
|
|
154
|
+
return EXIT.OK;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
|
|
158
|
+
if (resolved.state === 'answered') {
|
|
159
|
+
const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
|
|
160
|
+
if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
|
|
161
|
+
if (value === 'deny') { emitPreToolUseDecision('deny', 'Denied via PingRoom'); return EXIT.OK; }
|
|
162
|
+
emitPreToolUseDecision('ask', `PingRoom answer "${value}" — deferring to local prompt`);
|
|
163
|
+
return EXIT.OK;
|
|
164
|
+
}
|
|
165
|
+
emitPreToolUseDecision('ask', `PingRoom question ${resolved.state} — deferring to local prompt`);
|
|
166
|
+
return EXIT.OK;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
emitPreToolUseDecision('ask', `PingRoom unavailable (${err.message}) — deferring to local prompt`);
|
|
169
|
+
return EXIT.OK;
|
|
170
|
+
} finally {
|
|
171
|
+
process.removeListener('SIGINT', onSignal);
|
|
172
|
+
process.removeListener('SIGTERM', onSignal);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
177
|
+
if (!token || !room) {
|
|
178
|
+
if (!args.quiet) process.stderr.write('pingroom: hook skipped (pair by QR, or configure both a token and room)\n');
|
|
179
|
+
return EXIT.OK;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let title;
|
|
183
|
+
let message;
|
|
184
|
+
if (name === 'Stop' || name === 'SubagentStop') {
|
|
185
|
+
title = 'Claude finished';
|
|
186
|
+
message = summarizeTranscript(event.transcript_path) || 'Session finished — waiting for you.';
|
|
187
|
+
} else if (name === 'Notification') {
|
|
188
|
+
message = truncate(event.message || 'Claude is waiting for your input.', 500);
|
|
189
|
+
// A PreToolUse hook already turns permission prompts into a question; skip
|
|
190
|
+
// the duplicate "needs your permission" Notification so you aren't paged twice.
|
|
191
|
+
if (/permission/i.test(message)) return EXIT.OK;
|
|
192
|
+
title = 'Claude needs you';
|
|
193
|
+
} else if (name === 'SessionEnd') {
|
|
194
|
+
if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
|
|
195
|
+
title = 'Session ended';
|
|
196
|
+
message = `Claude Code session ended (${event.reason || 'unknown'}).`;
|
|
197
|
+
} else {
|
|
198
|
+
return EXIT.OK; // unknown event — stay silent rather than send noise
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const data = { event: name };
|
|
202
|
+
if (event.session_id) data.session_id = String(event.session_id);
|
|
203
|
+
if (event.cwd) data.cwd = String(event.cwd);
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
|
|
207
|
+
token,
|
|
208
|
+
body: {
|
|
209
|
+
message,
|
|
210
|
+
title,
|
|
211
|
+
data,
|
|
212
|
+
...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
if (!args.quiet) process.stderr.write('pingroom: pinged ✅\n');
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// A broken ping must never break the agent — report to stderr and exit 0.
|
|
218
|
+
if (!args.quiet) process.stderr.write(`pingroom: hook ping failed (${err.message})\n`);
|
|
219
|
+
}
|
|
220
|
+
return EXIT.OK;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function printHookConfig() {
|
|
224
|
+
const command = `npx --yes @pingroom/cli@${VERSION} hook`;
|
|
225
|
+
const config = {
|
|
226
|
+
hooks: {
|
|
227
|
+
Stop: [{ hooks: [{ type: 'command', command }] }],
|
|
228
|
+
Notification: [{ hooks: [{ type: 'command', command }] }],
|
|
229
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command, timeout: 960 }] }],
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
process.stdout.write(
|
|
233
|
+
`# PingRoom × Claude Code — merge this into ~/.claude/settings.json
|
|
234
|
+
#
|
|
235
|
+
# 1. Connect once and choose a delivery room when you scan the QR:
|
|
236
|
+
# npm install --global @pingroom/cli && pingroom
|
|
237
|
+
# Or, without a global install:
|
|
238
|
+
# npx --yes @pingroom/cli@${VERSION}
|
|
239
|
+
# The hook reads that stored credential and paired room automatically; you do
|
|
240
|
+
# not need to export PINGROOM_TOKEN or PINGROOM_ROOM for a local setup.
|
|
241
|
+
#
|
|
242
|
+
# 2. Merge the "hooks" block below into ~/.claude/settings.json.
|
|
243
|
+
# Stop / Notification -> ping your phone.
|
|
244
|
+
# PreToolUse (Bash) -> ask a question you Approve/Deny from the lock
|
|
245
|
+
# screen before the command runs. Add or change the
|
|
246
|
+
# matcher to gate other tools.
|
|
247
|
+
#
|
|
248
|
+
# If PingRoom is unreachable the hook defers to the normal local prompt — it
|
|
249
|
+
# never auto-approves and never blocks the agent.
|
|
250
|
+
# PINGROOM_TOKEN / PINGROOM_ROOM remain supported for CI and headless shells.
|
|
251
|
+
|
|
252
|
+
${JSON.stringify(config, null, 2)}
|
|
253
|
+
`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function hook(args) {
|
|
257
|
+
if (args.help) { process.stdout.write(`${commandHelp('hook')}\n`); return EXIT.OK; }
|
|
258
|
+
if (args.print_config) { printHookConfig(); return EXIT.OK; }
|
|
259
|
+
|
|
260
|
+
let event = {};
|
|
261
|
+
const raw = await readStdin();
|
|
262
|
+
if (raw) { try { event = JSON.parse(raw); } catch { event = {}; } }
|
|
263
|
+
const name = event.hook_event_name || '';
|
|
264
|
+
|
|
265
|
+
// The hook fails open, so it reads the same layered config as everything else
|
|
266
|
+
// but never complains about a missing piece — it just defers.
|
|
267
|
+
const token = resolveToken(args);
|
|
268
|
+
const room = resolveRoom(args);
|
|
269
|
+
const apiBase = resolveApiBase(args);
|
|
270
|
+
|
|
271
|
+
const originError = storedCredentialOriginError(args, apiBase);
|
|
272
|
+
if (originError) {
|
|
273
|
+
if (name === 'PreToolUse') {
|
|
274
|
+
emitPreToolUseDecision('ask', `${originError}; deferring to local prompt`);
|
|
275
|
+
} else if (!args.quiet) {
|
|
276
|
+
process.stderr.write(`pingroom: hook skipped (${originError})\n`);
|
|
277
|
+
}
|
|
278
|
+
return EXIT.OK;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Every other command that attaches a bearer gates its base through
|
|
282
|
+
// requireSafeUrl first; the hook was the one that didn't, so a config or env
|
|
283
|
+
// pointing at plain http shipped `Authorization: Bearer …` in the clear with
|
|
284
|
+
// nothing on screen. Same rule here — but enforced by deferring, not by
|
|
285
|
+
// exiting: the hook's whole contract is that it never blocks the agent, so a
|
|
286
|
+
// hard failure would trade a credential leak for a broken session.
|
|
287
|
+
if (!isSafeUrl(apiBase)) {
|
|
288
|
+
const why = `${apiBase} is not https — refusing to send credentials over cleartext`;
|
|
289
|
+
if (name === 'PreToolUse') {
|
|
290
|
+
emitPreToolUseDecision('ask', `PingRoom API base ${why}; deferring to local prompt`);
|
|
291
|
+
} else if (!args.quiet) {
|
|
292
|
+
process.stderr.write(`pingroom: hook skipped (API base ${why})\n`);
|
|
293
|
+
}
|
|
294
|
+
return EXIT.OK;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (name === 'PreToolUse') {
|
|
298
|
+
return hookPreToolUse(event, { token, room, apiBase, args });
|
|
299
|
+
}
|
|
300
|
+
return hookNotify(event, name, { token, room, apiBase, args });
|
|
301
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { EXIT } from '../constants.js';
|
|
2
|
+
import { fail, numberOption, sleep } from '../util.js';
|
|
3
|
+
import { commandHelp } from '../help.js';
|
|
4
|
+
import { apiDetail, httpJson, retryAfterMs } from '../http.js';
|
|
5
|
+
import { agentContext } from '../config.js';
|
|
6
|
+
import { formatIncoming } from '../render.js';
|
|
7
|
+
|
|
8
|
+
// --- listen ----------------------------------------------------------------
|
|
9
|
+
//
|
|
10
|
+
// The inbound half. Everything else here talks; this is how an agent hears —
|
|
11
|
+
// replies to its own structured pings, a human's ping in a room it belongs to,
|
|
12
|
+
// anything landing while it works.
|
|
13
|
+
//
|
|
14
|
+
// The server holds each request open until something arrives or the timeout
|
|
15
|
+
// elapses, so this is a long-poll, not a poll loop: an idle hour costs ~144
|
|
16
|
+
// requests, not one per second.
|
|
17
|
+
|
|
18
|
+
/** Cursor bookkeeping is the whole protocol: `after` in, `cursor` back. */
|
|
19
|
+
export async function listen(args) {
|
|
20
|
+
if (args.help) { process.stdout.write(`${commandHelp('listen')}\n`); return EXIT.OK; }
|
|
21
|
+
|
|
22
|
+
const { token, apiBase } = agentContext(args);
|
|
23
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
24
|
+
|
|
25
|
+
const timeout = numberOption(args.timeout, '--timeout', { min: 0, max: 30, integer: true }) ?? 25;
|
|
26
|
+
const limit = numberOption(args.limit, '--limit', { min: 1, max: 100, integer: true }) ?? 50;
|
|
27
|
+
|
|
28
|
+
// No cursor means "from now": the server answers an empty `after` with the
|
|
29
|
+
// head id and no rows, so starting up never replays history the agent has
|
|
30
|
+
// already seen. `--from` opts into catching up from a known id instead.
|
|
31
|
+
let cursor = args.from;
|
|
32
|
+
if (!cursor) {
|
|
33
|
+
const { res, json } = await httpJson('GET', `${apiBase}/api/agent/notifications/wait`, {
|
|
34
|
+
headers,
|
|
35
|
+
soft: true,
|
|
36
|
+
});
|
|
37
|
+
if (!res?.ok) fail(`listen failed: ${apiDetail(res, json)}`);
|
|
38
|
+
cursor = json && json.cursor;
|
|
39
|
+
if (!cursor) {
|
|
40
|
+
// A brand-new account with no pings at all has no head id. Nothing is
|
|
41
|
+
// wrong; there is simply nothing to be after yet.
|
|
42
|
+
cursor = '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let transientRun = 0;
|
|
47
|
+
|
|
48
|
+
for (;;) {
|
|
49
|
+
const query = new URLSearchParams({ timeout: String(timeout), limit: String(limit) });
|
|
50
|
+
if (cursor) query.set('after', cursor);
|
|
51
|
+
|
|
52
|
+
const { res, json, error } = await httpJson(
|
|
53
|
+
'GET',
|
|
54
|
+
`${apiBase}/api/agent/notifications/wait?${query}`,
|
|
55
|
+
// The hold plus headroom: aborting at exactly the server's deadline would
|
|
56
|
+
// race it and turn every quiet window into a client-side error.
|
|
57
|
+
{ headers, soft: true, signal: AbortSignal.timeout((timeout + 10) * 1000) },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (error || res.status === 429 || res.status >= 500) {
|
|
61
|
+
transientRun += 1;
|
|
62
|
+
const retryAfter = res?.status === 429 ? retryAfterMs(res) : null;
|
|
63
|
+
// Geometric backoff so a real outage is not also a thundering herd. The
|
|
64
|
+
// loop is unbounded by design — `listen` is a daemon, not a request.
|
|
65
|
+
const backoff = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 30_000);
|
|
66
|
+
await sleep(Math.max(0, retryAfter ?? backoff));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!res.ok) fail(`listen failed: ${apiDetail(res, json)}`);
|
|
71
|
+
transientRun = 0;
|
|
72
|
+
|
|
73
|
+
const batch = Array.isArray(json?.notifications) ? json.notifications : [];
|
|
74
|
+
for (const item of batch) {
|
|
75
|
+
process.stdout.write(args.json ? `${JSON.stringify(item)}\n` : `${formatIncoming(item)}\n`);
|
|
76
|
+
}
|
|
77
|
+
// Advance only on a cursor the server actually returned, or a batch could be
|
|
78
|
+
// replayed forever against a stale `after`.
|
|
79
|
+
if (json && typeof json.cursor === 'string' && json.cursor) cursor = json.cursor;
|
|
80
|
+
|
|
81
|
+
if (args.once) return EXIT.OK;
|
|
82
|
+
}
|
|
83
|
+
}
|