pidge-cli 0.5.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.
Files changed (3) hide show
  1. package/README.md +30 -3
  2. package/bin/pidge.js +296 -39
  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,17 @@ 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).
128
+
115
129
  OPTIONS (notify / ask)
116
130
  --title TEXT (required) the headline
117
131
  --body TEXT message shown on the banner
@@ -145,8 +159,8 @@ OPTIONS (notify / ask)
145
159
  --param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
146
160
  fields work without a CLI update; the manifest is the contract
147
161
  --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
162
+ --interval SECONDS FALLBACK poll cadence (default 30) — normally unused: WS or
163
+ the server-held long-poll (?wait=25) make answers ~instant
150
164
 
151
165
  ENV
152
166
  PIDGE_URL your Pidge server (default http://localhost:3000; HERALD_URL honored)
@@ -157,7 +171,9 @@ ENV
157
171
  OUTPUT
158
172
  stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
159
173
  human notices go to stderr. Exit: 0 answered · 3 timed out (no answer yet,
160
- not a failure) · 2 error · 1 usage.
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.
161
177
 
162
178
  Responses are one-and-done EXCEPT snooze/reschedule (they re-fire); ask/wait keep
163
179
  polling through a snooze and print snooze_until. Follow-up = a NEW notification.
@@ -187,14 +203,140 @@ const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application
187
203
  // The server advertises its manifest version on every response. When it's newer
188
204
  // than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
189
205
  // the manifest (whats_new) and learns the new capabilities without polling.
190
- const KNOWN_MANIFEST_VERSION = 11;
206
+ const KNOWN_MANIFEST_VERSION = 16;
191
207
  let newsWarned = false;
192
208
  function checkManifestNews(res) {
193
209
  const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
194
210
  if (v > KNOWN_MANIFEST_VERSION && !newsWarned) {
195
211
  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`);
212
+ // #119: a pinned npx ref never updates itselfgive 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)`);
214
+ }
215
+ }
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);
197
338
  }
339
+ return 'deadline';
198
340
  }
199
341
 
200
342
  // Map CLI flags → the /notify JSON body, including only what was provided.
@@ -351,13 +493,16 @@ async function doWait(cid, { timeout, interval }) {
351
493
  const deadline = Date.now() + timeout * 1000;
352
494
  let firedNotice = false;
353
495
  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}`;
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}` : ''}`;
356
500
  const askedAt = Date.now();
357
501
  try {
358
502
  const res = await fetch(url, { headers });
359
503
  checkManifestNews(res);
360
504
  if (res.status === 200) {
505
+ health.ok();
361
506
  const data = await res.json().catch(() => ({}));
362
507
  if (data.responded) {
363
508
  const chosen = data.chosen_action || {};
@@ -374,23 +519,81 @@ async function doWait(cid, { timeout, interval }) {
374
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');
375
520
  }
376
521
  } else if (res.status === 404) {
522
+ health.ok(); // the server ANSWERED — the channel is fine, the cid isn't known (yet)
377
523
  console.error(`pidge: no notification for correlation_id=${cid}`);
378
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
379
527
  } else {
528
+ health.ok();
380
529
  console.error(`pidge: poll error ${res.status}`);
381
530
  }
382
531
  } catch (e) {
383
- console.error(`pidge: poll error (network): ${e.message}`);
532
+ health.fail(`network: ${e.message}`);
384
533
  }
385
534
 
386
535
  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);
536
+ health.exitTimeout(`timed out after ${timeout}s waiting on ${cid}`);
389
537
  }
390
538
  // 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);
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 });
393
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 });
394
597
  }
395
598
 
396
599
  const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
@@ -421,13 +624,13 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
421
624
  const { ok, info } = await doNotify();
422
625
  if (!ok) process.exit(2);
423
626
  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) });
627
+ await waitForAnswer(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
425
628
  break;
426
629
  }
427
630
  case 'wait': {
428
631
  const cid = parsed.positionals[1];
429
632
  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) });
633
+ await waitForAnswer(cid, { timeout: num(v.timeout, 300), interval: num(v.interval, 30) });
431
634
  break;
432
635
  }
433
636
  case 'cancel': {
@@ -493,48 +696,102 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
493
696
  case 'listen': {
494
697
  // #48: block until the human messages this channel (the app's composer),
495
698
  // 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.
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).
497
701
  // At-least-once: the ack happens AFTER the print — a crash re-serves them;
498
702
  // dedupe by id if you've seen one before.
499
703
  const timeout = num(v.timeout, 600);
500
- const deadline = Date.now() + timeout * 1000;
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
+
501
768
  for (;;) {
502
- const waitS = Math.max(0, Math.min(55, Math.ceil((deadline - Date.now()) / 1000)));
769
+ const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
503
770
  const askedAt = Date.now();
504
771
  try {
505
- const res = await fetch(`${BASE}/api/v1/messages?wait=${waitS}`, { headers });
772
+ const res = await fetch(`${BASE}/api/v1/messages${waitS > 0 ? `?wait=${waitS}` : ''}`, { headers });
506
773
  checkManifestNews(res);
507
774
  if (res.status === 200) {
775
+ health.ok();
508
776
  const data = await res.json().catch(() => ({}));
509
777
  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
- }
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
527
781
  } else {
782
+ health.ok();
528
783
  console.error(`pidge: listen error ${res.status}`);
529
784
  }
530
785
  } catch (e) {
531
- console.error(`pidge: listen error (network): ${e.message}`);
786
+ health.fail(`network: ${e.message}`);
532
787
  }
533
788
  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);
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);
536
794
  }
537
- if (Date.now() - askedAt < 2000) await sleep(num(v.interval, 5) * 1000);
538
795
  }
539
796
  break;
540
797
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.5.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
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"