@pingroom/cli 0.7.2 → 0.7.4

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.
@@ -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, PUBLIC_PING_MESSAGE_MAX_LENGTH } 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, PUBLIC_PING_MESSAGE_MAX_LENGTH);
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.', PUBLIC_PING_MESSAGE_MAX_LENGTH);
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
+ }