pidge-cli 0.5.0 → 0.6.1
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 +316 -40
- package/package.json +7 -1
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
|
@@ -99,6 +99,9 @@ const OPTIONS = {
|
|
|
99
99
|
summary: { type: 'boolean' },
|
|
100
100
|
all: { type: 'boolean' },
|
|
101
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
|
|
102
105
|
};
|
|
103
106
|
|
|
104
107
|
const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
|
|
@@ -112,6 +115,19 @@ USAGE
|
|
|
112
115
|
pidge listen [--timeout N] block until the human MESSAGES you from the app, print + ack + exit (#48)
|
|
113
116
|
pidge --help
|
|
114
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). Degrade is STICKY for
|
|
128
|
+
the session (we can't probe held-poll health without re-paying the failure) —
|
|
129
|
+
re-invoke the command to retry the fast path.
|
|
130
|
+
|
|
115
131
|
OPTIONS (notify / ask)
|
|
116
132
|
--title TEXT (required) the headline
|
|
117
133
|
--body TEXT message shown on the banner
|
|
@@ -145,8 +161,8 @@ OPTIONS (notify / ask)
|
|
|
145
161
|
--param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
|
|
146
162
|
fields work without a CLI update; the manifest is the contract
|
|
147
163
|
--timeout SECONDS ask: 600 · wait: 300
|
|
148
|
-
--interval SECONDS FALLBACK poll cadence (default 30) — normally unused:
|
|
149
|
-
server long-
|
|
164
|
+
--interval SECONDS FALLBACK poll cadence (default 30) — normally unused: WS or
|
|
165
|
+
the server-held long-poll (?wait=25) make answers ~instant
|
|
150
166
|
|
|
151
167
|
ENV
|
|
152
168
|
PIDGE_URL your Pidge server (default http://localhost:3000; HERALD_URL honored)
|
|
@@ -157,7 +173,9 @@ ENV
|
|
|
157
173
|
OUTPUT
|
|
158
174
|
stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
|
|
159
175
|
human notices go to stderr. Exit: 0 answered · 3 timed out (no answer yet,
|
|
160
|
-
not a failure) ·
|
|
176
|
+
not a failure) · 4 timed out WITHOUT ONE healthy round-trip all session (the
|
|
177
|
+
CHANNEL looks broken — server/network — not the human ignoring you: surface
|
|
178
|
+
it instead of retrying blindly, #119) · 2 error · 1 usage.
|
|
161
179
|
|
|
162
180
|
Responses are one-and-done EXCEPT snooze/reschedule (they re-fire); ask/wait keep
|
|
163
181
|
polling through a snooze and print snooze_until. Follow-up = a NEW notification.
|
|
@@ -184,19 +202,158 @@ if (!TOKEN) die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.c
|
|
|
184
202
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
185
203
|
const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
|
|
186
204
|
|
|
205
|
+
// fetch with a hard timeout (#119 review): a wedged edge proxy can stall even a
|
|
206
|
+
// short POST forever, and a hung ack on the realtime listen path would pin the
|
|
207
|
+
// process past its deadline — worse than going deaf. NOTHING in this CLI should
|
|
208
|
+
// await a fetch that can't time out. A held long-poll passes its own (larger)
|
|
209
|
+
// timeout; everything else uses the 30 s default.
|
|
210
|
+
function fetchT(url, opts = {}, timeoutMs = 30000) {
|
|
211
|
+
const ms = parseInt(process.env.PIDGE_FETCH_TIMEOUT || '', 10) || timeoutMs; // test/ops hook
|
|
212
|
+
const ctl = new AbortController();
|
|
213
|
+
const t = setTimeout(() => ctl.abort(new Error(`timeout after ${ms}ms`)), ms);
|
|
214
|
+
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(t));
|
|
215
|
+
}
|
|
216
|
+
|
|
187
217
|
// The server advertises its manifest version on every response. When it's newer
|
|
188
218
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
189
219
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
190
|
-
const KNOWN_MANIFEST_VERSION =
|
|
220
|
+
const KNOWN_MANIFEST_VERSION = 16;
|
|
191
221
|
let newsWarned = false;
|
|
192
222
|
function checkManifestNews(res) {
|
|
193
223
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
194
224
|
if (v > KNOWN_MANIFEST_VERSION && !newsWarned) {
|
|
195
225
|
newsWarned = true;
|
|
196
|
-
|
|
226
|
+
// #119: a pinned npx ref never updates itself — give the CONCRETE command.
|
|
227
|
+
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)`);
|
|
197
228
|
}
|
|
198
229
|
}
|
|
199
230
|
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// #119: the health ledger of one blocking session (wait/ask/listen). Drives
|
|
233
|
+
// (a) automatic DEGRADE from held ?wait= polls to plain GETs after
|
|
234
|
+
// DEGRADE_AFTER consecutive failures (an edge that kills held responses
|
|
235
|
+
// leaves short requests fine — the channel stays alive, less instant),
|
|
236
|
+
// (b) ONE aggregated deafness line per minute instead of a line per failure,
|
|
237
|
+
// (c) exit code 4 when the session ends with ZERO healthy round-trips —
|
|
238
|
+
// deafness must exit LOUD, not masked as "the human didn't answer".
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
const DEGRADE_AFTER = 3;
|
|
241
|
+
// env override = a test/ops hook, not a documented knob
|
|
242
|
+
const DEGRADED_INTERVAL_S = parseInt(process.env.PIDGE_DEGRADED_INTERVAL || '45', 10);
|
|
243
|
+
const health = {
|
|
244
|
+
okEver: false, fails: 0, firstFailAt: 0, lastNoteAt: 0, degraded: false,
|
|
245
|
+
ok() {
|
|
246
|
+
if (this.fails > 0) console.error(`pidge: channel recovered after ${this.fails} consecutive failure(s)`);
|
|
247
|
+
this.okEver = true; this.fails = 0; this.firstFailAt = 0; this.lastNoteAt = 0;
|
|
248
|
+
},
|
|
249
|
+
fail(what) {
|
|
250
|
+
this.fails++;
|
|
251
|
+
if (!this.firstFailAt) { this.firstFailAt = Date.now(); this.lastNoteAt = Date.now(); }
|
|
252
|
+
if (!this.degraded && this.fails >= DEGRADE_AFTER) {
|
|
253
|
+
this.degraded = true;
|
|
254
|
+
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}`);
|
|
255
|
+
} else if (this.fails === 1 || Date.now() - this.lastNoteAt >= 60000) {
|
|
256
|
+
this.lastNoteAt = Date.now();
|
|
257
|
+
const mins = Math.round((Date.now() - this.firstFailAt) / 60000);
|
|
258
|
+
console.error(`pidge: deaf for ${mins} min — ${this.fails} consecutive failure(s) (latest: ${what})`);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
exitTimeout(message) {
|
|
262
|
+
if (this.okEver) { console.error(`pidge: ${message} (= 'no answer yet', not a failure)`); process.exit(3); }
|
|
263
|
+
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.`);
|
|
264
|
+
process.exit(4);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Realtime (#118): a minimal ActionCable client over the runtime's native
|
|
270
|
+
// WebSocket (Node ≥22). The token rides an extra Sec-WebSocket-Protocol entry
|
|
271
|
+
// (the browser-style API can't set headers). The WS is a WAKE-UP + payload
|
|
272
|
+
// channel only — every durable read (message backlog, chosen_action) still
|
|
273
|
+
// goes over HTTP, so a dropped socket costs latency, never data.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
function wantRealtime() {
|
|
276
|
+
if (v['no-realtime']) return false;
|
|
277
|
+
if (typeof WebSocket !== 'function') {
|
|
278
|
+
if (v.realtime) console.error('pidge: --realtime needs a native WebSocket (Node ≥22) — falling back to polling');
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Speak just enough of the protocol: welcome → subscribe → confirm → frames.
|
|
285
|
+
// The server pings every ~3 s — that heartbeat is the liveness check (silence
|
|
286
|
+
// >15 s ⇒ the socket is dead even if TCP hasn't noticed; close → caller
|
|
287
|
+
// reconnects). Returns {close()} or null if the constructor itself failed.
|
|
288
|
+
function cableSubscribe({ channel, onUp, onFrame, onDown }) {
|
|
289
|
+
let ws;
|
|
290
|
+
try {
|
|
291
|
+
ws = new WebSocket(BASE.replace(/^http/, 'ws') + '/cable', ['actioncable-v1-json', TOKEN]);
|
|
292
|
+
} catch (e) { onDown(e.message); return null; }
|
|
293
|
+
const identifier = JSON.stringify({ channel });
|
|
294
|
+
let lastBeat = Date.now();
|
|
295
|
+
let closed = false;
|
|
296
|
+
const die = (why) => {
|
|
297
|
+
if (closed) return; closed = true;
|
|
298
|
+
clearInterval(beatCheck);
|
|
299
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
300
|
+
onDown(why);
|
|
301
|
+
};
|
|
302
|
+
const beatCheck = setInterval(() => {
|
|
303
|
+
if (Date.now() - lastBeat > 15000) die('heartbeat lost (server gone?)');
|
|
304
|
+
}, 5000);
|
|
305
|
+
ws.onopen = () => ws.send(JSON.stringify({ command: 'subscribe', identifier }));
|
|
306
|
+
ws.onmessage = (e) => {
|
|
307
|
+
lastBeat = Date.now();
|
|
308
|
+
let f; try { f = JSON.parse(e.data); } catch { return; }
|
|
309
|
+
if (f.type === 'ping' || f.type === 'welcome') return;
|
|
310
|
+
if (f.type === 'confirm_subscription') { onUp && onUp(); return; }
|
|
311
|
+
if (f.type === 'reject_subscription') { die('subscription rejected (bad token?)'); return; }
|
|
312
|
+
if (f.identifier === identifier && f.message) onFrame(f.message);
|
|
313
|
+
};
|
|
314
|
+
ws.onerror = () => { /* onclose follows with the code */ };
|
|
315
|
+
ws.onclose = (e) => die(`socket closed (${e.code})`);
|
|
316
|
+
return { close: () => { closed = true; clearInterval(beatCheck); try { ws.close(); } catch { /* noop */ } } };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Run one WS subscription session until the deadline / an unrecoverable WS
|
|
320
|
+
// problem, reconnecting with backoff in between (a deploy = seconds of gap; the
|
|
321
|
+
// criterion: hours-long listens must SURVIVE it, #119). onUp/onFrame get a
|
|
322
|
+
// `finish(reason)` to end the session (e.g. when the answer landed over HTTP).
|
|
323
|
+
// Resolves 'deadline' | 'ws-unavailable'.
|
|
324
|
+
async function cableSession({ channel, deadline, onUp, onFrame }) {
|
|
325
|
+
let wsFails = 0;
|
|
326
|
+
while (Date.now() < deadline) {
|
|
327
|
+
const outcome = await new Promise((resolve) => {
|
|
328
|
+
let sub = null;
|
|
329
|
+
let settled = false;
|
|
330
|
+
const finish = (reason) => {
|
|
331
|
+
if (settled) return; settled = true;
|
|
332
|
+
clearTimeout(guard);
|
|
333
|
+
if (sub) sub.close();
|
|
334
|
+
resolve(reason);
|
|
335
|
+
};
|
|
336
|
+
const guard = setTimeout(() => finish('deadline'), Math.max(0, deadline - Date.now()));
|
|
337
|
+
sub = cableSubscribe({
|
|
338
|
+
channel,
|
|
339
|
+
onUp: () => { wsFails = 0; onUp(finish); },
|
|
340
|
+
onFrame: (frame) => onFrame(frame, finish),
|
|
341
|
+
onDown: (why) => finish(`down: ${why}`),
|
|
342
|
+
});
|
|
343
|
+
if (!sub) finish('down: no socket');
|
|
344
|
+
});
|
|
345
|
+
if (outcome === 'deadline') return 'deadline';
|
|
346
|
+
if (!outcome.startsWith('down: ')) return outcome; // caller-driven finish (e.g. 'answered')
|
|
347
|
+
wsFails++;
|
|
348
|
+
const MAX_WS_FAILS = 4; // then fall back to polling for the rest of the session
|
|
349
|
+
if (wsFails >= MAX_WS_FAILS) return 'ws-unavailable';
|
|
350
|
+
const backoff = Math.min(2000 * wsFails, 10000);
|
|
351
|
+
console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} — reconnecting in ${Math.round(backoff / 1000)}s (attempt ${wsFails}/${MAX_WS_FAILS})`);
|
|
352
|
+
await sleep(backoff);
|
|
353
|
+
}
|
|
354
|
+
return 'deadline';
|
|
355
|
+
}
|
|
356
|
+
|
|
200
357
|
// Map CLI flags → the /notify JSON body, including only what was provided.
|
|
201
358
|
function buildBody() {
|
|
202
359
|
if (!v.title) die('pidge: --title is required', 1);
|
|
@@ -351,13 +508,16 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
351
508
|
const deadline = Date.now() + timeout * 1000;
|
|
352
509
|
let firedNotice = false;
|
|
353
510
|
for (;;) {
|
|
354
|
-
|
|
355
|
-
|
|
511
|
+
// Degraded (#119): a held poll keeps dying behind some edge — switch to
|
|
512
|
+
// PLAIN GETs (the requests that kept working in the wild) on a slow pace.
|
|
513
|
+
const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
514
|
+
const url = `${BASE}/api/v1/notifications/${encodeURIComponent(cid)}${waitS > 0 ? `?wait=${waitS}` : ''}`;
|
|
356
515
|
const askedAt = Date.now();
|
|
357
516
|
try {
|
|
358
|
-
const res = await
|
|
517
|
+
const res = await fetchT(url, { headers }, (waitS + 10) * 1000);
|
|
359
518
|
checkManifestNews(res);
|
|
360
519
|
if (res.status === 200) {
|
|
520
|
+
health.ok();
|
|
361
521
|
const data = await res.json().catch(() => ({}));
|
|
362
522
|
if (data.responded) {
|
|
363
523
|
const chosen = data.chosen_action || {};
|
|
@@ -374,23 +534,81 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
374
534
|
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');
|
|
375
535
|
}
|
|
376
536
|
} else if (res.status === 404) {
|
|
537
|
+
health.ok(); // the server ANSWERED — the channel is fine, the cid isn't known (yet)
|
|
377
538
|
console.error(`pidge: no notification for correlation_id=${cid}`);
|
|
378
539
|
// keep polling — the agent may call wait/ask before the send round-trips
|
|
540
|
+
} else if (res.status >= 500) {
|
|
541
|
+
health.fail(`poll error ${res.status}`); // aggregated — no line per failure
|
|
379
542
|
} else {
|
|
543
|
+
health.ok();
|
|
380
544
|
console.error(`pidge: poll error ${res.status}`);
|
|
381
545
|
}
|
|
382
546
|
} catch (e) {
|
|
383
|
-
|
|
547
|
+
health.fail(`network: ${e.message}`);
|
|
384
548
|
}
|
|
385
549
|
|
|
386
550
|
if (Date.now() >= deadline) {
|
|
387
|
-
|
|
388
|
-
process.exit(3);
|
|
551
|
+
health.exitTimeout(`timed out after ${timeout}s waiting on ${cid}`);
|
|
389
552
|
}
|
|
390
553
|
// A server WITH long-poll just held us for waitS — loop right back. One that
|
|
391
|
-
// ignored `wait
|
|
392
|
-
|
|
554
|
+
// ignored `wait`, an error, or degraded mode returned fast: pace ourselves.
|
|
555
|
+
const pace = health.degraded ? DEGRADED_INTERVAL_S : interval;
|
|
556
|
+
if (Date.now() - askedAt < 2000) {
|
|
557
|
+
await sleep(Math.min(pace, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))) * 1000);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Realtime wait (#118): hold an InboxChannel subscription and treat every frame
|
|
563
|
+
// for OUR cid as a wake-up; the durable answer is always re-read over HTTP
|
|
564
|
+
// (doWait prints + exits). A safety re-check every 60 s covers a frame lost in
|
|
565
|
+
// a reconnect gap. Returns only when WS can't carry us — caller falls back.
|
|
566
|
+
async function realtimeWait(cid, { timeout, interval }) {
|
|
567
|
+
const deadline = Date.now() + timeout * 1000;
|
|
568
|
+
const answered = async () => {
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetchT(`${BASE}/api/v1/notifications/${encodeURIComponent(cid)}`, { headers });
|
|
571
|
+
if (res.status !== 200) return false;
|
|
572
|
+
const data = await res.json().catch(() => ({}));
|
|
573
|
+
return !!(data.responded && data.chosen_action && data.chosen_action.kind !== 'snoozed');
|
|
574
|
+
} catch { return false; }
|
|
575
|
+
};
|
|
576
|
+
let safety = null;
|
|
577
|
+
const outcome = await cableSession({
|
|
578
|
+
channel: 'InboxChannel',
|
|
579
|
+
deadline,
|
|
580
|
+
onUp: (finish) => {
|
|
581
|
+
health.ok();
|
|
582
|
+
// catch an answer that landed while we were connecting/offline
|
|
583
|
+
answered().then((done) => done && finish('answered'));
|
|
584
|
+
clearInterval(safety);
|
|
585
|
+
safety = setInterval(() => answered().then((done) => done && finish('answered')), 60000);
|
|
586
|
+
},
|
|
587
|
+
onFrame: (m, finish) => {
|
|
588
|
+
if (m.type !== 'event' || m.correlation_id !== cid) return;
|
|
589
|
+
if (m.kind === 'delivered') console.error('pidge: delivered to the phone');
|
|
590
|
+
else if (m.kind === 'seen') console.error('pidge: the human OPENED it (no answer yet)');
|
|
591
|
+
else if (m.kind === 'snoozed') console.error(`pidge: snoozed until ${m.snooze_until || m.at} — re-fires then, still waiting`);
|
|
592
|
+
else if (m.responded) finish('answered');
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
clearInterval(safety);
|
|
596
|
+
if (outcome === 'answered') {
|
|
597
|
+
// fetch + print + exit via the poller (one quick authoritative read)
|
|
598
|
+
await doWait(cid, { timeout: Math.max(10, Math.ceil((deadline - Date.now()) / 1000)), interval });
|
|
599
|
+
}
|
|
600
|
+
if (outcome === 'deadline') {
|
|
601
|
+
health.exitTimeout(`timed out after ${timeout}s waiting on ${cid}`);
|
|
393
602
|
}
|
|
603
|
+
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
604
|
+
return Math.max(1, Math.ceil((deadline - Date.now()) / 1000)); // remaining budget
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// wait/ask entry: WS when we can, polling as the universal fallback (#118/#119).
|
|
608
|
+
async function waitForAnswer(cid, { timeout, interval }) {
|
|
609
|
+
let budget = timeout;
|
|
610
|
+
if (wantRealtime()) budget = await realtimeWait(cid, { timeout, interval });
|
|
611
|
+
await doWait(cid, { timeout: budget, interval });
|
|
394
612
|
}
|
|
395
613
|
|
|
396
614
|
const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
|
|
@@ -421,13 +639,13 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
421
639
|
const { ok, info } = await doNotify();
|
|
422
640
|
if (!ok) process.exit(2);
|
|
423
641
|
console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
|
|
424
|
-
await
|
|
642
|
+
await waitForAnswer(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
|
|
425
643
|
break;
|
|
426
644
|
}
|
|
427
645
|
case 'wait': {
|
|
428
646
|
const cid = parsed.positionals[1];
|
|
429
647
|
if (!cid) die('pidge: usage: pidge wait <correlation_id> [--timeout N] [--interval N]', 1);
|
|
430
|
-
await
|
|
648
|
+
await waitForAnswer(cid, { timeout: num(v.timeout, 300), interval: num(v.interval, 30) });
|
|
431
649
|
break;
|
|
432
650
|
}
|
|
433
651
|
case 'cancel': {
|
|
@@ -493,48 +711,106 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
493
711
|
case 'listen': {
|
|
494
712
|
// #48: block until the human messages this channel (the app's composer),
|
|
495
713
|
// print the messages as JSON, ACK them, exit 0. One-shot by design (loop
|
|
496
|
-
// it, don't daemonize) — same contract as `wait`. Exit 3 on timeout
|
|
714
|
+
// it, don't daemonize) — same contract as `wait`. Exit 3 on timeout, 4 if
|
|
715
|
+
// the whole session never had a healthy round-trip (#119).
|
|
497
716
|
// At-least-once: the ack happens AFTER the print — a crash re-serves them;
|
|
498
717
|
// dedupe by id if you've seen one before.
|
|
499
718
|
const timeout = num(v.timeout, 600);
|
|
500
|
-
|
|
719
|
+
let deadline = Date.now() + timeout * 1000;
|
|
720
|
+
|
|
721
|
+
// Print + ack + exit 0 — shared by the WS and polling paths.
|
|
722
|
+
const printAndAck = async (msgs) => {
|
|
723
|
+
console.log(JSON.stringify(msgs, null, 2));
|
|
724
|
+
const upTo = Math.max(...msgs.map((m) => m.id));
|
|
725
|
+
try {
|
|
726
|
+
// fetchT, not fetch: a wedged proxy stalling this ack would otherwise
|
|
727
|
+
// pin the process forever (the WS drain path awaits printAndAck's exit
|
|
728
|
+
// with no deadline) — messages are already printed, so a timeout here
|
|
729
|
+
// just re-serves them next listen (at-least-once).
|
|
730
|
+
const ack = await fetchT(`${BASE}/api/v1/messages/ack`, {
|
|
731
|
+
method: 'POST', headers, body: JSON.stringify({ up_to: upTo }),
|
|
732
|
+
});
|
|
733
|
+
if (ack.status >= 200 && ack.status < 300) {
|
|
734
|
+
console.error(`pidge: ${msgs.length} message(s) from the human — acked (answer via notify; reuse thread_id when present)`);
|
|
735
|
+
} else {
|
|
736
|
+
console.error(`pidge: WARNING — ack failed (${ack.status}); these messages will be re-served next listen`);
|
|
737
|
+
}
|
|
738
|
+
} catch (e) {
|
|
739
|
+
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
740
|
+
}
|
|
741
|
+
process.exit(0);
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// Realtime path (#118): hold ConversationChannel — the human sees "ouvindo
|
|
745
|
+
// agora" — and treat frames as wake-ups: the BACKLOG is always re-read over
|
|
746
|
+
// a plain GET (at-least-once; also catches messages sent while offline).
|
|
747
|
+
if (wantRealtime()) {
|
|
748
|
+
let draining = false;
|
|
749
|
+
const drain = async (finish) => {
|
|
750
|
+
if (draining) return;
|
|
751
|
+
draining = true;
|
|
752
|
+
try {
|
|
753
|
+
const res = await fetchT(`${BASE}/api/v1/messages`, { headers });
|
|
754
|
+
checkManifestNews(res);
|
|
755
|
+
if (res.status === 200) {
|
|
756
|
+
health.ok();
|
|
757
|
+
const msgs = (await res.json().catch(() => ({}))).messages || [];
|
|
758
|
+
if (msgs.length) { finish('got-messages'); await printAndAck(msgs); }
|
|
759
|
+
} else if (res.status >= 500) {
|
|
760
|
+
health.fail(`backlog read ${res.status}`);
|
|
761
|
+
}
|
|
762
|
+
} catch (e) {
|
|
763
|
+
health.fail(`backlog read (network: ${e.message})`);
|
|
764
|
+
} finally {
|
|
765
|
+
draining = false;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
let announced = false;
|
|
769
|
+
const outcome = await cableSession({
|
|
770
|
+
channel: 'ConversationChannel',
|
|
771
|
+
deadline,
|
|
772
|
+
onUp: (finish) => {
|
|
773
|
+
if (!announced) { announced = true; console.error('pidge: listening over the realtime socket (the human sees "ouvindo agora")'); }
|
|
774
|
+
drain(finish);
|
|
775
|
+
},
|
|
776
|
+
onFrame: (m, finish) => { if (m.type === 'message') drain(finish); },
|
|
777
|
+
});
|
|
778
|
+
if (outcome === 'deadline') {
|
|
779
|
+
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
780
|
+
}
|
|
781
|
+
if (outcome === 'got-messages') {
|
|
782
|
+
await new Promise(() => {}); // printAndAck is in flight and exits the process
|
|
783
|
+
}
|
|
784
|
+
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
785
|
+
}
|
|
786
|
+
|
|
501
787
|
for (;;) {
|
|
502
|
-
const waitS = Math.max(0, Math.min(
|
|
788
|
+
const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
503
789
|
const askedAt = Date.now();
|
|
504
790
|
try {
|
|
505
|
-
const res = await
|
|
791
|
+
const res = await fetchT(`${BASE}/api/v1/messages${waitS > 0 ? `?wait=${waitS}` : ''}`, { headers }, (waitS + 10) * 1000);
|
|
506
792
|
checkManifestNews(res);
|
|
507
793
|
if (res.status === 200) {
|
|
794
|
+
health.ok();
|
|
508
795
|
const data = await res.json().catch(() => ({}));
|
|
509
796
|
const msgs = data.messages || [];
|
|
510
|
-
if (msgs.length)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
try {
|
|
514
|
-
const ack = await fetch(`${BASE}/api/v1/messages/ack`, {
|
|
515
|
-
method: 'POST', headers, body: JSON.stringify({ up_to: upTo }),
|
|
516
|
-
});
|
|
517
|
-
if (ack.status >= 200 && ack.status < 300) {
|
|
518
|
-
console.error(`pidge: ${msgs.length} message(s) from the human — acked (answer via notify; reuse thread_id when present)`);
|
|
519
|
-
} else {
|
|
520
|
-
console.error(`pidge: WARNING — ack failed (${ack.status}); these messages will be re-served next listen`);
|
|
521
|
-
}
|
|
522
|
-
} catch (e) {
|
|
523
|
-
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
524
|
-
}
|
|
525
|
-
process.exit(0);
|
|
526
|
-
}
|
|
797
|
+
if (msgs.length) await printAndAck(msgs);
|
|
798
|
+
} else if (res.status >= 500) {
|
|
799
|
+
health.fail(`listen error ${res.status}`); // aggregated (#119) — no line per attempt
|
|
527
800
|
} else {
|
|
801
|
+
health.ok();
|
|
528
802
|
console.error(`pidge: listen error ${res.status}`);
|
|
529
803
|
}
|
|
530
804
|
} catch (e) {
|
|
531
|
-
|
|
805
|
+
health.fail(`network: ${e.message}`);
|
|
532
806
|
}
|
|
533
807
|
if (Date.now() >= deadline) {
|
|
534
|
-
|
|
535
|
-
|
|
808
|
+
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
809
|
+
}
|
|
810
|
+
const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
|
|
811
|
+
if (Date.now() - askedAt < 2000) {
|
|
812
|
+
await sleep(Math.min(pace, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))) * 1000);
|
|
536
813
|
}
|
|
537
|
-
if (Date.now() - askedAt < 2000) await sleep(num(v.interval, 5) * 1000);
|
|
538
814
|
}
|
|
539
815
|
break;
|
|
540
816
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pidge-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Send rich, actionable iPhone notifications to a human and block until they answer. Built for AI agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pidge",
|
|
@@ -20,9 +20,15 @@
|
|
|
20
20
|
"README.md",
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/cli.test.js"
|
|
25
|
+
},
|
|
23
26
|
"engines": {
|
|
24
27
|
"node": ">=18"
|
|
25
28
|
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
},
|
|
26
32
|
"repository": {
|
|
27
33
|
"type": "git",
|
|
28
34
|
"url": "git+https://github.com/thiagoc7/pidge-cli.git"
|