pidge-cli 0.9.2 → 0.10.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 +12 -3
- package/bin/pidge.js +112 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ then gets the answer as JSON — no webhook, no polling loop to write.
|
|
|
20
20
|
> reports honest device reach + warns on a silent key swap.
|
|
21
21
|
>
|
|
22
22
|
> **v0.9.1** (Pidge manifest v28): full spec conformance — `setup` now **declares your
|
|
23
|
-
> operating contract** (`listen_mode`, default `turn_based`; `--listen-mode
|
|
23
|
+
> operating contract** (`listen_mode`, default `turn_based`; `--listen-mode persistent`
|
|
24
24
|
> for a supervisor); `contract set` rejects an unknown key/bad value **locally**;
|
|
25
25
|
> `whoami` reports honest device reach and SHOUTS on a silent key swap (not just
|
|
26
26
|
> `doctor`); `doctor` **exits 2** when devices exist but none are reachable; `--follow`
|
|
@@ -32,6 +32,14 @@ then gets the answer as JSON — no webhook, no polling loop to write.
|
|
|
32
32
|
> **v0.9.2**: `contract set` no longer prints the channel JSON (which echoed the key) —
|
|
33
33
|
> stdout now carries only the `operating_contract`, so the key never lands in an agent's
|
|
34
34
|
> transcript/logs.
|
|
35
|
+
>
|
|
36
|
+
> **v0.10.0** (Pidge manifest v29): the onboarding-close batch. **`pidge selftest`** proves
|
|
37
|
+
> your listener works by ROUND-TRIP (#205) — fire a nonce, run the listener, confirm it
|
|
38
|
+
> picks it up + acks in time (PASS exit 0 / FAIL exit 2 with the likely cause). `listen_mode`
|
|
39
|
+
> grew to **`turn_based | persistent | external_daemon`** (`always_on` is a tolerated alias),
|
|
40
|
+
> so you declare the mode that matches your runtime. And `listen` installs an **orphan-zombie
|
|
41
|
+
> guard**: a background listener whose parent (harness) dies exits instead of consuming the
|
|
42
|
+
> channel forever. The full operating guide now lives at `<base>/agent-setup`.
|
|
35
43
|
|
|
36
44
|
## Setup in one command (v0.8.0 — the claim flow)
|
|
37
45
|
|
|
@@ -120,8 +128,9 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
120
128
|
| `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
|
|
121
129
|
| `listen` | Block until the human **messages you** from the app; prints them, exits `0`. One-shot — loop it. **v0.9.0:** a read message is DELIVERED (gray ✓✓), **not** done — `ack` it after the work (`--ack-on-read` for the old immediate-consume). |
|
|
122
130
|
| `ack --up-to <id>` | **v0.9.0:** mark messages PROCESSED (green ✓✓) **after** you've handled them; `--renew` heartbeats the visibility-timeout lease on a long task. |
|
|
123
|
-
| `contract set <k>=<v>` / `contract show` | **v0.9.0:** DECLARE how you operate (`keep_connection_alive`, `mirror_in_origin_session`, `listen_mode=turn_based\|
|
|
124
|
-
| `
|
|
131
|
+
| `contract set <k>=<v>` / `contract show` | **v0.9.0:** DECLARE how you operate (`keep_connection_alive`, `mirror_in_origin_session`, `listen_mode=turn_based\|persistent\|external_daemon`, `quiet_when_idle`). **Advisory, never policy** — you declare, the human registers their expectation and *sees* if you honor it; Pidge enforces nothing. An unknown key/bad value is rejected locally (exit 1). |
|
|
132
|
+
| `selftest [--window N]` | **v0.10.0 (#205):** prove your listener works by ROUND-TRIP — fire a nonce, run the listener, confirm it picks it up + acks in time. PASS exit `0` / FAIL exit `2` with the likely cause (timeout / orphan / transport). Run it as the last onboarding step + whenever sends seem to go unheard. |
|
|
133
|
+
| `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. **v0.9.0** also claims channel ownership so `doctor` can warn on a silent key swap. **v0.9.1+** declares your `operating_contract` (default `listen_mode=turn_based`; `--listen-mode persistent\|external_daemon` for a supervisor/daemon). |
|
|
125
134
|
| `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, **honest device reach**, channel ownership. Exit 0/2. |
|
|
126
135
|
| `whoami` | Which channel does this key speak for (JSON). |
|
|
127
136
|
| `skill install` | Write `.claude/skills/pidge/SKILL.md` generated from the live manifest — persistent Pidge knowledge for Claude Code agents; re-run to update. |
|
package/bin/pidge.js
CHANGED
|
@@ -138,6 +138,7 @@ const OPTIONS = {
|
|
|
138
138
|
ids: { type: 'string' }, // ack: process this comma-list of ids
|
|
139
139
|
renew: { type: 'boolean' }, // ack: heartbeat the visibility-timeout lease (state=delivered)
|
|
140
140
|
'ack-on-read': { type: 'boolean' }, // listen: restore the pre-0.9 immediate-consume
|
|
141
|
+
window: { type: 'string' }, // selftest: reachability window in seconds (default 30)
|
|
141
142
|
};
|
|
142
143
|
|
|
143
144
|
const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
|
|
@@ -151,8 +152,8 @@ USAGE
|
|
|
151
152
|
(you run it in YOUR terminal; paste into the
|
|
152
153
|
agent's launcher — never run --print as an agent)
|
|
153
154
|
--force overwrite a shared file owned by another channel
|
|
154
|
-
--listen-mode turn_based|
|
|
155
|
-
operate (#182; default turn_based)
|
|
155
|
+
--listen-mode turn_based|persistent|external_daemon
|
|
156
|
+
declare how you operate (#182; default turn_based)
|
|
156
157
|
pidge doctor validate the setup WITHOUT exposing secrets:
|
|
157
158
|
env source, server, key, "canal X · N devices"
|
|
158
159
|
pidge whoami which channel does this key speak for (JSON)
|
|
@@ -174,8 +175,13 @@ USAGE
|
|
|
174
175
|
--renew heartbeats the lease on a long task (state=delivered)
|
|
175
176
|
pidge contract set <key>=<value> | contract show
|
|
176
177
|
DECLARE how you operate (#182): keep_connection_alive,
|
|
177
|
-
mirror_in_origin_session,
|
|
178
|
-
|
|
178
|
+
mirror_in_origin_session,
|
|
179
|
+
listen_mode=turn_based|persistent|external_daemon,
|
|
180
|
+
quiet_when_idle. ADVISORY, never policy (the human SEES if you honor it).
|
|
181
|
+
pidge selftest [--window N] prove your listener works by ROUND-TRIP (#205): fire a nonce,
|
|
182
|
+
run the listener, confirm it picks it up + acks in time.
|
|
183
|
+
PASS exit 0 / FAIL exit 2 (with the likely cause). Run it as the
|
|
184
|
+
last onboarding step + whenever sends seem to go unheard.
|
|
179
185
|
pidge skill install write .claude/skills/pidge/SKILL.md generated from the
|
|
180
186
|
live manifest (persistent Pidge knowledge for Claude Code)
|
|
181
187
|
pidge --version print the CLI version
|
|
@@ -825,9 +831,11 @@ async function declareOperatingContract(base, token, channelId) {
|
|
|
825
831
|
if (!channelId) return null;
|
|
826
832
|
const mode = v['listen-mode'];
|
|
827
833
|
let contract;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
834
|
+
// turn_based holds no connection; persistent/external_daemon/always_on all keep one
|
|
835
|
+
// alive (a supervisor or daemon holding the listen). §3c.
|
|
836
|
+
if (!mode || mode === 'turn_based') contract = { listen_mode: 'turn_based', keep_connection_alive: false };
|
|
837
|
+
else if (['persistent', 'external_daemon', 'always_on'].includes(mode)) contract = { listen_mode: mode, keep_connection_alive: true };
|
|
838
|
+
else { console.error(`pidge: --listen-mode must be turn_based | persistent | external_daemon (got "${mode}") — skipping the contract declaration`); return null; }
|
|
831
839
|
try {
|
|
832
840
|
const res = await fetchT(`${base}/api/v1/channels/${channelId}`, {
|
|
833
841
|
method: 'PATCH',
|
|
@@ -852,7 +860,10 @@ async function declareOperatingContract(base, token, channelId) {
|
|
|
852
860
|
const OPERATING_CONTRACT_SPEC = {
|
|
853
861
|
keep_connection_alive: 'boolean',
|
|
854
862
|
mirror_in_origin_session: 'boolean',
|
|
855
|
-
|
|
863
|
+
// §3c: match your RUNTIME. turn_based (no event loop — block-and-exit) · persistent
|
|
864
|
+
// (a supervisor holding the socket, --follow) · external_daemon (a daemon outside the
|
|
865
|
+
// session). always_on stays as a tolerated deprecated alias of persistent.
|
|
866
|
+
listen_mode: ['turn_based', 'persistent', 'external_daemon', 'always_on'],
|
|
856
867
|
quiet_when_idle: 'boolean',
|
|
857
868
|
};
|
|
858
869
|
// Coerce + validate one operating_contract value against the allowlist. Returns
|
|
@@ -934,6 +945,92 @@ async function runContract() {
|
|
|
934
945
|
process.exit(0);
|
|
935
946
|
}
|
|
936
947
|
|
|
948
|
+
// Orphan-zombie guard (§3c pitfall #1): when `npx pidge-cli listen` is launched as a
|
|
949
|
+
// background task and the harness later kills the npx wrapper, the node LEAF can
|
|
950
|
+
// orphan and keep consuming the channel forever without ever waking the agent. A
|
|
951
|
+
// long-running listen polls its parent: if it had a real parent at startup and that
|
|
952
|
+
// parent dies (re-parented to pid 1), it exits so it stops eating the queue. Skipped
|
|
953
|
+
// when started detached (ppid 1 already — e.g. an external_daemon under systemd).
|
|
954
|
+
function installOrphanWatchdog() {
|
|
955
|
+
if (process.ppid === 1) return; // already detached — nothing to orphan from
|
|
956
|
+
const t = setInterval(() => {
|
|
957
|
+
if (process.ppid === 1) {
|
|
958
|
+
console.error('pidge: parent process died — exiting so I stop consuming the channel (orphan-zombie guard). Relaunch from your harness.');
|
|
959
|
+
process.exit(0);
|
|
960
|
+
}
|
|
961
|
+
}, 2000);
|
|
962
|
+
if (t.unref) t.unref(); // never keep the process alive just for the watchdog
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// selftest (#205): prove the listener works by ROUND-TRIP, not prose. Fire a nonce
|
|
966
|
+
// onto our own queue, run the listener (long-poll floor — the reachability path) for
|
|
967
|
+
// the window, ack the nonce, then read the server's verdict. PASS = it round-tripped
|
|
968
|
+
// in time. FAIL = the server's window verdict + a likely CAUSE the server can't see
|
|
969
|
+
// (the orphan/`&`/transport bugs). Only the nonce is acked (ids:[id]) and any real
|
|
970
|
+
// messages briefly served are re-served fast (lease=60), so it doesn't eat the queue.
|
|
971
|
+
async function doSelftest() {
|
|
972
|
+
// Guard the parse: a non-numeric --window (e.g. "30s", a typo) must NOT become NaN
|
|
973
|
+
// — that would make the deadline NaN, skip the poll loop entirely, and mis-report a
|
|
974
|
+
// perfectly fine listener as "orphaned/dead" (the most misleading failure possible).
|
|
975
|
+
const rawWindow = num(v.window, 30);
|
|
976
|
+
const windowS = Math.max(5, Math.min(120, Number.isFinite(rawWindow) ? rawWindow : 30));
|
|
977
|
+
let fired;
|
|
978
|
+
try {
|
|
979
|
+
const res = await fetchT(`${BASE}/api/v1/selftest`, {
|
|
980
|
+
method: 'POST', headers, body: JSON.stringify({ window_seconds: windowS }),
|
|
981
|
+
});
|
|
982
|
+
checkManifestNews(res);
|
|
983
|
+
if (res.status < 200 || res.status >= 300) die(`pidge: selftest: the server refused (${res.status}) — is your key valid? try \`pidge doctor\``, 2);
|
|
984
|
+
fired = await res.json();
|
|
985
|
+
} catch (e) {
|
|
986
|
+
die(`pidge: selftest failed (network): ${e.message}`, 2);
|
|
987
|
+
}
|
|
988
|
+
const id = fired.id;
|
|
989
|
+
console.error(`pidge: self-test fired (id ${id}) — listening up to ${windowS}s to prove the round-trip (a nonce on your own queue; PASS = your listener picks it up + acks it in time)`);
|
|
990
|
+
|
|
991
|
+
const deadline = Date.now() + windowS * 1000;
|
|
992
|
+
let sawNonce = false;
|
|
993
|
+
while (Date.now() < deadline && !sawNonce) {
|
|
994
|
+
const waitS = Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
995
|
+
const askedAt = Date.now();
|
|
996
|
+
try {
|
|
997
|
+
const qs = new URLSearchParams({ all: 'true', lease: '60' });
|
|
998
|
+
if (waitS > 0) qs.set('wait', String(waitS));
|
|
999
|
+
const res = await fetchT(`${BASE}/api/v1/messages?${qs}`, { headers }, (waitS + 10) * 1000);
|
|
1000
|
+
if (res.status === 200) {
|
|
1001
|
+
const msgs = (await res.json().catch(() => ({}))).messages || [];
|
|
1002
|
+
if (msgs.some((m) => m.id === id)) {
|
|
1003
|
+
sawNonce = true;
|
|
1004
|
+
// ack ONLY the nonce (ids, not up_to) so real pending messages aren't consumed.
|
|
1005
|
+
try { await fetchT(`${BASE}/api/v1/messages/ack`, { method: 'POST', headers, body: JSON.stringify({ ids: [ id ] }) }); } catch { /* server verdict is the source of truth */ }
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
} catch { /* keep trying until the deadline */ }
|
|
1009
|
+
// pace: if the poll returned fast (the server didn't actually hold ?wait=), don't busy-spin.
|
|
1010
|
+
if (!sawNonce && Date.now() - askedAt < 1000 && Date.now() < deadline) await sleep(1000);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
let verdict = {};
|
|
1014
|
+
try {
|
|
1015
|
+
const res = await fetchT(`${BASE}/api/v1/selftest/${id}`, { headers });
|
|
1016
|
+
if (res.status === 200) verdict = await res.json();
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
die(`pidge: selftest: couldn't read the result (${e.message})`, 2);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (verdict.status === 'passed') {
|
|
1022
|
+
console.error('pidge: ✅ SELF-TEST PASSED — your listener received the nonce and acked it in time. Reachability proven.');
|
|
1023
|
+
console.log(JSON.stringify({ status: 'passed', id, window_seconds: windowS }));
|
|
1024
|
+
process.exit(0);
|
|
1025
|
+
}
|
|
1026
|
+
const cause = sawNonce
|
|
1027
|
+
? 'your listener received the nonce but acked it AFTER the window — a slow/flaky transport, or the work between read and ack took too long. Widen --window, or make your real listen loop ack sooner.'
|
|
1028
|
+
: 'your listener never received the nonce in the window — likely an ORPHANED/detached listener (an npx leaf left running, or a loose `&`), or a dead transport. Run ONE single-process listener as a tracked background task; `pidge listen --no-realtime` is the robust floor.';
|
|
1029
|
+
console.error(`pidge: ❌ SELF-TEST FAILED — ${cause}`);
|
|
1030
|
+
console.log(JSON.stringify({ status: verdict.status || 'failed', id, saw_nonce: sawNonce }));
|
|
1031
|
+
process.exit(2);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
937
1034
|
// doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
|
|
938
1035
|
// a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
|
|
939
1036
|
async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
@@ -1271,6 +1368,12 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1271
1368
|
await runContract();
|
|
1272
1369
|
break;
|
|
1273
1370
|
}
|
|
1371
|
+
case 'selftest': {
|
|
1372
|
+
// #205: prove reachability by round-trip. Fire a nonce, run the listener,
|
|
1373
|
+
// confirm it picks it up + acks in time. PASS exit 0 / FAIL exit 2.
|
|
1374
|
+
await doSelftest();
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1274
1377
|
case 'inbox': {
|
|
1275
1378
|
// #83: what this channel sent — the list (default), the pending slice
|
|
1276
1379
|
// (--pending = delivered + still unanswered) or the one-call summary
|
|
@@ -1318,6 +1421,7 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1318
1421
|
// ANSWERS (kind notification_reply, with a self-contained ref), so a
|
|
1319
1422
|
// fire-and-forget notify can't lose its reply. Without --all the original
|
|
1320
1423
|
// composer-only contract stands (no double-consumption for ask/wait users).
|
|
1424
|
+
installOrphanWatchdog(); // §3c: a killed-parent orphan exits instead of eating the queue
|
|
1321
1425
|
const timeout = num(v.timeout, 600);
|
|
1322
1426
|
let deadline = Date.now() + timeout * 1000;
|
|
1323
1427
|
const queueQs = v.all ? '?all=true' : '';
|