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.
- package/README.md +17 -0
- package/bin/pidge.js +306 -18
- 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)
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
|
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
|
|
1021
|
+
const sessions = [cableSession({
|
|
751
1022
|
channel: 'ConversationChannel',
|
|
752
1023
|
deadline,
|
|
753
1024
|
onUp: (finish) => {
|
|
754
|
-
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")`); }
|
|
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
|
|
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);
|