pidge-cli 0.9.2 → 0.11.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 +19 -3
  2. package/bin/pidge.js +142 -9
  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,20 @@ 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.11.0** (Pidge manifest v30): the **first-contact WOW** (#217). New **`pidge hello`** —
37
+ > your channel's debut handshake, narrated LIVE on the lock screen by a server-driven 3-stage
38
+ > Live Activity (Conectando → toque para confirmar → Concluído ✓) so your human *sees* the
39
+ > agent→human→agent loop close. Send + wait in one; run it as your first contact on a fresh
40
+ > channel. (It's a thin `ask --template onboarding` wrapper — that path already works on v0.10.0.)
41
+ >
42
+ > **v0.10.0** (Pidge manifest v29): the onboarding-close batch. **`pidge selftest`** proves
43
+ > your listener works by ROUND-TRIP (#205) — fire a nonce, run the listener, confirm it
44
+ > picks it up + acks in time (PASS exit 0 / FAIL exit 2 with the likely cause). `listen_mode`
45
+ > grew to **`turn_based | persistent | external_daemon`** (`always_on` is a tolerated alias),
46
+ > so you declare the mode that matches your runtime. And `listen` installs an **orphan-zombie
47
+ > guard**: a background listener whose parent (harness) dies exits instead of consuming the
48
+ > channel forever. The full operating guide now lives at `<base>/agent-setup`.
35
49
 
36
50
  ## Setup in one command (v0.8.0 — the claim flow)
37
51
 
@@ -113,6 +127,7 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
113
127
 
114
128
  | Command | What it does |
115
129
  |---|---|
130
+ | `hello` | **v0.11.0 (#217):** your channel's **first-contact WOW** — send the onboarding handshake **and block** until the human confirms. The server narrates a 3-stage Live Activity on the lock screen (Conectando → toque para confirmar → Concluído ✓) so they *see* the agent→human→agent loop close. Run it as your **first** contact on a fresh channel. A thin `ask --template onboarding` wrapper with friendly default copy. |
116
131
  | `ask` | Send a notification **and block** until the human answers; prints the chosen action JSON. The default for agents. |
117
132
  | `notify` | Send only. Prints the raw 201 JSON; the `correlation_id` + warnings go to stderr. |
118
133
  | `wait <correlation_id>` | Block on an already-sent notification until it's answered. |
@@ -120,8 +135,9 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
120
135
  | `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
121
136
  | `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
137
  | `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). |
138
+ | `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). |
139
+ | `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. |
140
+ | `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
141
  | `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, **honest device reach**, channel ownership. Exit 0/2. |
126
142
  | `whoami` | Which channel does this key speak for (JSON). |
127
143
  | `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,11 +152,15 @@ 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)
160
+ pidge hello [options] FIRST-CONTACT WOW (#217): your channel's debut handshake,
161
+ narrated LIVE on the lock screen by a 3-stage Live Activity
162
+ (Conectando → toque para confirmar → Concluído ✓). send + wait
163
+ in one — run it as your FIRST contact on a fresh channel.
159
164
  pidge ask [options] send AND wait for the answer (prints chosen_action JSON)
160
165
  pidge notify [options] send only (prints the 201 JSON)
161
166
  pidge wait <correlation_id> [options] block on an already-sent notification
@@ -174,8 +179,13 @@ USAGE
174
179
  --renew heartbeats the lease on a long task (state=delivered)
175
180
  pidge contract set <key>=<value> | contract show
176
181
  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).
182
+ mirror_in_origin_session,
183
+ listen_mode=turn_based|persistent|external_daemon,
184
+ quiet_when_idle. ADVISORY, never policy (the human SEES if you honor it).
185
+ pidge selftest [--window N] prove your listener works by ROUND-TRIP (#205): fire a nonce,
186
+ run the listener, confirm it picks it up + acks in time.
187
+ PASS exit 0 / FAIL exit 2 (with the likely cause). Run it as the
188
+ last onboarding step + whenever sends seem to go unheard.
179
189
  pidge skill install write .claude/skills/pidge/SKILL.md generated from the
180
190
  live manifest (persistent Pidge knowledge for Claude Code)
181
191
  pidge --version print the CLI version
@@ -292,7 +302,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
292
302
  // The server advertises its manifest version on every response. When it's newer
293
303
  // than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
294
304
  // the manifest (whats_new) and learns the new capabilities without polling.
295
- const KNOWN_MANIFEST_VERSION = 28;
305
+ const KNOWN_MANIFEST_VERSION = 30;
296
306
  let newsWarned = false;
297
307
  function checkManifestNews(res) {
298
308
  const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
@@ -825,9 +835,11 @@ async function declareOperatingContract(base, token, channelId) {
825
835
  if (!channelId) return null;
826
836
  const mode = v['listen-mode'];
827
837
  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; }
838
+ // turn_based holds no connection; persistent/external_daemon/always_on all keep one
839
+ // alive (a supervisor or daemon holding the listen). §3c.
840
+ if (!mode || mode === 'turn_based') contract = { listen_mode: 'turn_based', keep_connection_alive: false };
841
+ else if (['persistent', 'external_daemon', 'always_on'].includes(mode)) contract = { listen_mode: mode, keep_connection_alive: true };
842
+ else { console.error(`pidge: --listen-mode must be turn_based | persistent | external_daemon (got "${mode}") — skipping the contract declaration`); return null; }
831
843
  try {
832
844
  const res = await fetchT(`${base}/api/v1/channels/${channelId}`, {
833
845
  method: 'PATCH',
@@ -852,7 +864,10 @@ async function declareOperatingContract(base, token, channelId) {
852
864
  const OPERATING_CONTRACT_SPEC = {
853
865
  keep_connection_alive: 'boolean',
854
866
  mirror_in_origin_session: 'boolean',
855
- listen_mode: ['turn_based', 'always_on'],
867
+ // §3c: match your RUNTIME. turn_based (no event loop — block-and-exit) · persistent
868
+ // (a supervisor holding the socket, --follow) · external_daemon (a daemon outside the
869
+ // session). always_on stays as a tolerated deprecated alias of persistent.
870
+ listen_mode: ['turn_based', 'persistent', 'external_daemon', 'always_on'],
856
871
  quiet_when_idle: 'boolean',
857
872
  };
858
873
  // Coerce + validate one operating_contract value against the allowlist. Returns
@@ -934,6 +949,92 @@ async function runContract() {
934
949
  process.exit(0);
935
950
  }
936
951
 
952
+ // Orphan-zombie guard (§3c pitfall #1): when `npx pidge-cli listen` is launched as a
953
+ // background task and the harness later kills the npx wrapper, the node LEAF can
954
+ // orphan and keep consuming the channel forever without ever waking the agent. A
955
+ // long-running listen polls its parent: if it had a real parent at startup and that
956
+ // parent dies (re-parented to pid 1), it exits so it stops eating the queue. Skipped
957
+ // when started detached (ppid 1 already — e.g. an external_daemon under systemd).
958
+ function installOrphanWatchdog() {
959
+ if (process.ppid === 1) return; // already detached — nothing to orphan from
960
+ const t = setInterval(() => {
961
+ if (process.ppid === 1) {
962
+ console.error('pidge: parent process died — exiting so I stop consuming the channel (orphan-zombie guard). Relaunch from your harness.');
963
+ process.exit(0);
964
+ }
965
+ }, 2000);
966
+ if (t.unref) t.unref(); // never keep the process alive just for the watchdog
967
+ }
968
+
969
+ // selftest (#205): prove the listener works by ROUND-TRIP, not prose. Fire a nonce
970
+ // onto our own queue, run the listener (long-poll floor — the reachability path) for
971
+ // the window, ack the nonce, then read the server's verdict. PASS = it round-tripped
972
+ // in time. FAIL = the server's window verdict + a likely CAUSE the server can't see
973
+ // (the orphan/`&`/transport bugs). Only the nonce is acked (ids:[id]) and any real
974
+ // messages briefly served are re-served fast (lease=60), so it doesn't eat the queue.
975
+ async function doSelftest() {
976
+ // Guard the parse: a non-numeric --window (e.g. "30s", a typo) must NOT become NaN
977
+ // — that would make the deadline NaN, skip the poll loop entirely, and mis-report a
978
+ // perfectly fine listener as "orphaned/dead" (the most misleading failure possible).
979
+ const rawWindow = num(v.window, 30);
980
+ const windowS = Math.max(5, Math.min(120, Number.isFinite(rawWindow) ? rawWindow : 30));
981
+ let fired;
982
+ try {
983
+ const res = await fetchT(`${BASE}/api/v1/selftest`, {
984
+ method: 'POST', headers, body: JSON.stringify({ window_seconds: windowS }),
985
+ });
986
+ checkManifestNews(res);
987
+ if (res.status < 200 || res.status >= 300) die(`pidge: selftest: the server refused (${res.status}) — is your key valid? try \`pidge doctor\``, 2);
988
+ fired = await res.json();
989
+ } catch (e) {
990
+ die(`pidge: selftest failed (network): ${e.message}`, 2);
991
+ }
992
+ const id = fired.id;
993
+ 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)`);
994
+
995
+ const deadline = Date.now() + windowS * 1000;
996
+ let sawNonce = false;
997
+ while (Date.now() < deadline && !sawNonce) {
998
+ const waitS = Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
999
+ const askedAt = Date.now();
1000
+ try {
1001
+ const qs = new URLSearchParams({ all: 'true', lease: '60' });
1002
+ if (waitS > 0) qs.set('wait', String(waitS));
1003
+ const res = await fetchT(`${BASE}/api/v1/messages?${qs}`, { headers }, (waitS + 10) * 1000);
1004
+ if (res.status === 200) {
1005
+ const msgs = (await res.json().catch(() => ({}))).messages || [];
1006
+ if (msgs.some((m) => m.id === id)) {
1007
+ sawNonce = true;
1008
+ // ack ONLY the nonce (ids, not up_to) so real pending messages aren't consumed.
1009
+ try { await fetchT(`${BASE}/api/v1/messages/ack`, { method: 'POST', headers, body: JSON.stringify({ ids: [ id ] }) }); } catch { /* server verdict is the source of truth */ }
1010
+ }
1011
+ }
1012
+ } catch { /* keep trying until the deadline */ }
1013
+ // pace: if the poll returned fast (the server didn't actually hold ?wait=), don't busy-spin.
1014
+ if (!sawNonce && Date.now() - askedAt < 1000 && Date.now() < deadline) await sleep(1000);
1015
+ }
1016
+
1017
+ let verdict = {};
1018
+ try {
1019
+ const res = await fetchT(`${BASE}/api/v1/selftest/${id}`, { headers });
1020
+ if (res.status === 200) verdict = await res.json();
1021
+ } catch (e) {
1022
+ die(`pidge: selftest: couldn't read the result (${e.message})`, 2);
1023
+ }
1024
+
1025
+ if (verdict.status === 'passed') {
1026
+ console.error('pidge: ✅ SELF-TEST PASSED — your listener received the nonce and acked it in time. Reachability proven.');
1027
+ console.log(JSON.stringify({ status: 'passed', id, window_seconds: windowS }));
1028
+ process.exit(0);
1029
+ }
1030
+ const cause = sawNonce
1031
+ ? '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.'
1032
+ : '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.';
1033
+ console.error(`pidge: ❌ SELF-TEST FAILED — ${cause}`);
1034
+ console.log(JSON.stringify({ status: verdict.status || 'failed', id, saw_nonce: sawNonce }));
1035
+ process.exit(2);
1036
+ }
1037
+
937
1038
  // doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
938
1039
  // a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
939
1040
  async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
@@ -1176,6 +1277,31 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1176
1277
  process.exit(ok ? 0 : 2);
1177
1278
  break;
1178
1279
  }
1280
+ case 'hello': {
1281
+ // #217 — the first-contact WOW: fire the onboarding handshake and block on
1282
+ // your human's confirmation. The SERVER narrates a 3-stage Live Activity on
1283
+ // the lock screen (Conectando → toque para confirmar → Concluído ✓) so your
1284
+ // human SEES the agent→human→agent loop close. One command: send + wait.
1285
+ // Run it as your FIRST contact on a fresh channel. A thin wrapper over `ask`:
1286
+ // it just pins template=onboarding and friendly default copy.
1287
+ if (v.profile === 'tracking')
1288
+ die('pidge: `hello --profile tracking` makes no sense — the handshake waits for a confirmation, which tracking (Live-Activity-only) never produces', 1);
1289
+ v.template = 'onboarding';
1290
+ if (v.title === undefined) v.title = 'Seu agente está pronto 🐦';
1291
+ if (v.body === undefined) v.body = 'Toque em Feito ✓ para confirmar que me recebeu — você vai ver o teste fechar na tela.';
1292
+ const cid = v['correlation-id'] || crypto.randomUUID();
1293
+ v['correlation-id'] = cid;
1294
+ console.error(`pidge: correlation_id=${cid}`);
1295
+ const { ok, info } = await doNotify();
1296
+ if (!ok) process.exit(2);
1297
+ console.error(`pidge: WOW sent (${info.registered_devices} device(s)) — watch the lock screen narrate the handshake; waiting for your human to confirm on ${cid}`);
1298
+ // No --timeout ⇒ obey the template's suggestion from the 201 echo (onboarding
1299
+ // = 3600 s); explicit --timeout always wins.
1300
+ let timeout = num(v.timeout, NaN);
1301
+ if (!Number.isFinite(timeout)) timeout = info.suggested_ask_timeout || 3600;
1302
+ await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
1303
+ break;
1304
+ }
1179
1305
  case 'ask': {
1180
1306
  // Send, then block on the answer in one shot. stdout = ONLY chosen_action JSON.
1181
1307
  // tracking is Live-Activity-only: it NEVER produces a chosen_action, so an ask
@@ -1271,6 +1397,12 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1271
1397
  await runContract();
1272
1398
  break;
1273
1399
  }
1400
+ case 'selftest': {
1401
+ // #205: prove reachability by round-trip. Fire a nonce, run the listener,
1402
+ // confirm it picks it up + acks in time. PASS exit 0 / FAIL exit 2.
1403
+ await doSelftest();
1404
+ break;
1405
+ }
1274
1406
  case 'inbox': {
1275
1407
  // #83: what this channel sent — the list (default), the pending slice
1276
1408
  // (--pending = delivered + still unanswered) or the one-call summary
@@ -1318,6 +1450,7 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1318
1450
  // ANSWERS (kind notification_reply, with a self-contained ref), so a
1319
1451
  // fire-and-forget notify can't lose its reply. Without --all the original
1320
1452
  // composer-only contract stands (no double-consumption for ask/wait users).
1453
+ installOrphanWatchdog(); // §3c: a killed-parent orphan exits instead of eating the queue
1321
1454
  const timeout = num(v.timeout, 600);
1322
1455
  let deadline = Date.now() + timeout * 1000;
1323
1456
  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.11.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",