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.
Files changed (3) hide show
  1. package/README.md +30 -3
  2. package/bin/pidge.js +316 -40
  3. 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: the
89
- server long-polls each GET (?wait=55), answers are ~instant
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) · `2` error · `1` usage.
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: the
149
- server long-polls each GET (?wait=55), answers are ~instant
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) · 2 error · 1 usage.
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 = 11;
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
- 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 consider updating the CLI`);
226
+ // #119: a pinned npx ref never updates itselfgive 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
- const waitS = Math.max(0, Math.min(55, Math.ceil((deadline - Date.now()) / 1000)));
355
- const url = `${BASE}/api/v1/notifications/${encodeURIComponent(cid)}?wait=${waitS}`;
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 fetch(url, { headers });
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
- console.error(`pidge: poll error (network): ${e.message}`);
547
+ health.fail(`network: ${e.message}`);
384
548
  }
385
549
 
386
550
  if (Date.now() >= deadline) {
387
- console.error(`pidge: timed out after ${timeout}s waiting on ${cid} (= 'no answer yet', not a failure)`);
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` (or a network error) returned fast: pace with --interval.
392
- if (Date.now() - askedAt < 2000) await sleep(interval * 1000);
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 doWait(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
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 doWait(cid, { timeout: num(v.timeout, 300), interval: num(v.interval, 30) });
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
- const deadline = Date.now() + timeout * 1000;
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(55, Math.ceil((deadline - Date.now()) / 1000)));
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 fetch(`${BASE}/api/v1/messages?wait=${waitS}`, { headers });
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
- console.log(JSON.stringify(msgs, null, 2));
512
- const upTo = Math.max(...msgs.map((m) => m.id));
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
- console.error(`pidge: listen error (network): ${e.message}`);
805
+ health.fail(`network: ${e.message}`);
532
806
  }
533
807
  if (Date.now() >= deadline) {
534
- console.error(`pidge: timed out after ${timeout}s — no message from the human (not a failure)`);
535
- process.exit(3);
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.5.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"