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.
Files changed (3) hide show
  1. package/README.md +12 -3
  2. package/bin/pidge.js +112 -8
  3. 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 always_on`
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\|always_on`, `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). |
124
- | `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 always_on` for a supervisor). |
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|always_on declare how you
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, listen_mode=turn_based|always_on,
178
- quiet_when_idle. A CONTRACT, never policy (the human can force it).
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
- if (mode === 'always_on') contract = { listen_mode: 'always_on', keep_connection_alive: true };
829
- else if (!mode || mode === 'turn_based') contract = { listen_mode: 'turn_based', keep_connection_alive: false };
830
- else { console.error(`pidge: --listen-mode must be turn_based or always_on (got "${mode}") skipping the contract declaration`); return null; }
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
- listen_mode: ['turn_based', 'always_on'],
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' : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.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",