pidge-cli 0.6.1 → 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.
- package/README.md +17 -0
- package/bin/pidge.js +281 -12
- 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]
|
|
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)
|
|
@@ -157,6 +176,9 @@ OPTIONS (notify / ask)
|
|
|
157
176
|
--correlation-id ID idempotency + routing key (auto-generated if omitted)
|
|
158
177
|
--thread ID conversation handle (#49): sends sharing it group as ONE
|
|
159
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
|
|
160
182
|
--collapse-key KEY replace/update a prior notification
|
|
161
183
|
--param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
|
|
162
184
|
fields work without a CLI update; the manifest is the contract
|
|
@@ -197,7 +219,9 @@ const command = parsed.positionals[0];
|
|
|
197
219
|
// `pidge --help` / `-h` / `help` → full help on stdout, exit 0. No command → stderr, exit 1.
|
|
198
220
|
if (v.help || command === 'help') { console.log(USAGE); process.exit(0); }
|
|
199
221
|
if (!command) { console.error(USAGE); process.exit(1); }
|
|
200
|
-
|
|
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)');
|
|
201
225
|
|
|
202
226
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
203
227
|
const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
|
|
@@ -217,7 +241,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
|
|
|
217
241
|
// The server advertises its manifest version on every response. When it's newer
|
|
218
242
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
219
243
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
220
|
-
const KNOWN_MANIFEST_VERSION =
|
|
244
|
+
const KNOWN_MANIFEST_VERSION = 24;
|
|
221
245
|
let newsWarned = false;
|
|
222
246
|
function checkManifestNews(res) {
|
|
223
247
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
@@ -372,6 +396,7 @@ function buildBody() {
|
|
|
372
396
|
if (v['reply-to'] !== undefined) body.reply_to = v['reply-to'];
|
|
373
397
|
if (v['correlation-id'] !== undefined) body.correlation_id = v['correlation-id'];
|
|
374
398
|
if (v.thread !== undefined) body.thread_id = v.thread;
|
|
399
|
+
if (v.after !== undefined) body.after = v.after;
|
|
375
400
|
if (v['collapse-key'] !== undefined) body.collapse_key = v['collapse-key'];
|
|
376
401
|
if (v.actions !== undefined) body.actions = v.actions.split(',').filter(Boolean);
|
|
377
402
|
|
|
@@ -379,6 +404,11 @@ function buildBody() {
|
|
|
379
404
|
if (customs.length) {
|
|
380
405
|
body.custom_actions = customs.map((spec) => {
|
|
381
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
|
+
}
|
|
382
412
|
const ca = { id, label };
|
|
383
413
|
if (flags.includes('destructive')) ca.style = 'destructive';
|
|
384
414
|
if (flags.includes('confirm')) ca.confirm = true;
|
|
@@ -613,8 +643,189 @@ async function waitForAnswer(cid, { timeout, interval }) {
|
|
|
613
643
|
|
|
614
644
|
const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
|
|
615
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
|
+
|
|
616
805
|
(async () => {
|
|
617
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
|
+
}
|
|
618
829
|
case 'notify': {
|
|
619
830
|
const { ok, info, raw } = await doNotify();
|
|
620
831
|
console.log(raw);
|
|
@@ -639,7 +850,20 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
639
850
|
const { ok, info } = await doNotify();
|
|
640
851
|
if (!ok) process.exit(2);
|
|
641
852
|
console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
|
|
642
|
-
|
|
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) });
|
|
643
867
|
break;
|
|
644
868
|
}
|
|
645
869
|
case 'wait': {
|
|
@@ -715,12 +939,35 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
715
939
|
// the whole session never had a healthy round-trip (#119).
|
|
716
940
|
// At-least-once: the ack happens AFTER the print — a crash re-serves them;
|
|
717
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).
|
|
718
946
|
const timeout = num(v.timeout, 600);
|
|
719
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
|
+
};
|
|
720
959
|
|
|
721
|
-
// 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.
|
|
722
961
|
const printAndAck = async (msgs) => {
|
|
723
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
|
+
}
|
|
724
971
|
const upTo = Math.max(...msgs.map((m) => m.id));
|
|
725
972
|
try {
|
|
726
973
|
// fetchT, not fetch: a wedged proxy stalling this ack would otherwise
|
|
@@ -738,7 +985,9 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
738
985
|
} catch (e) {
|
|
739
986
|
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
740
987
|
}
|
|
741
|
-
|
|
988
|
+
gotAny = true;
|
|
989
|
+
if (!v.follow) process.exit(0);
|
|
990
|
+
console.error('pidge: --follow — still listening');
|
|
742
991
|
};
|
|
743
992
|
|
|
744
993
|
// Realtime path (#118): hold ConversationChannel — the human sees "ouvindo
|
|
@@ -750,12 +999,15 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
750
999
|
if (draining) return;
|
|
751
1000
|
draining = true;
|
|
752
1001
|
try {
|
|
753
|
-
const res = await fetchT(`${BASE}/api/v1/messages`, { headers });
|
|
1002
|
+
const res = await fetchT(`${BASE}/api/v1/messages${queueQs}`, { headers });
|
|
754
1003
|
checkManifestNews(res);
|
|
755
1004
|
if (res.status === 200) {
|
|
756
1005
|
health.ok();
|
|
757
1006
|
const msgs = (await res.json().catch(() => ({}))).messages || [];
|
|
758
|
-
if (msgs.length) {
|
|
1007
|
+
if (msgs.length) {
|
|
1008
|
+
if (!v.follow) finish('got-messages');
|
|
1009
|
+
await printAndAck(msgs);
|
|
1010
|
+
}
|
|
759
1011
|
} else if (res.status >= 500) {
|
|
760
1012
|
health.fail(`backlog read ${res.status}`);
|
|
761
1013
|
}
|
|
@@ -766,16 +1018,29 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
766
1018
|
}
|
|
767
1019
|
};
|
|
768
1020
|
let announced = false;
|
|
769
|
-
const
|
|
1021
|
+
const sessions = [cableSession({
|
|
770
1022
|
channel: 'ConversationChannel',
|
|
771
1023
|
deadline,
|
|
772
1024
|
onUp: (finish) => {
|
|
773
|
-
if (!announced) { announced = true; console.error(
|
|
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")`); }
|
|
774
1026
|
drain(finish);
|
|
775
1027
|
},
|
|
776
1028
|
onFrame: (m, finish) => { if (m.type === 'message') drain(finish); },
|
|
777
|
-
});
|
|
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);
|
|
778
1042
|
if (outcome === 'deadline') {
|
|
1043
|
+
followEnd();
|
|
779
1044
|
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
780
1045
|
}
|
|
781
1046
|
if (outcome === 'got-messages') {
|
|
@@ -788,7 +1053,10 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
788
1053
|
const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
789
1054
|
const askedAt = Date.now();
|
|
790
1055
|
try {
|
|
791
|
-
const
|
|
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);
|
|
792
1060
|
checkManifestNews(res);
|
|
793
1061
|
if (res.status === 200) {
|
|
794
1062
|
health.ok();
|
|
@@ -805,6 +1073,7 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
805
1073
|
health.fail(`network: ${e.message}`);
|
|
806
1074
|
}
|
|
807
1075
|
if (Date.now() >= deadline) {
|
|
1076
|
+
followEnd();
|
|
808
1077
|
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
809
1078
|
}
|
|
810
1079
|
const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
|