pidge-cli 0.4.0 → 0.6.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/README.md +30 -3
- package/bin/pidge.js +375 -16
- package/package.json +15 -2
package/README.md
CHANGED
|
@@ -53,6 +53,30 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
53
53
|
| `notify` | Send only. Prints the raw 201 JSON; the `correlation_id` + warnings go to stderr. |
|
|
54
54
|
| `wait <correlation_id>` | Block on an already-sent notification until it's answered. |
|
|
55
55
|
| `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
|
|
56
|
+
| `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
|
|
57
|
+
| `listen` | Block until the human **messages you** from the app; prints the messages, ACKs them, exits `0`. One-shot — loop it. |
|
|
58
|
+
|
|
59
|
+
## Realtime (v0.6.0)
|
|
60
|
+
|
|
61
|
+
`listen`/`ask`/`wait` hold a **WebSocket** to the server (ActionCable at `/cable`)
|
|
62
|
+
whenever the runtime has one (**Node ≥22**): answers and messages land in **<1 s**,
|
|
63
|
+
an idle hours-long `listen` **survives server deploys by reconnecting**, and while
|
|
64
|
+
you listen the human sees **"ouvindo agora"** in the app — they type more when the
|
|
65
|
+
light is on.
|
|
66
|
+
|
|
67
|
+
Everything durable still goes over HTTP (backlog reads + acks), so a dropped
|
|
68
|
+
socket costs latency, never data. The degrade ladder narrates itself on stderr:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
WebSocket → ?wait= long-poll (capped 25 s server-side) → plain GETs every ~45 s
|
|
72
|
+
(automatic after repeated WS failures) (after 3 consecutive
|
|
73
|
+
failures on held polls)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `--realtime` forces WS (warns + falls back if unavailable) · `--no-realtime` = polling only.
|
|
77
|
+
- **Deafness exits LOUD**: a session that times out with **zero** healthy round-trips
|
|
78
|
+
exits `4` (≠ `3`, "the human didn't answer") — the channel itself looks broken;
|
|
79
|
+
surface it instead of retrying blindly.
|
|
56
80
|
|
|
57
81
|
## Options (for `notify` / `ask`)
|
|
58
82
|
|
|
@@ -85,8 +109,9 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
85
109
|
--param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
|
|
86
110
|
fields work day-one, no CLI update needed
|
|
87
111
|
--timeout SECONDS ask: default 600 · wait: default 300
|
|
88
|
-
--interval SECONDS FALLBACK poll cadence (default 30) — normally unused:
|
|
89
|
-
server long-
|
|
112
|
+
--interval SECONDS FALLBACK poll cadence (default 30) — normally unused: WS or
|
|
113
|
+
the server-held long-poll (?wait=25) make answers ~instant
|
|
114
|
+
--realtime force the WebSocket (Node ≥22); --no-realtime = polling only
|
|
90
115
|
```
|
|
91
116
|
|
|
92
117
|
## Contract (important for agents)
|
|
@@ -98,7 +123,9 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
98
123
|
the `chosen_action` JSON. Everything human (warnings, the correlation_id, snooze
|
|
99
124
|
notices, armed-escalation and policy-degrade narration) goes to **stderr**.
|
|
100
125
|
- **Exit codes:** `0` answered · `3` timed out (= *no answer yet*, NOT a failure —
|
|
101
|
-
back off and retry later) · `
|
|
126
|
+
back off and retry later) · `4` timed out **without one healthy round-trip all
|
|
127
|
+
session** (the CHANNEL looks broken — server/network — tell your human) ·
|
|
128
|
+
`2` error · `1` usage.
|
|
102
129
|
- **Responses are one-and-done.** Every answer closes the notification EXCEPT a
|
|
103
130
|
**snooze** (or a reschedule that set a new time), which re-fires later. `ask`/`wait`
|
|
104
131
|
keep polling through a snooze and print `snooze_until` so you can schedule a re-check.
|
package/bin/pidge.js
CHANGED
|
@@ -66,7 +66,8 @@ const BASE = process.env.PIDGE_URL || process.env.HERALD_URL || FILE_ENV.PIDGE_U
|
|
|
66
66
|
const TOKEN = process.env.PIDGE_TOKEN || process.env.HERALD_TOKEN || FILE_ENV.PIDGE_TOKEN;
|
|
67
67
|
|
|
68
68
|
function die(msg, code = 1) { console.error(msg); process.exit(code); }
|
|
69
|
-
|
|
69
|
+
// NB: the TOKEN requirement is enforced AFTER help/usage handling (below) — a
|
|
70
|
+
// first-time `npx pidge-cli --help` must work without any setup.
|
|
70
71
|
|
|
71
72
|
const OPTIONS = {
|
|
72
73
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -88,10 +89,19 @@ const OPTIONS = {
|
|
|
88
89
|
'deliver-at': { type: 'string' },
|
|
89
90
|
'reply-to': { type: 'string' },
|
|
90
91
|
'correlation-id': { type: 'string' },
|
|
92
|
+
thread: { type: 'string' }, // conversation handle (#49) — same id ⇒ one strand on the phone
|
|
91
93
|
'collapse-key': { type: 'string' },
|
|
92
94
|
param: { type: 'string', multiple: true }, // key=value escape hatch → raw /notify field
|
|
93
95
|
timeout: { type: 'string' },
|
|
94
96
|
interval: { type: 'string' },
|
|
97
|
+
// inbox flags (#83)
|
|
98
|
+
pending: { type: 'boolean' },
|
|
99
|
+
summary: { type: 'boolean' },
|
|
100
|
+
all: { type: 'boolean' },
|
|
101
|
+
limit: { type: 'string' },
|
|
102
|
+
// realtime (#118): WS by default when the runtime has a WebSocket (Node ≥22)
|
|
103
|
+
realtime: { type: 'boolean' }, // force WS (warn+fallback if unavailable)
|
|
104
|
+
'no-realtime': { type: 'boolean' }, // polling only
|
|
95
105
|
};
|
|
96
106
|
|
|
97
107
|
const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
|
|
@@ -101,8 +111,21 @@ USAGE
|
|
|
101
111
|
pidge notify [options] send only (prints the 201 JSON)
|
|
102
112
|
pidge wait <correlation_id> [options] block on an already-sent notification
|
|
103
113
|
pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
|
|
114
|
+
pidge inbox [--pending|--summary|--all|--limit N] what you sent: list, pending slice, or counts+latency (#83)
|
|
115
|
+
pidge listen [--timeout N] block until the human MESSAGES you from the app, print + ack + exit (#48)
|
|
104
116
|
pidge --help
|
|
105
117
|
|
|
118
|
+
REALTIME (#118)
|
|
119
|
+
listen/ask/wait hold a WebSocket to the server (ActionCable at /cable) when the
|
|
120
|
+
runtime has one (Node ≥22): answers/messages land in <1 s, an idle hours-long
|
|
121
|
+
listen survives server deploys by RECONNECTING, and while you listen the human
|
|
122
|
+
sees "ouvindo agora" in the app. Everything durable still goes over HTTP
|
|
123
|
+
(backlog GET + ack), so a dropped socket costs latency, never data.
|
|
124
|
+
--realtime force WS (warns + falls back to polling if unavailable)
|
|
125
|
+
--no-realtime polling only (the ?wait= long-poll, capped 25 s server-side)
|
|
126
|
+
Degrade ladder, narrated on stderr: WS → ?wait= long-poll → plain GETs every
|
|
127
|
+
~45 s after 3 consecutive failures on held polls (#119).
|
|
128
|
+
|
|
106
129
|
OPTIONS (notify / ask)
|
|
107
130
|
--title TEXT (required) the headline
|
|
108
131
|
--body TEXT message shown on the banner
|
|
@@ -130,12 +153,14 @@ OPTIONS (notify / ask)
|
|
|
130
153
|
--deliver-at ISO8601 schedule for later
|
|
131
154
|
--reply-to URL also POST the answer to your webhook (HMAC-signed)
|
|
132
155
|
--correlation-id ID idempotency + routing key (auto-generated if omitted)
|
|
156
|
+
--thread ID conversation handle (#49): sends sharing it group as ONE
|
|
157
|
+
strand on the phone — use it for follow-ups
|
|
133
158
|
--collapse-key KEY replace/update a prior notification
|
|
134
159
|
--param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
|
|
135
160
|
fields work without a CLI update; the manifest is the contract
|
|
136
161
|
--timeout SECONDS ask: 600 · wait: 300
|
|
137
|
-
--interval SECONDS FALLBACK poll cadence (default 30) — normally unused:
|
|
138
|
-
server long-
|
|
162
|
+
--interval SECONDS FALLBACK poll cadence (default 30) — normally unused: WS or
|
|
163
|
+
the server-held long-poll (?wait=25) make answers ~instant
|
|
139
164
|
|
|
140
165
|
ENV
|
|
141
166
|
PIDGE_URL your Pidge server (default http://localhost:3000; HERALD_URL honored)
|
|
@@ -146,7 +171,9 @@ ENV
|
|
|
146
171
|
OUTPUT
|
|
147
172
|
stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
|
|
148
173
|
human notices go to stderr. Exit: 0 answered · 3 timed out (no answer yet,
|
|
149
|
-
not a failure) ·
|
|
174
|
+
not a failure) · 4 timed out WITHOUT ONE healthy round-trip all session (the
|
|
175
|
+
CHANNEL looks broken — server/network — not the human ignoring you: surface
|
|
176
|
+
it instead of retrying blindly, #119) · 2 error · 1 usage.
|
|
150
177
|
|
|
151
178
|
Responses are one-and-done EXCEPT snooze/reschedule (they re-fire); ask/wait keep
|
|
152
179
|
polling through a snooze and print snooze_until. Follow-up = a NEW notification.
|
|
@@ -168,6 +195,7 @@ const command = parsed.positionals[0];
|
|
|
168
195
|
// `pidge --help` / `-h` / `help` → full help on stdout, exit 0. No command → stderr, exit 1.
|
|
169
196
|
if (v.help || command === 'help') { console.log(USAGE); process.exit(0); }
|
|
170
197
|
if (!command) { console.error(USAGE); process.exit(1); }
|
|
198
|
+
if (!TOKEN) die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.config/pidge/env)');
|
|
171
199
|
|
|
172
200
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
173
201
|
const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
|
|
@@ -175,16 +203,142 @@ const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application
|
|
|
175
203
|
// The server advertises its manifest version on every response. When it's newer
|
|
176
204
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
177
205
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
178
|
-
const KNOWN_MANIFEST_VERSION =
|
|
206
|
+
const KNOWN_MANIFEST_VERSION = 16;
|
|
179
207
|
let newsWarned = false;
|
|
180
208
|
function checkManifestNews(res) {
|
|
181
209
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
182
210
|
if (v > KNOWN_MANIFEST_VERSION && !newsWarned) {
|
|
183
211
|
newsWarned = true;
|
|
184
|
-
|
|
212
|
+
// #119: a pinned npx ref never updates itself — give the CONCRETE command.
|
|
213
|
+
console.error(`pidge: the server has NEW capabilities (manifest v${v}; this CLI knows v${KNOWN_MANIFEST_VERSION}) — re-read GET $PIDGE_URL/api/v1/manifest (see whats_new) and UPDATE the CLI: npm i -g pidge-cli@latest (npx users: run npx pidge-cli@latest, a pinned ref never self-updates)`);
|
|
185
214
|
}
|
|
186
215
|
}
|
|
187
216
|
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// #119: the health ledger of one blocking session (wait/ask/listen). Drives
|
|
219
|
+
// (a) automatic DEGRADE from held ?wait= polls to plain GETs after
|
|
220
|
+
// DEGRADE_AFTER consecutive failures (an edge that kills held responses
|
|
221
|
+
// leaves short requests fine — the channel stays alive, less instant),
|
|
222
|
+
// (b) ONE aggregated deafness line per minute instead of a line per failure,
|
|
223
|
+
// (c) exit code 4 when the session ends with ZERO healthy round-trips —
|
|
224
|
+
// deafness must exit LOUD, not masked as "the human didn't answer".
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
const DEGRADE_AFTER = 3;
|
|
227
|
+
// env override = a test/ops hook, not a documented knob
|
|
228
|
+
const DEGRADED_INTERVAL_S = parseInt(process.env.PIDGE_DEGRADED_INTERVAL || '45', 10);
|
|
229
|
+
const health = {
|
|
230
|
+
okEver: false, fails: 0, firstFailAt: 0, lastNoteAt: 0, degraded: false,
|
|
231
|
+
ok() {
|
|
232
|
+
if (this.fails > 0) console.error(`pidge: channel recovered after ${this.fails} consecutive failure(s)`);
|
|
233
|
+
this.okEver = true; this.fails = 0; this.firstFailAt = 0; this.lastNoteAt = 0;
|
|
234
|
+
},
|
|
235
|
+
fail(what) {
|
|
236
|
+
this.fails++;
|
|
237
|
+
if (!this.firstFailAt) { this.firstFailAt = Date.now(); this.lastNoteAt = Date.now(); }
|
|
238
|
+
if (!this.degraded && this.fails >= DEGRADE_AFTER) {
|
|
239
|
+
this.degraded = true;
|
|
240
|
+
console.error(`pidge: ${this.fails} consecutive failures on held polls — degraded to plain GETs every ~${DEGRADED_INTERVAL_S}s (channel stays ALIVE, just less instant). Latest: ${what}`);
|
|
241
|
+
} else if (this.fails === 1 || Date.now() - this.lastNoteAt >= 60000) {
|
|
242
|
+
this.lastNoteAt = Date.now();
|
|
243
|
+
const mins = Math.round((Date.now() - this.firstFailAt) / 60000);
|
|
244
|
+
console.error(`pidge: deaf for ${mins} min — ${this.fails} consecutive failure(s) (latest: ${what})`);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
exitTimeout(message) {
|
|
248
|
+
if (this.okEver) { console.error(`pidge: ${message} (= 'no answer yet', not a failure)`); process.exit(3); }
|
|
249
|
+
console.error(`pidge: ${message} — and NOT ONE healthy round-trip all session: the CHANNEL looks broken (server/network), not the human ignoring you. Surface this to your human.`);
|
|
250
|
+
process.exit(4);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Realtime (#118): a minimal ActionCable client over the runtime's native
|
|
256
|
+
// WebSocket (Node ≥22). The token rides an extra Sec-WebSocket-Protocol entry
|
|
257
|
+
// (the browser-style API can't set headers). The WS is a WAKE-UP + payload
|
|
258
|
+
// channel only — every durable read (message backlog, chosen_action) still
|
|
259
|
+
// goes over HTTP, so a dropped socket costs latency, never data.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
function wantRealtime() {
|
|
262
|
+
if (v['no-realtime']) return false;
|
|
263
|
+
if (typeof WebSocket !== 'function') {
|
|
264
|
+
if (v.realtime) console.error('pidge: --realtime needs a native WebSocket (Node ≥22) — falling back to polling');
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Speak just enough of the protocol: welcome → subscribe → confirm → frames.
|
|
271
|
+
// The server pings every ~3 s — that heartbeat is the liveness check (silence
|
|
272
|
+
// >15 s ⇒ the socket is dead even if TCP hasn't noticed; close → caller
|
|
273
|
+
// reconnects). Returns {close()} or null if the constructor itself failed.
|
|
274
|
+
function cableSubscribe({ channel, onUp, onFrame, onDown }) {
|
|
275
|
+
let ws;
|
|
276
|
+
try {
|
|
277
|
+
ws = new WebSocket(BASE.replace(/^http/, 'ws') + '/cable', ['actioncable-v1-json', TOKEN]);
|
|
278
|
+
} catch (e) { onDown(e.message); return null; }
|
|
279
|
+
const identifier = JSON.stringify({ channel });
|
|
280
|
+
let lastBeat = Date.now();
|
|
281
|
+
let closed = false;
|
|
282
|
+
const die = (why) => {
|
|
283
|
+
if (closed) return; closed = true;
|
|
284
|
+
clearInterval(beatCheck);
|
|
285
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
286
|
+
onDown(why);
|
|
287
|
+
};
|
|
288
|
+
const beatCheck = setInterval(() => {
|
|
289
|
+
if (Date.now() - lastBeat > 15000) die('heartbeat lost (server gone?)');
|
|
290
|
+
}, 5000);
|
|
291
|
+
ws.onopen = () => ws.send(JSON.stringify({ command: 'subscribe', identifier }));
|
|
292
|
+
ws.onmessage = (e) => {
|
|
293
|
+
lastBeat = Date.now();
|
|
294
|
+
let f; try { f = JSON.parse(e.data); } catch { return; }
|
|
295
|
+
if (f.type === 'ping' || f.type === 'welcome') return;
|
|
296
|
+
if (f.type === 'confirm_subscription') { onUp && onUp(); return; }
|
|
297
|
+
if (f.type === 'reject_subscription') { die('subscription rejected (bad token?)'); return; }
|
|
298
|
+
if (f.identifier === identifier && f.message) onFrame(f.message);
|
|
299
|
+
};
|
|
300
|
+
ws.onerror = () => { /* onclose follows with the code */ };
|
|
301
|
+
ws.onclose = (e) => die(`socket closed (${e.code})`);
|
|
302
|
+
return { close: () => { closed = true; clearInterval(beatCheck); try { ws.close(); } catch { /* noop */ } } };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Run one WS subscription session until the deadline / an unrecoverable WS
|
|
306
|
+
// problem, reconnecting with backoff in between (a deploy = seconds of gap; the
|
|
307
|
+
// criterion: hours-long listens must SURVIVE it, #119). onUp/onFrame get a
|
|
308
|
+
// `finish(reason)` to end the session (e.g. when the answer landed over HTTP).
|
|
309
|
+
// Resolves 'deadline' | 'ws-unavailable'.
|
|
310
|
+
async function cableSession({ channel, deadline, onUp, onFrame }) {
|
|
311
|
+
let wsFails = 0;
|
|
312
|
+
while (Date.now() < deadline) {
|
|
313
|
+
const outcome = await new Promise((resolve) => {
|
|
314
|
+
let sub = null;
|
|
315
|
+
let settled = false;
|
|
316
|
+
const finish = (reason) => {
|
|
317
|
+
if (settled) return; settled = true;
|
|
318
|
+
clearTimeout(guard);
|
|
319
|
+
if (sub) sub.close();
|
|
320
|
+
resolve(reason);
|
|
321
|
+
};
|
|
322
|
+
const guard = setTimeout(() => finish('deadline'), Math.max(0, deadline - Date.now()));
|
|
323
|
+
sub = cableSubscribe({
|
|
324
|
+
channel,
|
|
325
|
+
onUp: () => { wsFails = 0; onUp(finish); },
|
|
326
|
+
onFrame: (frame) => onFrame(frame, finish),
|
|
327
|
+
onDown: (why) => finish(`down: ${why}`),
|
|
328
|
+
});
|
|
329
|
+
if (!sub) finish('down: no socket');
|
|
330
|
+
});
|
|
331
|
+
if (outcome === 'deadline') return 'deadline';
|
|
332
|
+
if (!outcome.startsWith('down: ')) return outcome; // caller-driven finish (e.g. 'answered')
|
|
333
|
+
wsFails++;
|
|
334
|
+
if (wsFails >= 4) return 'ws-unavailable';
|
|
335
|
+
const backoff = Math.min(2000 * wsFails, 10000);
|
|
336
|
+
console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} — reconnecting in ${Math.round(backoff / 1000)}s (attempt ${wsFails}/3)`);
|
|
337
|
+
await sleep(backoff);
|
|
338
|
+
}
|
|
339
|
+
return 'deadline';
|
|
340
|
+
}
|
|
341
|
+
|
|
188
342
|
// Map CLI flags → the /notify JSON body, including only what was provided.
|
|
189
343
|
function buildBody() {
|
|
190
344
|
if (!v.title) die('pidge: --title is required', 1);
|
|
@@ -202,6 +356,7 @@ function buildBody() {
|
|
|
202
356
|
if (v['deliver-at'] !== undefined) body.deliver_at = v['deliver-at'];
|
|
203
357
|
if (v['reply-to'] !== undefined) body.reply_to = v['reply-to'];
|
|
204
358
|
if (v['correlation-id'] !== undefined) body.correlation_id = v['correlation-id'];
|
|
359
|
+
if (v.thread !== undefined) body.thread_id = v.thread;
|
|
205
360
|
if (v['collapse-key'] !== undefined) body.collapse_key = v['collapse-key'];
|
|
206
361
|
if (v.actions !== undefined) body.actions = v.actions.split(',').filter(Boolean);
|
|
207
362
|
|
|
@@ -319,6 +474,9 @@ async function doNotify() {
|
|
|
319
474
|
}
|
|
320
475
|
if (info.degraded)
|
|
321
476
|
console.error(`pidge: DEGRADED by channel policy — ${info.degrade_reason} (delivered anyway, quieter; the human's setting, don't retry harder)`);
|
|
477
|
+
// #49: threads — remind the agent how to keep the conversation grouped.
|
|
478
|
+
if (info.thread_id)
|
|
479
|
+
console.error(`pidge: thread=${info.thread_id} — send follow-ups with the same --thread to group them on the phone`);
|
|
322
480
|
} else {
|
|
323
481
|
console.error(`pidge: send failed (${res.status}): ${raw}`);
|
|
324
482
|
}
|
|
@@ -335,13 +493,16 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
335
493
|
const deadline = Date.now() + timeout * 1000;
|
|
336
494
|
let firedNotice = false;
|
|
337
495
|
for (;;) {
|
|
338
|
-
|
|
339
|
-
|
|
496
|
+
// Degraded (#119): a held poll keeps dying behind some edge — switch to
|
|
497
|
+
// PLAIN GETs (the requests that kept working in the wild) on a slow pace.
|
|
498
|
+
const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
499
|
+
const url = `${BASE}/api/v1/notifications/${encodeURIComponent(cid)}${waitS > 0 ? `?wait=${waitS}` : ''}`;
|
|
340
500
|
const askedAt = Date.now();
|
|
341
501
|
try {
|
|
342
502
|
const res = await fetch(url, { headers });
|
|
343
503
|
checkManifestNews(res);
|
|
344
504
|
if (res.status === 200) {
|
|
505
|
+
health.ok();
|
|
345
506
|
const data = await res.json().catch(() => ({}));
|
|
346
507
|
if (data.responded) {
|
|
347
508
|
const chosen = data.chosen_action || {};
|
|
@@ -353,26 +514,86 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
353
514
|
}
|
|
354
515
|
} else if (!firedNotice && data.escalation && data.escalation.state === 'fired') {
|
|
355
516
|
firedNotice = true;
|
|
356
|
-
|
|
517
|
+
// #70: stopping the ring on-device now reports `seen` (seen_at flips);
|
|
518
|
+
// snoozing it is a real snoozed event this loop narrates.
|
|
519
|
+
console.error('pidge: the escalation alarm FIRED and there is still no answer — seen_at tells you if the human at least silenced it; keep waiting or back off');
|
|
357
520
|
}
|
|
358
521
|
} else if (res.status === 404) {
|
|
522
|
+
health.ok(); // the server ANSWERED — the channel is fine, the cid isn't known (yet)
|
|
359
523
|
console.error(`pidge: no notification for correlation_id=${cid}`);
|
|
360
524
|
// keep polling — the agent may call wait/ask before the send round-trips
|
|
525
|
+
} else if (res.status >= 500) {
|
|
526
|
+
health.fail(`poll error ${res.status}`); // aggregated — no line per failure
|
|
361
527
|
} else {
|
|
528
|
+
health.ok();
|
|
362
529
|
console.error(`pidge: poll error ${res.status}`);
|
|
363
530
|
}
|
|
364
531
|
} catch (e) {
|
|
365
|
-
|
|
532
|
+
health.fail(`network: ${e.message}`);
|
|
366
533
|
}
|
|
367
534
|
|
|
368
535
|
if (Date.now() >= deadline) {
|
|
369
|
-
|
|
370
|
-
process.exit(3);
|
|
536
|
+
health.exitTimeout(`timed out after ${timeout}s waiting on ${cid}`);
|
|
371
537
|
}
|
|
372
538
|
// A server WITH long-poll just held us for waitS — loop right back. One that
|
|
373
|
-
// ignored `wait
|
|
374
|
-
|
|
539
|
+
// ignored `wait`, an error, or degraded mode returned fast: pace ourselves.
|
|
540
|
+
const pace = health.degraded ? DEGRADED_INTERVAL_S : interval;
|
|
541
|
+
if (Date.now() - askedAt < 2000) {
|
|
542
|
+
await sleep(Math.min(pace, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))) * 1000);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Realtime wait (#118): hold an InboxChannel subscription and treat every frame
|
|
548
|
+
// for OUR cid as a wake-up; the durable answer is always re-read over HTTP
|
|
549
|
+
// (doWait prints + exits). A safety re-check every 60 s covers a frame lost in
|
|
550
|
+
// a reconnect gap. Returns only when WS can't carry us — caller falls back.
|
|
551
|
+
async function realtimeWait(cid, { timeout, interval }) {
|
|
552
|
+
const deadline = Date.now() + timeout * 1000;
|
|
553
|
+
const answered = async () => {
|
|
554
|
+
try {
|
|
555
|
+
const res = await fetch(`${BASE}/api/v1/notifications/${encodeURIComponent(cid)}`, { headers });
|
|
556
|
+
if (res.status !== 200) return false;
|
|
557
|
+
const data = await res.json().catch(() => ({}));
|
|
558
|
+
return !!(data.responded && data.chosen_action && data.chosen_action.kind !== 'snoozed');
|
|
559
|
+
} catch { return false; }
|
|
560
|
+
};
|
|
561
|
+
let safety = null;
|
|
562
|
+
const outcome = await cableSession({
|
|
563
|
+
channel: 'InboxChannel',
|
|
564
|
+
deadline,
|
|
565
|
+
onUp: (finish) => {
|
|
566
|
+
health.ok();
|
|
567
|
+
// catch an answer that landed while we were connecting/offline
|
|
568
|
+
answered().then((done) => done && finish('answered'));
|
|
569
|
+
clearInterval(safety);
|
|
570
|
+
safety = setInterval(() => answered().then((done) => done && finish('answered')), 60000);
|
|
571
|
+
},
|
|
572
|
+
onFrame: (m, finish) => {
|
|
573
|
+
if (m.type !== 'event' || m.correlation_id !== cid) return;
|
|
574
|
+
if (m.kind === 'delivered') console.error('pidge: delivered to the phone');
|
|
575
|
+
else if (m.kind === 'seen') console.error('pidge: the human OPENED it (no answer yet)');
|
|
576
|
+
else if (m.kind === 'snoozed') console.error(`pidge: snoozed until ${m.snooze_until || m.at} — re-fires then, still waiting`);
|
|
577
|
+
else if (m.responded) finish('answered');
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
clearInterval(safety);
|
|
581
|
+
if (outcome === 'answered') {
|
|
582
|
+
// fetch + print + exit via the poller (one quick authoritative read)
|
|
583
|
+
await doWait(cid, { timeout: Math.max(10, Math.ceil((deadline - Date.now()) / 1000)), interval });
|
|
375
584
|
}
|
|
585
|
+
if (outcome === 'deadline') {
|
|
586
|
+
health.exitTimeout(`timed out after ${timeout}s waiting on ${cid}`);
|
|
587
|
+
}
|
|
588
|
+
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
589
|
+
return Math.max(1, Math.ceil((deadline - Date.now()) / 1000)); // remaining budget
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// wait/ask entry: WS when we can, polling as the universal fallback (#118/#119).
|
|
593
|
+
async function waitForAnswer(cid, { timeout, interval }) {
|
|
594
|
+
let budget = timeout;
|
|
595
|
+
if (wantRealtime()) budget = await realtimeWait(cid, { timeout, interval });
|
|
596
|
+
await doWait(cid, { timeout: budget, interval });
|
|
376
597
|
}
|
|
377
598
|
|
|
378
599
|
const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
|
|
@@ -403,13 +624,13 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
403
624
|
const { ok, info } = await doNotify();
|
|
404
625
|
if (!ok) process.exit(2);
|
|
405
626
|
console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
|
|
406
|
-
await
|
|
627
|
+
await waitForAnswer(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
|
|
407
628
|
break;
|
|
408
629
|
}
|
|
409
630
|
case 'wait': {
|
|
410
631
|
const cid = parsed.positionals[1];
|
|
411
632
|
if (!cid) die('pidge: usage: pidge wait <correlation_id> [--timeout N] [--interval N]', 1);
|
|
412
|
-
await
|
|
633
|
+
await waitForAnswer(cid, { timeout: num(v.timeout, 300), interval: num(v.interval, 30) });
|
|
413
634
|
break;
|
|
414
635
|
}
|
|
415
636
|
case 'cancel': {
|
|
@@ -436,6 +657,144 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
436
657
|
process.exit(2);
|
|
437
658
|
break;
|
|
438
659
|
}
|
|
660
|
+
case 'inbox': {
|
|
661
|
+
// #83: what this channel sent — the list (default), the pending slice
|
|
662
|
+
// (--pending = delivered + still unanswered) or the one-call summary
|
|
663
|
+
// (--summary = counts + answer latency). stdout = raw server JSON.
|
|
664
|
+
const qs = new URLSearchParams();
|
|
665
|
+
if (v.all) qs.set('all', 'true');
|
|
666
|
+
let inboxPath = '/api/v1/inbox/summary';
|
|
667
|
+
if (!v.summary) {
|
|
668
|
+
inboxPath = '/api/v1/notifications';
|
|
669
|
+
if (v.pending) qs.set('pending', 'true');
|
|
670
|
+
if (v.limit !== undefined) qs.set('limit', v.limit);
|
|
671
|
+
}
|
|
672
|
+
let res, raw;
|
|
673
|
+
try {
|
|
674
|
+
res = await fetch(`${BASE}${inboxPath}${qs.size ? `?${qs}` : ''}`, { headers });
|
|
675
|
+
raw = await res.text();
|
|
676
|
+
} catch (e) {
|
|
677
|
+
die(`pidge: inbox failed (network): ${e.message}`, 2);
|
|
678
|
+
}
|
|
679
|
+
checkManifestNews(res);
|
|
680
|
+
console.log(raw);
|
|
681
|
+
if (!(res.status >= 200 && res.status < 300)) die(`pidge: inbox failed (${res.status})`, 2);
|
|
682
|
+
let data = {};
|
|
683
|
+
try { data = JSON.parse(raw); } catch { /* leave {} */ }
|
|
684
|
+
if (v.summary) {
|
|
685
|
+
const latency = data.avg_response_seconds != null
|
|
686
|
+
? `, human answers in ~${Math.round(data.avg_response_seconds / 60)} min` : '';
|
|
687
|
+
console.error(`pidge: ${data.total} sent (${data.scope}) — ${data.pending} pending${latency}`);
|
|
688
|
+
} else {
|
|
689
|
+
const rows = data.notifications || [];
|
|
690
|
+
const pendingCount = rows.filter((r) => r.status === 'delivered' && !r.responded).length;
|
|
691
|
+
console.error(`pidge: ${rows.length} notification(s)${v.pending ? ' pending' : ` — ${pendingCount} pending`} (add --summary for counts+latency)`);
|
|
692
|
+
}
|
|
693
|
+
process.exit(0);
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
case 'listen': {
|
|
697
|
+
// #48: block until the human messages this channel (the app's composer),
|
|
698
|
+
// print the messages as JSON, ACK them, exit 0. One-shot by design (loop
|
|
699
|
+
// it, don't daemonize) — same contract as `wait`. Exit 3 on timeout, 4 if
|
|
700
|
+
// the whole session never had a healthy round-trip (#119).
|
|
701
|
+
// At-least-once: the ack happens AFTER the print — a crash re-serves them;
|
|
702
|
+
// dedupe by id if you've seen one before.
|
|
703
|
+
const timeout = num(v.timeout, 600);
|
|
704
|
+
let deadline = Date.now() + timeout * 1000;
|
|
705
|
+
|
|
706
|
+
// Print + ack + exit 0 — shared by the WS and polling paths.
|
|
707
|
+
const printAndAck = async (msgs) => {
|
|
708
|
+
console.log(JSON.stringify(msgs, null, 2));
|
|
709
|
+
const upTo = Math.max(...msgs.map((m) => m.id));
|
|
710
|
+
try {
|
|
711
|
+
const ack = await fetch(`${BASE}/api/v1/messages/ack`, {
|
|
712
|
+
method: 'POST', headers, body: JSON.stringify({ up_to: upTo }),
|
|
713
|
+
});
|
|
714
|
+
if (ack.status >= 200 && ack.status < 300) {
|
|
715
|
+
console.error(`pidge: ${msgs.length} message(s) from the human — acked (answer via notify; reuse thread_id when present)`);
|
|
716
|
+
} else {
|
|
717
|
+
console.error(`pidge: WARNING — ack failed (${ack.status}); these messages will be re-served next listen`);
|
|
718
|
+
}
|
|
719
|
+
} catch (e) {
|
|
720
|
+
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
721
|
+
}
|
|
722
|
+
process.exit(0);
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// Realtime path (#118): hold ConversationChannel — the human sees "ouvindo
|
|
726
|
+
// agora" — and treat frames as wake-ups: the BACKLOG is always re-read over
|
|
727
|
+
// a plain GET (at-least-once; also catches messages sent while offline).
|
|
728
|
+
if (wantRealtime()) {
|
|
729
|
+
let draining = false;
|
|
730
|
+
const drain = async (finish) => {
|
|
731
|
+
if (draining) return;
|
|
732
|
+
draining = true;
|
|
733
|
+
try {
|
|
734
|
+
const res = await fetch(`${BASE}/api/v1/messages`, { headers });
|
|
735
|
+
checkManifestNews(res);
|
|
736
|
+
if (res.status === 200) {
|
|
737
|
+
health.ok();
|
|
738
|
+
const msgs = (await res.json().catch(() => ({}))).messages || [];
|
|
739
|
+
if (msgs.length) { finish('got-messages'); await printAndAck(msgs); }
|
|
740
|
+
} else if (res.status >= 500) {
|
|
741
|
+
health.fail(`backlog read ${res.status}`);
|
|
742
|
+
}
|
|
743
|
+
} catch (e) {
|
|
744
|
+
health.fail(`backlog read (network: ${e.message})`);
|
|
745
|
+
} finally {
|
|
746
|
+
draining = false;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
let announced = false;
|
|
750
|
+
const outcome = await cableSession({
|
|
751
|
+
channel: 'ConversationChannel',
|
|
752
|
+
deadline,
|
|
753
|
+
onUp: (finish) => {
|
|
754
|
+
if (!announced) { announced = true; console.error('pidge: listening over the realtime socket (the human sees "ouvindo agora")'); }
|
|
755
|
+
drain(finish);
|
|
756
|
+
},
|
|
757
|
+
onFrame: (m, finish) => { if (m.type === 'message') drain(finish); },
|
|
758
|
+
});
|
|
759
|
+
if (outcome === 'deadline') {
|
|
760
|
+
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
761
|
+
}
|
|
762
|
+
if (outcome === 'got-messages') {
|
|
763
|
+
await new Promise(() => {}); // printAndAck is in flight and exits the process
|
|
764
|
+
}
|
|
765
|
+
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
for (;;) {
|
|
769
|
+
const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
770
|
+
const askedAt = Date.now();
|
|
771
|
+
try {
|
|
772
|
+
const res = await fetch(`${BASE}/api/v1/messages${waitS > 0 ? `?wait=${waitS}` : ''}`, { headers });
|
|
773
|
+
checkManifestNews(res);
|
|
774
|
+
if (res.status === 200) {
|
|
775
|
+
health.ok();
|
|
776
|
+
const data = await res.json().catch(() => ({}));
|
|
777
|
+
const msgs = data.messages || [];
|
|
778
|
+
if (msgs.length) await printAndAck(msgs);
|
|
779
|
+
} else if (res.status >= 500) {
|
|
780
|
+
health.fail(`listen error ${res.status}`); // aggregated (#119) — no line per attempt
|
|
781
|
+
} else {
|
|
782
|
+
health.ok();
|
|
783
|
+
console.error(`pidge: listen error ${res.status}`);
|
|
784
|
+
}
|
|
785
|
+
} catch (e) {
|
|
786
|
+
health.fail(`network: ${e.message}`);
|
|
787
|
+
}
|
|
788
|
+
if (Date.now() >= deadline) {
|
|
789
|
+
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
790
|
+
}
|
|
791
|
+
const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
|
|
792
|
+
if (Date.now() - askedAt < 2000) {
|
|
793
|
+
await sleep(Math.min(pace, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))) * 1000);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
439
798
|
default:
|
|
440
799
|
die(USAGE, 1);
|
|
441
800
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pidge-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Send rich, actionable iPhone notifications to a human and block until they answer. Built for AI agents.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pidge",
|
|
7
|
+
"notifications",
|
|
8
|
+
"agent",
|
|
9
|
+
"cli",
|
|
10
|
+
"push",
|
|
11
|
+
"ai"
|
|
12
|
+
],
|
|
6
13
|
"license": "MIT",
|
|
7
14
|
"type": "commonjs",
|
|
8
15
|
"bin": {
|
|
@@ -13,9 +20,15 @@
|
|
|
13
20
|
"README.md",
|
|
14
21
|
"LICENSE"
|
|
15
22
|
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/cli.test.js"
|
|
25
|
+
},
|
|
16
26
|
"engines": {
|
|
17
27
|
"node": ">=18"
|
|
18
28
|
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
},
|
|
19
32
|
"repository": {
|
|
20
33
|
"type": "git",
|
|
21
34
|
"url": "git+https://github.com/thiagoc7/pidge-cli.git"
|