pidge-cli 0.6.0 → 0.7.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 +17 -0
  2. package/bin/pidge.js +306 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,6 +11,19 @@ then gets the answer as JSON — no webhook, no polling loop to write.
11
11
  > current spec (fields, profiles, guarantees). This CLI is a thin pipe over it — any
12
12
  > new server field works without a CLI update via `--param key=value`.
13
13
 
14
+ ## Setup in one command (v0.7.0 — the claim flow)
15
+
16
+ ```bash
17
+ # The human copies a setup prompt from the Pidge app (Canais → the channel) —
18
+ # it carries a SINGLE-USE claim code (15 min TTL), never the key:
19
+ npx pidge-cli setup --claim <code> --url https://pidge.sh
20
+ # → exchanges the code for the real key, writes ~/.config/pidge/env (chmod 600)
21
+ # and runs `pidge doctor`. The secret never appears on screen or in any chat.
22
+
23
+ npx pidge-cli doctor # validate anytime: env source, server, key, "canal X · N devices"
24
+ npx pidge-cli whoami # which channel does this key speak for (JSON)
25
+ ```
26
+
14
27
  ## Use it (no install — via npx)
15
28
 
16
29
  ```bash
@@ -55,6 +68,10 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
55
68
  | `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
56
69
  | `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
57
70
  | `listen` | Block until the human **messages you** from the app; prints the messages, ACKs them, exits `0`. One-shot — loop it. |
71
+ | `setup --claim <code>` | One-shot onboarding (v0.7.0): exchange the single-use code for the key, store it in `~/.config/pidge/env` (600), run doctor. |
72
+ | `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, channel + device count. Exit 0/2. |
73
+ | `whoami` | Which channel does this key speak for (JSON). |
74
+ | `skill install` | Write `.claude/skills/pidge/SKILL.md` generated from the live manifest — persistent Pidge knowledge for Claude Code agents; re-run to update. |
58
75
 
59
76
  ## Realtime (v0.6.0)
60
77
 
package/bin/pidge.js CHANGED
@@ -90,6 +90,7 @@ const OPTIONS = {
90
90
  'reply-to': { type: 'string' },
91
91
  'correlation-id': { type: 'string' },
92
92
  thread: { type: 'string' }, // conversation handle (#49) — same id ⇒ one strand on the phone
93
+ after: { type: 'string' }, // decision queue (#157): held until this cid resolves
93
94
  'collapse-key': { type: 'string' },
94
95
  param: { type: 'string', multiple: true }, // key=value escape hatch → raw /notify field
95
96
  timeout: { type: 'string' },
@@ -102,17 +103,35 @@ const OPTIONS = {
102
103
  // realtime (#118): WS by default when the runtime has a WebSocket (Node ≥22)
103
104
  realtime: { type: 'boolean' }, // force WS (warn+fallback if unavailable)
104
105
  'no-realtime': { type: 'boolean' }, // polling only
106
+ // onboarding v2 (#110)
107
+ claim: { type: 'string' }, // setup --claim <single-use code>
108
+ // #157 P2: listen keeps going after a batch (supervisor loop, one process)
109
+ follow: { type: 'boolean' },
105
110
  };
106
111
 
107
112
  const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
108
113
 
109
114
  USAGE
115
+ pidge setup --claim CODE [--url BASE] one-shot onboarding (#110): exchange the single-use
116
+ code for the channel key, store it in
117
+ ~/.config/pidge/env (chmod 600), run doctor
118
+ pidge doctor validate the setup WITHOUT exposing secrets:
119
+ env source, server, key, "canal X · N devices"
120
+ pidge whoami which channel does this key speak for (JSON)
110
121
  pidge ask [options] send AND wait for the answer (prints chosen_action JSON)
111
122
  pidge notify [options] send only (prints the 201 JSON)
112
123
  pidge wait <correlation_id> [options] block on an already-sent notification
113
124
  pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
114
125
  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)
126
+ pidge listen [--timeout N] [--all] [--follow]
127
+ block until the human MESSAGES you from the app, print + ack + exit (#48)
128
+ --follow = print+ack and KEEP listening until --timeout
129
+ (exit 0 if any batch landed; one-shot stays the default)
130
+ --all (#131) = the SINGLE EAR: also hear notification ANSWERS
131
+ (kind notification_reply + self-contained ref) — nothing the human
132
+ says can be missed by a looped listen --all
133
+ pidge skill install write .claude/skills/pidge/SKILL.md generated from the
134
+ live manifest (persistent Pidge knowledge for Claude Code)
116
135
  pidge --help
117
136
 
118
137
  REALTIME (#118)
@@ -124,7 +143,9 @@ REALTIME (#118)
124
143
  --realtime force WS (warns + falls back to polling if unavailable)
125
144
  --no-realtime polling only (the ?wait= long-poll, capped 25 s server-side)
126
145
  Degrade ladder, narrated on stderr: WS → ?wait= long-poll → plain GETs every
127
- ~45 s after 3 consecutive failures on held polls (#119).
146
+ ~45 s after 3 consecutive failures on held polls (#119). Degrade is STICKY for
147
+ the session (we can't probe held-poll health without re-paying the failure) —
148
+ re-invoke the command to retry the fast path.
128
149
 
129
150
  OPTIONS (notify / ask)
130
151
  --title TEXT (required) the headline
@@ -155,6 +176,9 @@ OPTIONS (notify / ask)
155
176
  --correlation-id ID idempotency + routing key (auto-generated if omitted)
156
177
  --thread ID conversation handle (#49): sends sharing it group as ONE
157
178
  strand on the phone — use it for follow-ups
179
+ --after CID decision queue (#157): HELD until that notification is
180
+ answered — chain N decisions so the human sees one at a
181
+ time ("Decisão 2/3" --after <cid-da-1>); snooze doesn't advance
158
182
  --collapse-key KEY replace/update a prior notification
159
183
  --param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
160
184
  fields work without a CLI update; the manifest is the contract
@@ -195,15 +219,29 @@ const command = parsed.positionals[0];
195
219
  // `pidge --help` / `-h` / `help` → full help on stdout, exit 0. No command → stderr, exit 1.
196
220
  if (v.help || command === 'help') { console.log(USAGE); process.exit(0); }
197
221
  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)');
222
+ // `setup` is the command that CREATES the token config it must run without one.
223
+ if (!TOKEN && command !== 'setup')
224
+ die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.config/pidge/env) — or onboard with: pidge setup --claim <code> (ask your human for the code: Pidge app → Canais → o canal → copiar prompt de setup)');
199
225
 
200
226
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
201
227
  const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
202
228
 
229
+ // fetch with a hard timeout (#119 review): a wedged edge proxy can stall even a
230
+ // short POST forever, and a hung ack on the realtime listen path would pin the
231
+ // process past its deadline — worse than going deaf. NOTHING in this CLI should
232
+ // await a fetch that can't time out. A held long-poll passes its own (larger)
233
+ // timeout; everything else uses the 30 s default.
234
+ function fetchT(url, opts = {}, timeoutMs = 30000) {
235
+ const ms = parseInt(process.env.PIDGE_FETCH_TIMEOUT || '', 10) || timeoutMs; // test/ops hook
236
+ const ctl = new AbortController();
237
+ const t = setTimeout(() => ctl.abort(new Error(`timeout after ${ms}ms`)), ms);
238
+ return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(t));
239
+ }
240
+
203
241
  // The server advertises its manifest version on every response. When it's newer
204
242
  // than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
205
243
  // the manifest (whats_new) and learns the new capabilities without polling.
206
- const KNOWN_MANIFEST_VERSION = 16;
244
+ const KNOWN_MANIFEST_VERSION = 24;
207
245
  let newsWarned = false;
208
246
  function checkManifestNews(res) {
209
247
  const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
@@ -331,9 +369,10 @@ async function cableSession({ channel, deadline, onUp, onFrame }) {
331
369
  if (outcome === 'deadline') return 'deadline';
332
370
  if (!outcome.startsWith('down: ')) return outcome; // caller-driven finish (e.g. 'answered')
333
371
  wsFails++;
334
- if (wsFails >= 4) return 'ws-unavailable';
372
+ const MAX_WS_FAILS = 4; // then fall back to polling for the rest of the session
373
+ if (wsFails >= MAX_WS_FAILS) return 'ws-unavailable';
335
374
  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)`);
375
+ console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} — reconnecting in ${Math.round(backoff / 1000)}s (attempt ${wsFails}/${MAX_WS_FAILS})`);
337
376
  await sleep(backoff);
338
377
  }
339
378
  return 'deadline';
@@ -357,6 +396,7 @@ function buildBody() {
357
396
  if (v['reply-to'] !== undefined) body.reply_to = v['reply-to'];
358
397
  if (v['correlation-id'] !== undefined) body.correlation_id = v['correlation-id'];
359
398
  if (v.thread !== undefined) body.thread_id = v.thread;
399
+ if (v.after !== undefined) body.after = v.after;
360
400
  if (v['collapse-key'] !== undefined) body.collapse_key = v['collapse-key'];
361
401
  if (v.actions !== undefined) body.actions = v.actions.split(',').filter(Boolean);
362
402
 
@@ -364,6 +404,11 @@ function buildBody() {
364
404
  if (customs.length) {
365
405
  body.custom_actions = customs.map((spec) => {
366
406
  const [id, label, ...flags] = spec.split(':');
407
+ // #157 P2: fail fast locally — the rule is stable and the server 422
408
+ // costs a round-trip an agent then has to interpret.
409
+ if (!/^[a-z0-9_]{1,40}$/.test(id || '')) {
410
+ die(`pidge: --custom-action id ${JSON.stringify(id)} is invalid — lowercase letters, digits and underscore only (^[a-z0-9_]{1,40}$)`, 1);
411
+ }
367
412
  const ca = { id, label };
368
413
  if (flags.includes('destructive')) ca.style = 'destructive';
369
414
  if (flags.includes('confirm')) ca.confirm = true;
@@ -499,7 +544,7 @@ async function doWait(cid, { timeout, interval }) {
499
544
  const url = `${BASE}/api/v1/notifications/${encodeURIComponent(cid)}${waitS > 0 ? `?wait=${waitS}` : ''}`;
500
545
  const askedAt = Date.now();
501
546
  try {
502
- const res = await fetch(url, { headers });
547
+ const res = await fetchT(url, { headers }, (waitS + 10) * 1000);
503
548
  checkManifestNews(res);
504
549
  if (res.status === 200) {
505
550
  health.ok();
@@ -552,7 +597,7 @@ async function realtimeWait(cid, { timeout, interval }) {
552
597
  const deadline = Date.now() + timeout * 1000;
553
598
  const answered = async () => {
554
599
  try {
555
- const res = await fetch(`${BASE}/api/v1/notifications/${encodeURIComponent(cid)}`, { headers });
600
+ const res = await fetchT(`${BASE}/api/v1/notifications/${encodeURIComponent(cid)}`, { headers });
556
601
  if (res.status !== 200) return false;
557
602
  const data = await res.json().catch(() => ({}));
558
603
  return !!(data.responded && data.chosen_action && data.chosen_action.kind !== 'snoozed');
@@ -598,8 +643,189 @@ async function waitForAnswer(cid, { timeout, interval }) {
598
643
 
599
644
  const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
600
645
 
646
+ // ---------------------------------------------------------------------------
647
+ // Onboarding v2 (#110): setup --claim / doctor / whoami / skill install.
648
+ // ---------------------------------------------------------------------------
649
+
650
+ const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge');
651
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'env');
652
+
653
+ // Where the token came from — doctor narrates it, setup respects precedence.
654
+ function tokenSource() {
655
+ if (process.env.PIDGE_TOKEN || process.env.HERALD_TOKEN) return 'env var';
656
+ if (FILE_ENV.PIDGE_TOKEN) return CONFIG_FILE;
657
+ return null;
658
+ }
659
+
660
+ // GET /whoami — which channel does this key speak for. Returns {res, data}.
661
+ async function fetchWhoami(base = BASE, token = TOKEN) {
662
+ const res = await fetchT(`${base}/api/v1/whoami`, {
663
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
664
+ });
665
+ let data = {};
666
+ try { data = await res.json(); } catch { /* leave {} */ }
667
+ return { res, data };
668
+ }
669
+
670
+ // doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
671
+ // a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
672
+ async function runDoctor(base = BASE, token = TOKEN) {
673
+ const source = token === TOKEN ? tokenSource() : CONFIG_FILE; // post-setup call passes the fresh token
674
+ if (!token) {
675
+ console.error('pidge doctor: NO TOKEN — set PIDGE_TOKEN, or onboard with `pidge setup --claim <code>` (the human copies the code from the Pidge app)');
676
+ process.exit(2);
677
+ }
678
+ console.error(`pidge doctor: token found (${source || 'passed in'}) — never displayed`);
679
+ console.error(`pidge doctor: server ${base}`);
680
+ let out;
681
+ try {
682
+ out = await fetchWhoami(base, token);
683
+ } catch (e) {
684
+ console.error(`pidge doctor: server UNREACHABLE — ${e.message} (check the URL; is it ${base}?)`);
685
+ process.exit(2);
686
+ }
687
+ const { res, data } = out;
688
+ checkManifestNews(res);
689
+ if (res.status === 401) {
690
+ console.error('pidge doctor: server reachable but the key is INVALID/REVOKED — re-onboard: ask your human for a fresh claim code (Pidge app → Canais → o canal → copiar prompt de setup)');
691
+ process.exit(2);
692
+ }
693
+ if (res.status === 404) {
694
+ // pre-#110 server: no /whoami yet — the key may still be fine; prove it on the manifest.
695
+ const m = await fetchT(`${base}/api/v1/manifest`, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
696
+ if (m && m.status === 200) {
697
+ console.error('pidge doctor: key VALID (server predates /whoami — channel/device detail unavailable; update the server to see it)');
698
+ console.log(JSON.stringify({ ok: true, base_url: base, channel: null, devices: null }));
699
+ process.exit(0);
700
+ }
701
+ console.error(`pidge doctor: unexpected ${m ? m.status : 'network error'} on the manifest — server looks broken`);
702
+ process.exit(2);
703
+ }
704
+ if (res.status !== 200) {
705
+ console.error(`pidge doctor: unexpected ${res.status} from /whoami — ${JSON.stringify(data)}`);
706
+ process.exit(2);
707
+ }
708
+ const devices = data.devices ?? 0;
709
+ console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
710
+ if (devices === 0)
711
+ console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
712
+ console.error('pidge doctor: all good — try: pidge ask --template decision --title "Pidge funcionando?"');
713
+ console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version }));
714
+ process.exit(0);
715
+ }
716
+
717
+ // setup --claim: exchange the single-use code for the key, store it ourselves
718
+ // (the secret never appears on screen or in the chat the prompt was pasted in),
719
+ // then prove the loop with doctor.
720
+ async function runSetup() {
721
+ const code = v.claim;
722
+ if (!code) die('pidge: usage: pidge setup --claim <code> [--url <base>] (the human copies the code from the Pidge app)', 1);
723
+ const base = (v.url || process.env.PIDGE_URL || FILE_ENV.PIDGE_URL || 'https://pidge.sh').replace(/\/+$/, '');
724
+ let res, data = {};
725
+ try {
726
+ res = await fetchT(`${base}/api/v1/claim`, {
727
+ method: 'POST', headers: { 'content-type': 'application/json' },
728
+ body: JSON.stringify({ code }),
729
+ });
730
+ try { data = await res.json(); } catch { /* leave {} */ }
731
+ } catch (e) {
732
+ die(`pidge: claim failed (network): ${e.message} — is the server URL right? (${base})`, 2);
733
+ }
734
+ if (res.status === 404)
735
+ die('pidge: claim code unknown, EXPIRED (15 min TTL) or already used — ask your human for a fresh one (Pidge app → Canais → o canal → copiar prompt de setup)', 2);
736
+ if (!(res.status >= 200 && res.status < 300) || !data.key)
737
+ die(`pidge: claim failed (${res.status}): ${JSON.stringify(data)}`, 2);
738
+
739
+ const finalBase = (data.base_url || base).replace(/\/+$/, '');
740
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
741
+ fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
742
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
743
+ console.error(`pidge: configured for canal "${data.channel && data.channel.name}" — key stored in ${CONFIG_FILE} (chmod 600, never displayed)`);
744
+ await runDoctor(finalBase, data.key);
745
+ }
746
+
747
+ // skill install (#110e): persistent Pidge knowledge for Claude Code agents —
748
+ // a skill generated FROM the live manifest (so it can't drift), versioned with
749
+ // manifest_version (re-run to update; whats_new is the changelog).
750
+ async function runSkillInstall() {
751
+ let res, m;
752
+ try {
753
+ res = await fetchT(`${BASE}/api/v1/manifest`, { headers });
754
+ m = await res.json();
755
+ } catch (e) {
756
+ die(`pidge: could not read the manifest: ${e.message}`, 2);
757
+ }
758
+ if (res.status !== 200) die(`pidge: manifest read failed (${res.status})`, 2);
759
+ const table = (m.templates && m.templates.decision_table) || [];
760
+ const profileTable = (m.profiles && m.profiles.decision_table) || [];
761
+ const notes = m.notes || [];
762
+ const exits = (m.cli && m.cli.output) || '';
763
+ const skill = `---
764
+ name: pidge
765
+ description: Send rich, actionable iPhone notifications to your human and get their decision back (Pidge). Use when finishing long tasks (report), needing a decision/approval, sending FYIs with substance, or anything time-anchored. Also covers reading the human's replies/messages back.
766
+ ---
767
+
768
+ # Pidge — notify your human, get answers back
769
+
770
+ Generated from manifest v${m.manifest_version} of ${BASE} — re-run \`pidge skill install\` to update (any API response header X-Pidge-Manifest-Version > ${m.manifest_version} means there's news).
771
+
772
+ All commands: \`npx pidge-cli …\` (Node ≥18; reads ~/.config/pidge/env — no token in context). Not set up? \`pidge doctor\` tells you; onboard with \`pidge setup --claim <code>\` (the human copies the code from the Pidge app).
773
+
774
+ ## Pick the right send (decision table)
775
+
776
+ ${table.map((r) => `- ${r}`).join('\n')}
777
+
778
+ ## How it intrudes (profiles — the human owns them)
779
+
780
+ ${profileTable.map((r) => `- ${r}`).join('\n')}
781
+
782
+ ## The contract
783
+
784
+ ${notes.map((n) => `- ${n}`).join('\n')}
785
+
786
+ ## Getting answers
787
+
788
+ - \`pidge ask …\` blocks and prints chosen_action JSON; \`pidge wait <cid>\` blocks on an existing send.
789
+ - \`pidge listen\` blocks until the human MESSAGES you from the app (composer) — run it when idle.
790
+ - ${exits}
791
+
792
+ ## Full spec
793
+
794
+ \`curl $PIDGE_URL/api/v1/manifest -H "Authorization: Bearer $PIDGE_TOKEN"\` — the always-current contract (fields, templates, custom actions, media, threads, realtime).
795
+ `;
796
+ const dir = path.join(process.cwd(), '.claude', 'skills', 'pidge');
797
+ fs.mkdirSync(dir, { recursive: true });
798
+ const file = path.join(dir, 'SKILL.md');
799
+ fs.writeFileSync(file, skill);
800
+ console.error(`pidge: skill written to ${file} (manifest v${m.manifest_version}) — your future sessions in this project know Pidge now`);
801
+ console.log(JSON.stringify({ ok: true, file, manifest_version: m.manifest_version }));
802
+ process.exit(0);
803
+ }
804
+
601
805
  (async () => {
602
806
  switch (command) {
807
+ case 'setup': {
808
+ await runSetup(); // exits via runDoctor
809
+ break;
810
+ }
811
+ case 'doctor': {
812
+ await runDoctor();
813
+ break;
814
+ }
815
+ case 'whoami': {
816
+ const { res, data } = await fetchWhoami().catch((e) => { die(`pidge: whoami failed (network): ${e.message}`, 2); });
817
+ checkManifestNews(res);
818
+ if (res.status !== 200) die(`pidge: whoami failed (${res.status}): ${JSON.stringify(data)}`, 2);
819
+ console.log(JSON.stringify(data, null, 2));
820
+ console.error(`pidge: you are canal "${data.channel && data.channel.name}" · ${data.devices ?? '?'} device(s)`);
821
+ process.exit(0);
822
+ break;
823
+ }
824
+ case 'skill': {
825
+ if (parsed.positionals[1] !== 'install') die('pidge: usage: pidge skill install', 1);
826
+ await runSkillInstall();
827
+ break;
828
+ }
603
829
  case 'notify': {
604
830
  const { ok, info, raw } = await doNotify();
605
831
  console.log(raw);
@@ -624,7 +850,20 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
624
850
  const { ok, info } = await doNotify();
625
851
  if (!ok) process.exit(2);
626
852
  console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
627
- await waitForAnswer(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
853
+ // #132: no --timeout obey the template's suggestion from the 201 echo
854
+ // (human decisions take 30-40 min in the wild; a 600 s default misread
855
+ // them as silence). Explicit --timeout always wins.
856
+ let timeout = num(v.timeout, NaN);
857
+ if (!Number.isFinite(timeout)) {
858
+ if (info.suggested_ask_timeout) {
859
+ timeout = info.suggested_ask_timeout;
860
+ const mins = Math.round(timeout / 60);
861
+ console.error(`pidge: timeout ${mins} min — suggested by template ${info.template || v.template} (override with --timeout)`);
862
+ } else {
863
+ timeout = 600;
864
+ }
865
+ }
866
+ await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
628
867
  break;
629
868
  }
630
869
  case 'wait': {
@@ -700,15 +939,42 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
700
939
  // the whole session never had a healthy round-trip (#119).
701
940
  // At-least-once: the ack happens AFTER the print — a crash re-serves them;
702
941
  // dedupe by id if you've seen one before.
942
+ // --all (#131): the SINGLE EAR — the queue also serves notification
943
+ // ANSWERS (kind notification_reply, with a self-contained ref), so a
944
+ // fire-and-forget notify can't lose its reply. Without --all the original
945
+ // composer-only contract stands (no double-consumption for ask/wait users).
703
946
  const timeout = num(v.timeout, 600);
704
947
  let deadline = Date.now() + timeout * 1000;
948
+ const queueQs = v.all ? '?all=true' : '';
949
+ // #157 P2 --follow: print+ack a batch and KEEP listening until the
950
+ // timeout — the supervisor loop without re-spawning a process per batch.
951
+ let gotAny = false;
952
+ const followEnd = () => {
953
+ if (v.follow && gotAny) {
954
+ console.error(`pidge: --follow window ended after ${timeout}s — batches were delivered`);
955
+ process.exit(0);
956
+ }
957
+ return false;
958
+ };
705
959
 
706
- // Print + ack + exit 0 — shared by the WS and polling paths.
960
+ // Print + ack (+ exit 0 unless --follow) — shared by the WS and polling paths.
707
961
  const printAndAck = async (msgs) => {
708
962
  console.log(JSON.stringify(msgs, null, 2));
963
+ // #131: narrate answers so the agent knows WHICH notification spoke back.
964
+ for (const m of msgs) {
965
+ if (m.kind === 'notification_reply' && m.ref) {
966
+ const said = m.text ? `: ${String(m.text).slice(0, 120)}` : '';
967
+ console.error(`pidge: reply to your notification ${m.ref.correlation_id} ("${m.ref.title}") — ${m.action_id || m.ref.event_kind}${said}`);
968
+ if (m.truncated) console.error('pidge: that reply hit the server cap (truncated:true) — tell your human the tail was lost');
969
+ }
970
+ }
709
971
  const upTo = Math.max(...msgs.map((m) => m.id));
710
972
  try {
711
- const ack = await fetch(`${BASE}/api/v1/messages/ack`, {
973
+ // fetchT, not fetch: a wedged proxy stalling this ack would otherwise
974
+ // pin the process forever (the WS drain path awaits printAndAck's exit
975
+ // with no deadline) — messages are already printed, so a timeout here
976
+ // just re-serves them next listen (at-least-once).
977
+ const ack = await fetchT(`${BASE}/api/v1/messages/ack`, {
712
978
  method: 'POST', headers, body: JSON.stringify({ up_to: upTo }),
713
979
  });
714
980
  if (ack.status >= 200 && ack.status < 300) {
@@ -719,7 +985,9 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
719
985
  } catch (e) {
720
986
  console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
721
987
  }
722
- process.exit(0);
988
+ gotAny = true;
989
+ if (!v.follow) process.exit(0);
990
+ console.error('pidge: --follow — still listening');
723
991
  };
724
992
 
725
993
  // Realtime path (#118): hold ConversationChannel — the human sees "ouvindo
@@ -731,12 +999,15 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
731
999
  if (draining) return;
732
1000
  draining = true;
733
1001
  try {
734
- const res = await fetch(`${BASE}/api/v1/messages`, { headers });
1002
+ const res = await fetchT(`${BASE}/api/v1/messages${queueQs}`, { headers });
735
1003
  checkManifestNews(res);
736
1004
  if (res.status === 200) {
737
1005
  health.ok();
738
1006
  const msgs = (await res.json().catch(() => ({}))).messages || [];
739
- if (msgs.length) { finish('got-messages'); await printAndAck(msgs); }
1007
+ if (msgs.length) {
1008
+ if (!v.follow) finish('got-messages');
1009
+ await printAndAck(msgs);
1010
+ }
740
1011
  } else if (res.status >= 500) {
741
1012
  health.fail(`backlog read ${res.status}`);
742
1013
  }
@@ -747,16 +1018,29 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
747
1018
  }
748
1019
  };
749
1020
  let announced = false;
750
- const outcome = await cableSession({
1021
+ const sessions = [cableSession({
751
1022
  channel: 'ConversationChannel',
752
1023
  deadline,
753
1024
  onUp: (finish) => {
754
- if (!announced) { announced = true; console.error('pidge: listening over the realtime socket (the human sees "ouvindo agora")'); }
1025
+ if (!announced) { announced = true; console.error(`pidge: listening over the realtime socket${v.all ? ' — single ear: composer + notification answers (#131)' : ''} (the human sees "ouvindo agora")`); }
755
1026
  drain(finish);
756
1027
  },
757
1028
  onFrame: (m, finish) => { if (m.type === 'message') drain(finish); },
758
- });
1029
+ })];
1030
+ // --all (#131): answers broadcast on InboxChannel, not Conversation — a
1031
+ // second subscription wakes the same HTTP drain (the queue is the ledger;
1032
+ // the loser session leaks until exit, harmless in a one-shot process).
1033
+ if (v.all) {
1034
+ sessions.push(cableSession({
1035
+ channel: 'InboxChannel',
1036
+ deadline,
1037
+ onUp: (finish) => drain(finish),
1038
+ onFrame: (m, finish) => { if (m.type === 'event' && m.responded) drain(finish); },
1039
+ }));
1040
+ }
1041
+ const outcome = await Promise.race(sessions);
759
1042
  if (outcome === 'deadline') {
1043
+ followEnd();
760
1044
  health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
761
1045
  }
762
1046
  if (outcome === 'got-messages') {
@@ -769,7 +1053,10 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
769
1053
  const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
770
1054
  const askedAt = Date.now();
771
1055
  try {
772
- const res = await fetch(`${BASE}/api/v1/messages${waitS > 0 ? `?wait=${waitS}` : ''}`, { headers });
1056
+ const qs = new URLSearchParams();
1057
+ if (waitS > 0) qs.set('wait', String(waitS));
1058
+ if (v.all) qs.set('all', 'true');
1059
+ const res = await fetchT(`${BASE}/api/v1/messages${qs.size ? `?${qs}` : ''}`, { headers }, (waitS + 10) * 1000);
773
1060
  checkManifestNews(res);
774
1061
  if (res.status === 200) {
775
1062
  health.ok();
@@ -786,6 +1073,7 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
786
1073
  health.fail(`network: ${e.message}`);
787
1074
  }
788
1075
  if (Date.now() >= deadline) {
1076
+ followEnd();
789
1077
  health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
790
1078
  }
791
1079
  const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",