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.
Files changed (3) hide show
  1. package/README.md +30 -3
  2. package/bin/pidge.js +375 -16
  3. 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: 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
@@ -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
- if (!TOKEN) die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.config/pidge/env)');
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: the
138
- 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
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) · 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.
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 = 7;
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
- 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)`);
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
- const waitS = Math.max(0, Math.min(55, Math.ceil((deadline - Date.now()) / 1000)));
339
- 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}` : ''}`;
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
- console.error('pidge: the escalation alarm FIRED and there is still no answer — the human may have stopped the alarm on-device (that is not reported back); keep waiting or back off');
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
- console.error(`pidge: poll error (network): ${e.message}`);
532
+ health.fail(`network: ${e.message}`);
366
533
  }
367
534
 
368
535
  if (Date.now() >= deadline) {
369
- console.error(`pidge: timed out after ${timeout}s waiting on ${cid} (= 'no answer yet', not a failure)`);
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` (or a network error) returned fast: pace with --interval.
374
- 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 });
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 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) });
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 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) });
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.4.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": ["pidge", "notifications", "agent", "cli", "push", "ai"],
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"