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.
- package/README.md +19 -3
- package/bin/pidge.js +142 -9
- 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,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\|
|
|
124
|
-
| `
|
|
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|
|
|
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,
|
|
178
|
-
|
|
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 =
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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' : '';
|