pidge-cli 0.8.1 → 0.9.1
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 +24 -3
- package/bin/pidge.js +348 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,24 @@ 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
|
+
> **New in v0.9.0** (ships with Pidge manifest v27): **`listen` no longer consumes on
|
|
15
|
+
> read** — a read message is DELIVERED, and you `ack` it after the work (a ~10-min
|
|
16
|
+
> server lease re-serves un-acked messages, so a crash never loses one; `--ack-on-read`
|
|
17
|
+
> restores the old behavior). A WS close **1006 now degrades to long-polling** on the
|
|
18
|
+
> same deadline instead of exiting early, and timeouts report the **real** elapsed time.
|
|
19
|
+
> New: `ack`, `contract`, `--version`; `setup` claims channel ownership and `doctor`
|
|
20
|
+
> reports honest device reach + warns on a silent key swap.
|
|
21
|
+
>
|
|
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`
|
|
24
|
+
> for a supervisor); `contract set` rejects an unknown key/bad value **locally**;
|
|
25
|
+
> `whoami` reports honest device reach and SHOUTS on a silent key swap (not just
|
|
26
|
+
> `doctor`); `doctor` **exits 2** when devices exist but none are reachable; `--follow`
|
|
27
|
+
> prints a loud supervisor-only warning; the ack-after-work notice shows **once per
|
|
28
|
+
> install**; and the timeout clock is monotonic. `operating_contract` is **advisory** —
|
|
29
|
+
> Pidge is a relay: you declare how you operate, the human registers their expectation
|
|
30
|
+
> and *sees* if you honor it; nothing is forced.
|
|
31
|
+
|
|
14
32
|
## Setup in one command (v0.8.0 — the claim flow)
|
|
15
33
|
|
|
16
34
|
```bash
|
|
@@ -96,11 +114,14 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
96
114
|
| `wait <correlation_id>` | Block on an already-sent notification until it's answered. |
|
|
97
115
|
| `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
|
|
98
116
|
| `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
|
|
99
|
-
| `listen` | Block until the human **messages you** from the app; prints
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
117
|
+
| `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). |
|
|
118
|
+
| `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. |
|
|
119
|
+
| `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). |
|
|
120
|
+
| `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). |
|
|
121
|
+
| `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, **honest device reach**, channel ownership. Exit 0/2. |
|
|
102
122
|
| `whoami` | Which channel does this key speak for (JSON). |
|
|
103
123
|
| `skill install` | Write `.claude/skills/pidge/SKILL.md` generated from the live manifest — persistent Pidge knowledge for Claude Code agents; re-run to update. |
|
|
124
|
+
| `--version` | Print the CLI version. |
|
|
104
125
|
|
|
105
126
|
## Realtime (v0.6.0)
|
|
106
127
|
|
package/bin/pidge.js
CHANGED
|
@@ -41,6 +41,14 @@ const path = require('node:path');
|
|
|
41
41
|
const os = require('node:os');
|
|
42
42
|
const crypto = require('node:crypto');
|
|
43
43
|
|
|
44
|
+
// `pidge --version` / `-v` — handled BEFORE parseArgs (which would otherwise
|
|
45
|
+
// throw "Unknown option" on the undeclared flag). Prints the version, exit 0.
|
|
46
|
+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
47
|
+
try { console.log(require(path.join(__dirname, '..', 'package.json')).version); }
|
|
48
|
+
catch { console.log('unknown'); }
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
// Per-agent isolation (incident 2026-06-13): ~/.config/pidge/env is one slot
|
|
45
53
|
// per machine-user, so N agents sharing a HOME share an identity — one agent's
|
|
46
54
|
// setup hijacked another's cron. The fix is a NON-secret namespacing var the
|
|
@@ -124,6 +132,12 @@ const OPTIONS = {
|
|
|
124
132
|
follow: { type: 'boolean' },
|
|
125
133
|
force: { type: 'boolean' }, // setup: overwrite a config owned by ANOTHER channel
|
|
126
134
|
print: { type: 'boolean' }, // setup: print export lines instead of writing a file (per-agent, human runs it)
|
|
135
|
+
'listen-mode': { type: 'string' }, // setup: declare operating_contract listen mode (turn_based|always_on; default turn_based)
|
|
136
|
+
// Fix 2 (#170): read-receipt split — `ack` after the work; listen no longer consumes on read.
|
|
137
|
+
'up-to': { type: 'string' }, // ack: process messages up to this id
|
|
138
|
+
ids: { type: 'string' }, // ack: process this comma-list of ids
|
|
139
|
+
renew: { type: 'boolean' }, // ack: heartbeat the visibility-timeout lease (state=delivered)
|
|
140
|
+
'ack-on-read': { type: 'boolean' }, // listen: restore the pre-0.9 immediate-consume
|
|
127
141
|
};
|
|
128
142
|
|
|
129
143
|
const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
|
|
@@ -137,6 +151,8 @@ USAGE
|
|
|
137
151
|
(you run it in YOUR terminal; paste into the
|
|
138
152
|
agent's launcher — never run --print as an agent)
|
|
139
153
|
--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)
|
|
140
156
|
pidge doctor validate the setup WITHOUT exposing secrets:
|
|
141
157
|
env source, server, key, "canal X · N devices"
|
|
142
158
|
pidge whoami which channel does this key speak for (JSON)
|
|
@@ -145,15 +161,24 @@ USAGE
|
|
|
145
161
|
pidge wait <correlation_id> [options] block on an already-sent notification
|
|
146
162
|
pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
|
|
147
163
|
pidge inbox [--pending|--summary|--all|--limit N] what you sent: list, pending slice, or counts+latency (#83)
|
|
148
|
-
pidge listen [--timeout N] [--all] [--follow]
|
|
149
|
-
block until the human MESSAGES you from the app, print
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
pidge listen [--timeout N] [--all] [--ack-on-read] [--follow]
|
|
165
|
+
block until the human MESSAGES you from the app, print, exit (#48)
|
|
166
|
+
#170: a read message is DELIVERED (gray ✓✓), NOT done — ACK it
|
|
167
|
+
AFTER the work: pidge ack --up-to <id> (a ~10-min lease re-serves
|
|
168
|
+
un-acked messages, so a crash never loses one)
|
|
169
|
+
--ack-on-read = the old immediate-consume (ack on print)
|
|
170
|
+
--follow = KEEP listening until --timeout (supervisor-only)
|
|
171
|
+
--all (#131) = the SINGLE EAR: also hear notification ANSWERS
|
|
172
|
+
pidge ack --up-to <id> | --ids a,b [--renew]
|
|
173
|
+
mark messages PROCESSED (green ✓✓) after you handled them (#170);
|
|
174
|
+
--renew heartbeats the lease on a long task (state=delivered)
|
|
175
|
+
pidge contract set <key>=<value> | contract show
|
|
176
|
+
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).
|
|
155
179
|
pidge skill install write .claude/skills/pidge/SKILL.md generated from the
|
|
156
180
|
live manifest (persistent Pidge knowledge for Claude Code)
|
|
181
|
+
pidge --version print the CLI version
|
|
157
182
|
pidge --help
|
|
158
183
|
|
|
159
184
|
REALTIME (#118)
|
|
@@ -267,7 +292,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
|
|
|
267
292
|
// The server advertises its manifest version on every response. When it's newer
|
|
268
293
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
269
294
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
270
|
-
const KNOWN_MANIFEST_VERSION =
|
|
295
|
+
const KNOWN_MANIFEST_VERSION = 28;
|
|
271
296
|
let newsWarned = false;
|
|
272
297
|
function checkManifestNews(res) {
|
|
273
298
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
@@ -290,6 +315,14 @@ function checkManifestNews(res) {
|
|
|
290
315
|
const DEGRADE_AFTER = 3;
|
|
291
316
|
// env override = a test/ops hook, not a documented knob
|
|
292
317
|
const DEGRADED_INTERVAL_S = parseInt(process.env.PIDGE_DEGRADED_INTERVAL || '45', 10);
|
|
318
|
+
// When the blocking session began — so a timeout reports the REAL elapsed
|
|
319
|
+
// wall-clock, never the configured deadline. The dogfooding bug (2026-06-14): a
|
|
320
|
+
// WS close 1006 made the CLI exit "timed out after 28800s" when only seconds had
|
|
321
|
+
// passed — the number lied. exitTimeout now reports elapsed since this baseline.
|
|
322
|
+
// MONOTONIC on purpose (§2.5): performance.now() can't be skewed by a wall-clock
|
|
323
|
+
// change (NTP step / DST) mid-session — a Date.now() delta could, re-opening the
|
|
324
|
+
// "wrong number" failure mode the fix exists to kill.
|
|
325
|
+
const SESSION_START_MONO = performance.now();
|
|
293
326
|
const health = {
|
|
294
327
|
okEver: false, fails: 0, firstFailAt: 0, lastNoteAt: 0, degraded: false,
|
|
295
328
|
ok() {
|
|
@@ -309,8 +342,11 @@ const health = {
|
|
|
309
342
|
}
|
|
310
343
|
},
|
|
311
344
|
exitTimeout(message) {
|
|
312
|
-
|
|
313
|
-
|
|
345
|
+
// REAL elapsed wall-clock — never the configured deadline (the 2026-06-14
|
|
346
|
+
// "timed out after 28800s" lie). If only seconds passed, the number says so.
|
|
347
|
+
const elapsed = Math.round((performance.now() - SESSION_START_MONO) / 1000);
|
|
348
|
+
if (this.okEver) { console.error(`pidge: ${message} after ${elapsed}s (= 'no answer yet', not a failure)`); process.exit(3); }
|
|
349
|
+
console.error(`pidge: ${message} after ${elapsed}s — and NOT ONE healthy round-trip all session: the CHANNEL looks broken (server/network), not the human ignoring you. Surface this to your human.`);
|
|
314
350
|
process.exit(4);
|
|
315
351
|
},
|
|
316
352
|
};
|
|
@@ -397,7 +433,9 @@ async function cableSession({ channel, deadline, onUp, onFrame }) {
|
|
|
397
433
|
wsFails++;
|
|
398
434
|
const MAX_WS_FAILS = 4; // then fall back to polling for the rest of the session
|
|
399
435
|
if (wsFails >= MAX_WS_FAILS) return 'ws-unavailable';
|
|
400
|
-
|
|
436
|
+
// env override = a test/ops hook (keeps the forced-1006 degrade test fast)
|
|
437
|
+
const base = parseInt(process.env.PIDGE_WS_BACKOFF_MS || '2000', 10) || 2000;
|
|
438
|
+
const backoff = Math.min(base * wsFails, base * 5);
|
|
401
439
|
console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} — reconnecting in ${Math.round(backoff / 1000)}s (attempt ${wsFails}/${MAX_WS_FAILS})`);
|
|
402
440
|
await sleep(backoff);
|
|
403
441
|
}
|
|
@@ -604,7 +642,7 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
604
642
|
}
|
|
605
643
|
|
|
606
644
|
if (Date.now() >= deadline) {
|
|
607
|
-
health.exitTimeout(`
|
|
645
|
+
health.exitTimeout(`no answer on ${cid}`);
|
|
608
646
|
}
|
|
609
647
|
// A server WITH long-poll just held us for waitS — loop right back. One that
|
|
610
648
|
// ignored `wait`, an error, or degraded mode returned fast: pace ourselves.
|
|
@@ -653,8 +691,11 @@ async function realtimeWait(cid, { timeout, interval }) {
|
|
|
653
691
|
// fetch + print + exit via the poller (one quick authoritative read)
|
|
654
692
|
await doWait(cid, { timeout: Math.max(10, Math.ceil((deadline - Date.now()) / 1000)), interval });
|
|
655
693
|
}
|
|
656
|
-
if
|
|
657
|
-
|
|
694
|
+
// Only exit-as-timeout if the REAL deadline genuinely passed. An EARLY
|
|
695
|
+
// 'deadline' (a spurious guard, a WS oddity) must degrade to polling for the
|
|
696
|
+
// remaining budget, NOT exit lying that the full timeout elapsed (#119).
|
|
697
|
+
if (outcome === 'deadline' && Date.now() >= deadline - 1500) {
|
|
698
|
+
health.exitTimeout(`no answer on ${cid}`);
|
|
658
699
|
}
|
|
659
700
|
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
660
701
|
return Math.max(1, Math.ceil((deadline - Date.now()) / 1000)); // remaining budget
|
|
@@ -696,6 +737,194 @@ async function fetchWhoami(base = BASE, token = TOKEN) {
|
|
|
696
737
|
return { res, data };
|
|
697
738
|
}
|
|
698
739
|
|
|
740
|
+
// #181 identity ownership: a STABLE, privacy-safe per-install fingerprint (a
|
|
741
|
+
// HASH, never raw hostname/PII) so the server can tell THIS install apart from a
|
|
742
|
+
// different agent that grabbed the same key. The label is the human-readable
|
|
743
|
+
// self-name (PIDGE_LABEL, else PIDGE_AGENT, else the hostname).
|
|
744
|
+
function agentFingerprint() {
|
|
745
|
+
const material = [ os.hostname(), os.userInfo().username || '', AGENT_ID, CONFIG_FILE ].join('|');
|
|
746
|
+
return 'fp_' + crypto.createHash('sha256').update(material).digest('hex').slice(0, 24);
|
|
747
|
+
}
|
|
748
|
+
function agentLabel() {
|
|
749
|
+
return (process.env.PIDGE_LABEL || AGENT_ID || os.hostname() || 'pidge-cli').slice(0, 80);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// #170 first-run notice: show the ack-after-work BREAKING-flip contract ONCE PER
|
|
753
|
+
// INSTALL (a stamp under the config dir), not every invocation — a turn-based
|
|
754
|
+
// agent runs a FRESH process per turn, so an in-process flag would shout every
|
|
755
|
+
// time. Best-effort: if the stamp can't be persisted (env-var-only install /
|
|
756
|
+
// read-only fs) the caller's per-process guard still shows it once per run.
|
|
757
|
+
const ACK_NOTICE_STAMP = path.join(CONFIG_DIR, '.ack_notice_seen');
|
|
758
|
+
function ackNoticeAlreadySeen() {
|
|
759
|
+
try { return fs.existsSync(ACK_NOTICE_STAMP); } catch { return false; }
|
|
760
|
+
}
|
|
761
|
+
function markAckNoticeSeen() {
|
|
762
|
+
try {
|
|
763
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
764
|
+
fs.writeFileSync(ACK_NOTICE_STAMP, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
765
|
+
} catch { /* best-effort — per-process guard covers it */ }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Shared by `doctor` AND `whoami` (#182/gotcha #9): narrate HONEST device reach —
|
|
769
|
+
// `deliverable` (push-enabled AND on the live APNs environment) can be lower than
|
|
770
|
+
// the headline pushable count. Returns true when reach is BROKEN: devices exist
|
|
771
|
+
// but NONE are deliverable (a send reaches nobody). doctor exits 2 on that.
|
|
772
|
+
function reportDeviceReach(data) {
|
|
773
|
+
const reach = data.device_reach;
|
|
774
|
+
if (!reach) return false;
|
|
775
|
+
console.error(`pidge: reach — ${reach.deliverable}/${reach.total} device(s) will actually receive a push (${reach.apns_environment} APNs)`);
|
|
776
|
+
if (reach.total > reach.deliverable)
|
|
777
|
+
console.error(`pidge: WARNING — ${reach.total - reach.deliverable} registered device(s) are UNREACHABLE (disabled, or on the wrong APNs environment): a send lands on ${reach.deliverable}, not ${reach.total} ("você pensa que alcança ${reach.total}, alcança ${reach.deliverable}").`);
|
|
778
|
+
return reach.total > 0 && reach.deliverable === 0;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Shared by `doctor` AND `whoami` (#181): SHOUT when a DIFFERENT install claimed
|
|
782
|
+
// this channel since we set up. Returns 'hard' (different fingerprint AND higher
|
|
783
|
+
// generation), 'soft' (we never claimed locally — informational), or null.
|
|
784
|
+
function reportClaimMismatch(data) {
|
|
785
|
+
if (!data.claim) return null;
|
|
786
|
+
const localGen = parseInt(FILE_ENV.PIDGE_CLAIM_GENERATION || '', 10);
|
|
787
|
+
const ourFp = FILE_ENV.PIDGE_FINGERPRINT || agentFingerprint();
|
|
788
|
+
const srvGen = data.claim.claim_generation;
|
|
789
|
+
const srvFp = data.claim.claimed_by_fingerprint;
|
|
790
|
+
if (srvFp && srvFp !== ourFp && Number.isFinite(localGen) && srvGen > localGen) {
|
|
791
|
+
console.error(`pidge: ⚠️ ANOTHER AGENT CLAIMED THIS CHANNEL — server generation ${srvGen} > yours ${localGen}, now owned by "${data.claim.claimed_by_label}". Your sends may go out as a DIFFERENT identity. If that's not intended, give THIS agent its own PIDGE_AGENT=<id> (isolated config) or PIDGE_TOKEN, then re-run setup.`);
|
|
792
|
+
return 'hard';
|
|
793
|
+
}
|
|
794
|
+
if (srvFp && srvFp !== ourFp && !Number.isFinite(localGen)) {
|
|
795
|
+
console.error(`pidge: note — this channel is owned by "${data.claim.claimed_by_label}" (generation ${srvGen}); THIS install hasn't claimed it. If you are its agent, run setup to claim ownership (so a future swap becomes detectable).`);
|
|
796
|
+
return 'soft';
|
|
797
|
+
}
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// POST /claim/ownership — stamp WHICH install wears this channel's key (#181), so
|
|
802
|
+
// a multi-agent machine can DETECT a silent key swap. Best-effort: a server that
|
|
803
|
+
// predates it 404s (skip silently); a network blip never breaks setup. Returns
|
|
804
|
+
// the server's claim block or null.
|
|
805
|
+
async function claimOwnership(base, token) {
|
|
806
|
+
try {
|
|
807
|
+
const res = await fetchT(`${base}/api/v1/claim/ownership`, {
|
|
808
|
+
method: 'POST',
|
|
809
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
810
|
+
body: JSON.stringify({ fingerprint: agentFingerprint(), label: agentLabel() }),
|
|
811
|
+
});
|
|
812
|
+
if (res.status !== 200) return null;
|
|
813
|
+
const data = await res.json().catch(() => ({}));
|
|
814
|
+
return data.claim || null;
|
|
815
|
+
} catch { return null; }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// #182 step 5: after onboarding, DECLARE how this agent operates so the human
|
|
819
|
+
// knows what to expect from this channel. ADVISORY — Pidge enforces nothing; it's
|
|
820
|
+
// metadata the human reads. The default is the common case (a turn-based agent:
|
|
821
|
+
// one-shot listen, no keep-alive); `--listen-mode always_on` flips it for a
|
|
822
|
+
// long-lived supervisor. Non-interactive by design (the safe default is narrated);
|
|
823
|
+
// best-effort — a 422/blip never breaks setup. Returns the declared mode or null.
|
|
824
|
+
async function declareOperatingContract(base, token, channelId) {
|
|
825
|
+
if (!channelId) return null;
|
|
826
|
+
const mode = v['listen-mode'];
|
|
827
|
+
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; }
|
|
831
|
+
try {
|
|
832
|
+
const res = await fetchT(`${base}/api/v1/channels/${channelId}`, {
|
|
833
|
+
method: 'PATCH',
|
|
834
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
835
|
+
body: JSON.stringify({ operating_contract: contract }),
|
|
836
|
+
});
|
|
837
|
+
if (res.status >= 200 && res.status < 300) {
|
|
838
|
+
const hint = mode ? '' : ' (default — pass --listen-mode always_on for a long-lived supervisor)';
|
|
839
|
+
console.error(`pidge: declared listen_mode=${contract.listen_mode}${hint} — ADVISORY, how you operate (the human sees it; Pidge enforces nothing). Change anytime: pidge contract set listen_mode=...`);
|
|
840
|
+
return contract.listen_mode;
|
|
841
|
+
}
|
|
842
|
+
console.error(`pidge: note — couldn't declare the operating_contract (${res.status}); set it later with \`pidge contract set listen_mode=turn_based\``);
|
|
843
|
+
} catch (e) {
|
|
844
|
+
console.error(`pidge: note — couldn't declare the operating_contract (network: ${e.message}); set it later with \`pidge contract set\``);
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// #182 the CLOSED allowlist (mirrors the server's OPERATING_CONTRACT_KEYS) — so
|
|
850
|
+
// `contract set` and `setup` reject an unknown key / bad value type LOCALLY (exit
|
|
851
|
+
// 1) before the round-trip, instead of leaning on the server's 422.
|
|
852
|
+
const OPERATING_CONTRACT_SPEC = {
|
|
853
|
+
keep_connection_alive: 'boolean',
|
|
854
|
+
mirror_in_origin_session: 'boolean',
|
|
855
|
+
listen_mode: ['turn_based', 'always_on'],
|
|
856
|
+
quiet_when_idle: 'boolean',
|
|
857
|
+
};
|
|
858
|
+
// Coerce + validate one operating_contract value against the allowlist. Returns
|
|
859
|
+
// the typed value, or throws an Error whose message the caller die()s with (exit 1).
|
|
860
|
+
function coerceContractValue(key, raw) {
|
|
861
|
+
const spec = OPERATING_CONTRACT_SPEC[key];
|
|
862
|
+
if (!spec) throw new Error(`unknown operating_contract key "${key}" (allowed: ${Object.keys(OPERATING_CONTRACT_SPEC).join(', ')})`);
|
|
863
|
+
if (spec === 'boolean') {
|
|
864
|
+
if (raw === true || raw === 'true') return true;
|
|
865
|
+
if (raw === false || raw === 'false') return false;
|
|
866
|
+
throw new Error(`operating_contract.${key} must be true or false`);
|
|
867
|
+
}
|
|
868
|
+
const value = String(raw);
|
|
869
|
+
if (!spec.includes(value)) throw new Error(`operating_contract.${key} must be one of: ${spec.join(', ')}`);
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// #182 operating_contract: DECLARE how you operate. ADVISORY, never policy —
|
|
874
|
+
// nothing derives urgency/ceiling from it and Pidge enforces nothing; you declare,
|
|
875
|
+
// the human registers their own expectation and SEES if you honor it.
|
|
876
|
+
// pidge contract show → print the channel's operating_contract
|
|
877
|
+
// pidge contract set key=value → PATCH it (key ∈ the closed allowlist above)
|
|
878
|
+
async function runContract() {
|
|
879
|
+
const sub = parsed.positionals[1];
|
|
880
|
+
if (sub !== 'show' && sub !== 'set' && sub !== undefined)
|
|
881
|
+
die('pidge: usage: pidge contract set <key>=<value> | pidge contract show', 1);
|
|
882
|
+
|
|
883
|
+
// For `set`: parse + validate the key/value LOCALLY (exit 1) BEFORE any network
|
|
884
|
+
// — an unknown key / bad type never reaches the server (the allowlist is closed
|
|
885
|
+
// and known client-side; the server would 422, but a local usage error is
|
|
886
|
+
// faster and clearer, and avoids a needless round-trip).
|
|
887
|
+
let key, value;
|
|
888
|
+
if (sub === 'set') {
|
|
889
|
+
const assignment = parsed.positionals[2];
|
|
890
|
+
if (!assignment || !assignment.includes('=')) die('pidge: usage: pidge contract set <key>=<value> (e.g. listen_mode=turn_based)', 1);
|
|
891
|
+
const eq = assignment.indexOf('=');
|
|
892
|
+
key = assignment.slice(0, eq);
|
|
893
|
+
const raw = assignment.slice(eq + 1);
|
|
894
|
+
try { value = coerceContractValue(key, raw); } catch (e) { die(`pidge: ${e.message}`, 1); }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
let who;
|
|
898
|
+
try { who = await fetchWhoami(); } catch (e) { die(`pidge: contract failed (network): ${e.message}`, 2); }
|
|
899
|
+
if (who.res.status !== 200) die(`pidge: contract: whoami failed (${who.res.status})`, 2);
|
|
900
|
+
const channelId = who.data.channel && who.data.channel.id;
|
|
901
|
+
|
|
902
|
+
if (sub === 'show' || sub === undefined) {
|
|
903
|
+
const oc = who.data.operating_contract || {};
|
|
904
|
+
console.log(JSON.stringify(oc, null, 2));
|
|
905
|
+
const keys = Object.keys(oc);
|
|
906
|
+
console.error(keys.length
|
|
907
|
+
? `pidge: operating_contract — ${keys.map((k) => `${k}=${JSON.stringify(oc[k].value)}${oc[k].locked ? ' (registered by your human)' : ''}`).join(', ')}`
|
|
908
|
+
: 'pidge: no operating_contract declared yet — set one with `pidge contract set listen_mode=turn_based`');
|
|
909
|
+
process.exit(0);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
let res, body;
|
|
913
|
+
try {
|
|
914
|
+
res = await fetch(`${BASE}/api/v1/channels/${channelId}`, {
|
|
915
|
+
method: 'PATCH', headers, body: JSON.stringify({ operating_contract: { [key]: value } }),
|
|
916
|
+
});
|
|
917
|
+
body = await res.text();
|
|
918
|
+
} catch (e) {
|
|
919
|
+
die(`pidge: contract set failed (network): ${e.message}`, 2);
|
|
920
|
+
}
|
|
921
|
+
checkManifestNews(res);
|
|
922
|
+
console.log(body);
|
|
923
|
+
if (!(res.status >= 200 && res.status < 300)) die(`pidge: contract set failed (${res.status}): ${body}`, 2);
|
|
924
|
+
console.error(`pidge: declared ${key}=${JSON.stringify(value)} (ADVISORY, never policy — the human sees if you honor it; Pidge enforces nothing)`);
|
|
925
|
+
process.exit(0);
|
|
926
|
+
}
|
|
927
|
+
|
|
699
928
|
// doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
|
|
700
929
|
// a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
|
|
701
930
|
async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
@@ -741,8 +970,19 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
|
741
970
|
console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
|
|
742
971
|
if (devices === 0)
|
|
743
972
|
console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
|
|
973
|
+
// #182 device-reach honesty (gotcha #9) + #181 ownership — shared with whoami.
|
|
974
|
+
const unreachable = reportDeviceReach(data);
|
|
975
|
+
reportClaimMismatch(data);
|
|
744
976
|
if (ON_SHARED_FILE)
|
|
745
977
|
console.error(`pidge doctor: WARNING — reading the SHARED file ${CONFIG_FILE}. If another agent runs on this machine, it reads the SAME key and you'll send as each other (the 2026-06-13 incident). Isolate: set PIDGE_AGENT=<id> at this agent's launch (config → ~/.config/pidge/agents/<id>/env) or give it its own PIDGE_TOKEN.`);
|
|
978
|
+
// #182: devices exist but 0 are deliverable ⇒ a send reaches NOBODY — BROKEN
|
|
979
|
+
// (exit 2). (0 devices total stays a warning above: a fresh setup before the
|
|
980
|
+
// app is installed isn't "broken".) The claim mismatch SHOUTS but stays exit 0
|
|
981
|
+
// — the warning is the contract (§4.6: the severity split is a judgment call).
|
|
982
|
+
if (unreachable) {
|
|
983
|
+
console.error('pidge doctor: BROKEN (exit 2) — devices exist but 0 are reachable (all disabled or on the wrong APNs environment): a send reaches nobody.');
|
|
984
|
+
process.exit(2);
|
|
985
|
+
}
|
|
746
986
|
console.error('pidge doctor: all good — try: pidge ask --template decision --title "Pidge funcionando?"');
|
|
747
987
|
console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version }));
|
|
748
988
|
process.exit(0);
|
|
@@ -794,6 +1034,12 @@ async function runSetup() {
|
|
|
794
1034
|
|
|
795
1035
|
const finalBase = (data.base_url || base).replace(/\/+$/, '');
|
|
796
1036
|
const channelName = data.channel && data.channel.name;
|
|
1037
|
+
const channelId = data.channel && data.channel.id;
|
|
1038
|
+
|
|
1039
|
+
// #182 step 5: DECLARE how this agent operates (operating_contract) right after
|
|
1040
|
+
// the claim succeeds — ADVISORY metadata, the same for --print and the file
|
|
1041
|
+
// path. Done here (before the branch) so both onboarding modes declare it.
|
|
1042
|
+
await declareOperatingContract(finalBase, data.key, channelId);
|
|
797
1043
|
|
|
798
1044
|
// --print: the pure per-agent path — emit the export lines (the HUMAN runs
|
|
799
1045
|
// this in THEIR terminal and pastes them into the agent's launcher). Stores
|
|
@@ -814,6 +1060,14 @@ async function runSetup() {
|
|
|
814
1060
|
fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
|
|
815
1061
|
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
|
|
816
1062
|
console.error(`pidge: canal "${channelName}" configurado — chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
|
|
1063
|
+
// #181: claim ownership of the channel for THIS install and record the
|
|
1064
|
+
// generation locally, so a later `pidge doctor` can DETECT a silent key swap
|
|
1065
|
+
// by a different agent (the v25 incident, now caught in code). Best-effort.
|
|
1066
|
+
const claim = await claimOwnership(finalBase, data.key);
|
|
1067
|
+
if (claim) {
|
|
1068
|
+
fs.appendFileSync(CONFIG_FILE, `PIDGE_CLAIM_GENERATION=${claim.claim_generation}\nPIDGE_FINGERPRINT=${agentFingerprint()}\n`, { mode: 0o600 });
|
|
1069
|
+
console.error(`pidge: ownership claimed as "${agentLabel()}" (generation ${claim.claim_generation}) — doctor WARNS if another agent takes this channel.`);
|
|
1070
|
+
}
|
|
817
1071
|
if (!AGENT_ID)
|
|
818
1072
|
console.error('pidge: este é o arquivo COMPARTILHADO (single-agent). Vai rodar 2+ agentes nesta máquina? Dê a cada um PIDGE_AGENT=<id> no launch (arquivo isolado por agente) — senão eles enviam como o mesmo canal.');
|
|
819
1073
|
await runDoctor(finalBase, data.key, CONFIG_FILE);
|
|
@@ -893,6 +1147,10 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
893
1147
|
if (res.status !== 200) die(`pidge: whoami failed (${res.status}): ${JSON.stringify(data)}`, 2);
|
|
894
1148
|
console.log(JSON.stringify(data, null, 2));
|
|
895
1149
|
console.error(`pidge: you are canal "${data.channel && data.channel.name}" · ${data.devices ?? '?'} device(s)`);
|
|
1150
|
+
// §5.2/§4.6: whoami MUST also report HONEST reach + SHOUT on a claim swap,
|
|
1151
|
+
// not just doctor — the same shared helpers (deliverable, ANOTHER AGENT…).
|
|
1152
|
+
reportDeviceReach(data);
|
|
1153
|
+
reportClaimMismatch(data);
|
|
896
1154
|
process.exit(0);
|
|
897
1155
|
break;
|
|
898
1156
|
}
|
|
@@ -971,6 +1229,39 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
971
1229
|
process.exit(2);
|
|
972
1230
|
break;
|
|
973
1231
|
}
|
|
1232
|
+
case 'ack': {
|
|
1233
|
+
// #170 read-receipt split: mark messages PROCESSED (green ✓✓) AFTER you've
|
|
1234
|
+
// durably handled them — `listen` only DELIVERS them now. --renew
|
|
1235
|
+
// (state=delivered) instead RENEWS the visibility-timeout lease, a
|
|
1236
|
+
// heartbeat for a long task so the reservation doesn't lapse and re-serve.
|
|
1237
|
+
const ackBody = {};
|
|
1238
|
+
if (v['up-to'] !== undefined && v.ids !== undefined)
|
|
1239
|
+
die('pidge: pass EITHER --up-to <id> OR --ids a,b, not both', 1);
|
|
1240
|
+
if (v['up-to'] !== undefined) ackBody.up_to = parseInt(v['up-to'], 10);
|
|
1241
|
+
else if (v.ids !== undefined) ackBody.ids = v.ids.split(',').map((s) => parseInt(s.trim(), 10)).filter(Number.isFinite);
|
|
1242
|
+
else die('pidge: usage: pidge ack --up-to <id> | --ids a,b [--renew]', 1);
|
|
1243
|
+
if (v.renew) ackBody.state = 'delivered';
|
|
1244
|
+
let res, raw;
|
|
1245
|
+
try {
|
|
1246
|
+
res = await fetch(`${BASE}/api/v1/messages/ack`, { method: 'POST', headers, body: JSON.stringify(ackBody) });
|
|
1247
|
+
raw = await res.text();
|
|
1248
|
+
} catch (e) {
|
|
1249
|
+
die(`pidge: ack failed (network): ${e.message}`, 2);
|
|
1250
|
+
}
|
|
1251
|
+
checkManifestNews(res);
|
|
1252
|
+
console.log(raw);
|
|
1253
|
+
if (!(res.status >= 200 && res.status < 300)) die(`pidge: ack failed (${res.status}): ${raw}`, 2);
|
|
1254
|
+
let adata = {};
|
|
1255
|
+
try { adata = JSON.parse(raw); } catch { /* leave {} */ }
|
|
1256
|
+
if (v.renew) console.error(`pidge: lease renewed on ${adata.renewed ?? 0} message(s) (still yours; ack again when done)`);
|
|
1257
|
+
else console.error(`pidge: processed ${adata.acked ?? 0} message(s) — green ✓✓ (the human sees "lida pelo agente")`);
|
|
1258
|
+
process.exit(0);
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
case 'contract': {
|
|
1262
|
+
await runContract();
|
|
1263
|
+
break;
|
|
1264
|
+
}
|
|
974
1265
|
case 'inbox': {
|
|
975
1266
|
// #83: what this channel sent — the list (default), the pending slice
|
|
976
1267
|
// (--pending = delivered + still unanswered) or the one-call summary
|
|
@@ -1021,6 +1312,14 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1021
1312
|
const timeout = num(v.timeout, 600);
|
|
1022
1313
|
let deadline = Date.now() + timeout * 1000;
|
|
1023
1314
|
const queueQs = v.all ? '?all=true' : '';
|
|
1315
|
+
// §2.6: --follow is SUPERVISOR-ONLY — warn LOUDLY at startup. A turn-based
|
|
1316
|
+
// agent that uses it traps its turn (the process keeps listening); the
|
|
1317
|
+
// default one-shot, looped from the supervisor, is what almost everyone wants.
|
|
1318
|
+
if (v.follow) {
|
|
1319
|
+
console.error('pidge: --follow keeps this process listening until --timeout (supervisor mode).');
|
|
1320
|
+
console.error('pidge: a TURN-BASED agent must NOT use --follow — it traps the turn. Use the');
|
|
1321
|
+
console.error('pidge: default one-shot (loop the command from your supervisor) instead.');
|
|
1322
|
+
}
|
|
1024
1323
|
// #157 P2 --follow: print+ack a batch and KEEP listening until the
|
|
1025
1324
|
// timeout — the supervisor loop without re-spawning a process per batch.
|
|
1026
1325
|
let gotAny = false;
|
|
@@ -1032,7 +1331,15 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1032
1331
|
return false;
|
|
1033
1332
|
};
|
|
1034
1333
|
|
|
1035
|
-
//
|
|
1334
|
+
// #170 read-receipt split: by DEFAULT a read message is DELIVERED (gray
|
|
1335
|
+
// ✓✓), NOT consumed — the agent ACKS after the work (`pidge ack`), and a
|
|
1336
|
+
// ~10-min server lease re-serves un-acked messages so a crash never loses
|
|
1337
|
+
// one. --ack-on-read restores the pre-0.9 immediate-consume.
|
|
1338
|
+
const ackOnRead = v['ack-on-read'];
|
|
1339
|
+
// Per-INSTALL notice (stamp file) + an in-process guard so a --follow run
|
|
1340
|
+
// doesn't repeat it across batches before the stamp write is observed.
|
|
1341
|
+
let ackNoticeShownThisProcess = false;
|
|
1342
|
+
// Print + (conditionally) ack — shared by the WS and polling paths.
|
|
1036
1343
|
const printAndAck = async (msgs) => {
|
|
1037
1344
|
console.log(JSON.stringify(msgs, null, 2));
|
|
1038
1345
|
// #131: narrate answers so the agent knows WHICH notification spoke back.
|
|
@@ -1044,21 +1351,28 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1044
1351
|
}
|
|
1045
1352
|
}
|
|
1046
1353
|
const upTo = Math.max(...msgs.map((m) => m.id));
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1354
|
+
if (ackOnRead) {
|
|
1355
|
+
try {
|
|
1356
|
+
// fetchT, not fetch: a wedged proxy stalling this ack would otherwise
|
|
1357
|
+
// pin the process forever (the WS drain path awaits printAndAck's exit
|
|
1358
|
+
// with no deadline) — messages are already printed, so a timeout here
|
|
1359
|
+
// just re-serves them next listen (at-least-once).
|
|
1360
|
+
const ack = await fetchT(`${BASE}/api/v1/messages/ack`, {
|
|
1361
|
+
method: 'POST', headers, body: JSON.stringify({ up_to: upTo }),
|
|
1362
|
+
});
|
|
1363
|
+
if (ack.status >= 200 && ack.status < 300) {
|
|
1364
|
+
console.error(`pidge: ${msgs.length} message(s) — acked on read (--ack-on-read); answer via notify, reuse thread_id when present`);
|
|
1365
|
+
} else {
|
|
1366
|
+
console.error(`pidge: WARNING — ack failed (${ack.status}); these messages will be re-served next listen`);
|
|
1367
|
+
}
|
|
1368
|
+
} catch (e) {
|
|
1369
|
+
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
1059
1370
|
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1371
|
+
} else if (!ackNoticeShownThisProcess && !ackNoticeAlreadySeen()) {
|
|
1372
|
+
ackNoticeShownThisProcess = true;
|
|
1373
|
+
markAckNoticeSeen(); // once per install (stamp); a fresh per-turn process won't re-shout
|
|
1374
|
+
// The version-gated BREAKING flip — LOUD on stderr the first time.
|
|
1375
|
+
console.error(`pidge: NEW in 0.9.x — ${msgs.length} message(s) DELIVERED (gray ✓✓), NOT done. ACK AFTER you handle them: \`pidge ack --up-to ${upTo}\` (a ~10-min lease re-serves un-acked messages, so a crash between "I have it" and "I'm done" never loses one). Use --ack-on-read for the old immediate-consume.`);
|
|
1062
1376
|
}
|
|
1063
1377
|
gotAny = true;
|
|
1064
1378
|
if (!v.follow) process.exit(0);
|
|
@@ -1114,9 +1428,11 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1114
1428
|
}));
|
|
1115
1429
|
}
|
|
1116
1430
|
const outcome = await Promise.race(sessions);
|
|
1117
|
-
|
|
1431
|
+
// Only a GENUINE deadline exits; an early/spurious 'deadline' or
|
|
1432
|
+
// 'ws-unavailable' degrades to polling below (never an early timeout lie).
|
|
1433
|
+
if (outcome === 'deadline' && Date.now() >= deadline - 1500) {
|
|
1118
1434
|
followEnd();
|
|
1119
|
-
health.exitTimeout(
|
|
1435
|
+
health.exitTimeout('no message from the human');
|
|
1120
1436
|
}
|
|
1121
1437
|
if (outcome === 'got-messages') {
|
|
1122
1438
|
await new Promise(() => {}); // printAndAck is in flight and exits the process
|
|
@@ -1149,7 +1465,7 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1149
1465
|
}
|
|
1150
1466
|
if (Date.now() >= deadline) {
|
|
1151
1467
|
followEnd();
|
|
1152
|
-
health.exitTimeout(
|
|
1468
|
+
health.exitTimeout('no message from the human');
|
|
1153
1469
|
}
|
|
1154
1470
|
const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
|
|
1155
1471
|
if (Date.now() - askedAt < 2000) {
|