pidge-cli 0.8.0 → 0.9.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 +14 -3
- package/bin/pidge.js +226 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,14 @@ 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
|
+
|
|
14
22
|
## Setup in one command (v0.8.0 — the claim flow)
|
|
15
23
|
|
|
16
24
|
```bash
|
|
@@ -96,11 +104,14 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
96
104
|
| `wait <correlation_id>` | Block on an already-sent notification until it's answered. |
|
|
97
105
|
| `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
|
|
98
106
|
| `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
|
-
| `
|
|
107
|
+
| `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). |
|
|
108
|
+
| `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. |
|
|
109
|
+
| `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`). A contract, never policy — the human can force it. |
|
|
110
|
+
| `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. |
|
|
111
|
+
| `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, **honest device reach**, channel ownership. Exit 0/2. |
|
|
102
112
|
| `whoami` | Which channel does this key speak for (JSON). |
|
|
103
113
|
| `skill install` | Write `.claude/skills/pidge/SKILL.md` generated from the live manifest — persistent Pidge knowledge for Claude Code agents; re-run to update. |
|
|
114
|
+
| `--version` | Print the CLI version. |
|
|
104
115
|
|
|
105
116
|
## Realtime (v0.6.0)
|
|
106
117
|
|
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,11 @@ 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
|
+
// Fix 2 (#170): read-receipt split — `ack` after the work; listen no longer consumes on read.
|
|
136
|
+
'up-to': { type: 'string' }, // ack: process messages up to this id
|
|
137
|
+
ids: { type: 'string' }, // ack: process this comma-list of ids
|
|
138
|
+
renew: { type: 'boolean' }, // ack: heartbeat the visibility-timeout lease (state=delivered)
|
|
139
|
+
'ack-on-read': { type: 'boolean' }, // listen: restore the pre-0.9 immediate-consume
|
|
127
140
|
};
|
|
128
141
|
|
|
129
142
|
const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
|
|
@@ -145,15 +158,24 @@ USAGE
|
|
|
145
158
|
pidge wait <correlation_id> [options] block on an already-sent notification
|
|
146
159
|
pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
|
|
147
160
|
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
|
-
|
|
161
|
+
pidge listen [--timeout N] [--all] [--ack-on-read] [--follow]
|
|
162
|
+
block until the human MESSAGES you from the app, print, exit (#48)
|
|
163
|
+
#170: a read message is DELIVERED (gray ✓✓), NOT done — ACK it
|
|
164
|
+
AFTER the work: pidge ack --up-to <id> (a ~10-min lease re-serves
|
|
165
|
+
un-acked messages, so a crash never loses one)
|
|
166
|
+
--ack-on-read = the old immediate-consume (ack on print)
|
|
167
|
+
--follow = KEEP listening until --timeout (supervisor-only)
|
|
168
|
+
--all (#131) = the SINGLE EAR: also hear notification ANSWERS
|
|
169
|
+
pidge ack --up-to <id> | --ids a,b [--renew]
|
|
170
|
+
mark messages PROCESSED (green ✓✓) after you handled them (#170);
|
|
171
|
+
--renew heartbeats the lease on a long task (state=delivered)
|
|
172
|
+
pidge contract set <key>=<value> | contract show
|
|
173
|
+
DECLARE how you operate (#182): keep_connection_alive,
|
|
174
|
+
mirror_in_origin_session, listen_mode=turn_based|always_on,
|
|
175
|
+
quiet_when_idle. A CONTRACT, never policy (the human can force it).
|
|
155
176
|
pidge skill install write .claude/skills/pidge/SKILL.md generated from the
|
|
156
177
|
live manifest (persistent Pidge knowledge for Claude Code)
|
|
178
|
+
pidge --version print the CLI version
|
|
157
179
|
pidge --help
|
|
158
180
|
|
|
159
181
|
REALTIME (#118)
|
|
@@ -267,7 +289,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
|
|
|
267
289
|
// The server advertises its manifest version on every response. When it's newer
|
|
268
290
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
269
291
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
270
|
-
const KNOWN_MANIFEST_VERSION =
|
|
292
|
+
const KNOWN_MANIFEST_VERSION = 27;
|
|
271
293
|
let newsWarned = false;
|
|
272
294
|
function checkManifestNews(res) {
|
|
273
295
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
@@ -290,6 +312,11 @@ function checkManifestNews(res) {
|
|
|
290
312
|
const DEGRADE_AFTER = 3;
|
|
291
313
|
// env override = a test/ops hook, not a documented knob
|
|
292
314
|
const DEGRADED_INTERVAL_S = parseInt(process.env.PIDGE_DEGRADED_INTERVAL || '45', 10);
|
|
315
|
+
// When the blocking session began — so a timeout reports the REAL elapsed
|
|
316
|
+
// wall-clock, never the configured deadline. The dogfooding bug (2026-06-14): a
|
|
317
|
+
// WS close 1006 made the CLI exit "timed out after 28800s" when only seconds had
|
|
318
|
+
// passed — the number lied. exitTimeout now reports SESSION_START → now.
|
|
319
|
+
const SESSION_START = Date.now();
|
|
293
320
|
const health = {
|
|
294
321
|
okEver: false, fails: 0, firstFailAt: 0, lastNoteAt: 0, degraded: false,
|
|
295
322
|
ok() {
|
|
@@ -309,8 +336,11 @@ const health = {
|
|
|
309
336
|
}
|
|
310
337
|
},
|
|
311
338
|
exitTimeout(message) {
|
|
312
|
-
|
|
313
|
-
|
|
339
|
+
// REAL elapsed wall-clock — never the configured deadline (the 2026-06-14
|
|
340
|
+
// "timed out after 28800s" lie). If only seconds passed, the number says so.
|
|
341
|
+
const elapsed = Math.round((Date.now() - SESSION_START) / 1000);
|
|
342
|
+
if (this.okEver) { console.error(`pidge: ${message} after ${elapsed}s (= 'no answer yet', not a failure)`); process.exit(3); }
|
|
343
|
+
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
344
|
process.exit(4);
|
|
315
345
|
},
|
|
316
346
|
};
|
|
@@ -397,7 +427,9 @@ async function cableSession({ channel, deadline, onUp, onFrame }) {
|
|
|
397
427
|
wsFails++;
|
|
398
428
|
const MAX_WS_FAILS = 4; // then fall back to polling for the rest of the session
|
|
399
429
|
if (wsFails >= MAX_WS_FAILS) return 'ws-unavailable';
|
|
400
|
-
|
|
430
|
+
// env override = a test/ops hook (keeps the forced-1006 degrade test fast)
|
|
431
|
+
const base = parseInt(process.env.PIDGE_WS_BACKOFF_MS || '2000', 10) || 2000;
|
|
432
|
+
const backoff = Math.min(base * wsFails, base * 5);
|
|
401
433
|
console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} — reconnecting in ${Math.round(backoff / 1000)}s (attempt ${wsFails}/${MAX_WS_FAILS})`);
|
|
402
434
|
await sleep(backoff);
|
|
403
435
|
}
|
|
@@ -604,7 +636,7 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
604
636
|
}
|
|
605
637
|
|
|
606
638
|
if (Date.now() >= deadline) {
|
|
607
|
-
health.exitTimeout(`
|
|
639
|
+
health.exitTimeout(`no answer on ${cid}`);
|
|
608
640
|
}
|
|
609
641
|
// A server WITH long-poll just held us for waitS — loop right back. One that
|
|
610
642
|
// ignored `wait`, an error, or degraded mode returned fast: pace ourselves.
|
|
@@ -653,8 +685,11 @@ async function realtimeWait(cid, { timeout, interval }) {
|
|
|
653
685
|
// fetch + print + exit via the poller (one quick authoritative read)
|
|
654
686
|
await doWait(cid, { timeout: Math.max(10, Math.ceil((deadline - Date.now()) / 1000)), interval });
|
|
655
687
|
}
|
|
656
|
-
if
|
|
657
|
-
|
|
688
|
+
// Only exit-as-timeout if the REAL deadline genuinely passed. An EARLY
|
|
689
|
+
// 'deadline' (a spurious guard, a WS oddity) must degrade to polling for the
|
|
690
|
+
// remaining budget, NOT exit lying that the full timeout elapsed (#119).
|
|
691
|
+
if (outcome === 'deadline' && Date.now() >= deadline - 1500) {
|
|
692
|
+
health.exitTimeout(`no answer on ${cid}`);
|
|
658
693
|
}
|
|
659
694
|
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
660
695
|
return Math.max(1, Math.ceil((deadline - Date.now()) / 1000)); // remaining budget
|
|
@@ -696,10 +731,91 @@ async function fetchWhoami(base = BASE, token = TOKEN) {
|
|
|
696
731
|
return { res, data };
|
|
697
732
|
}
|
|
698
733
|
|
|
734
|
+
// #181 identity ownership: a STABLE, privacy-safe per-install fingerprint (a
|
|
735
|
+
// HASH, never raw hostname/PII) so the server can tell THIS install apart from a
|
|
736
|
+
// different agent that grabbed the same key. The label is the human-readable
|
|
737
|
+
// self-name (PIDGE_LABEL, else PIDGE_AGENT, else the hostname).
|
|
738
|
+
function agentFingerprint() {
|
|
739
|
+
const material = [ os.hostname(), os.userInfo().username || '', AGENT_ID, CONFIG_FILE ].join('|');
|
|
740
|
+
return 'fp_' + crypto.createHash('sha256').update(material).digest('hex').slice(0, 24);
|
|
741
|
+
}
|
|
742
|
+
function agentLabel() {
|
|
743
|
+
return (process.env.PIDGE_LABEL || AGENT_ID || os.hostname() || 'pidge-cli').slice(0, 80);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// POST /claim/ownership — stamp WHICH install wears this channel's key (#181), so
|
|
747
|
+
// a multi-agent machine can DETECT a silent key swap. Best-effort: a server that
|
|
748
|
+
// predates it 404s (skip silently); a network blip never breaks setup. Returns
|
|
749
|
+
// the server's claim block or null.
|
|
750
|
+
async function claimOwnership(base, token) {
|
|
751
|
+
try {
|
|
752
|
+
const res = await fetchT(`${base}/api/v1/claim/ownership`, {
|
|
753
|
+
method: 'POST',
|
|
754
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
755
|
+
body: JSON.stringify({ fingerprint: agentFingerprint(), label: agentLabel() }),
|
|
756
|
+
});
|
|
757
|
+
if (res.status !== 200) return null;
|
|
758
|
+
const data = await res.json().catch(() => ({}));
|
|
759
|
+
return data.claim || null;
|
|
760
|
+
} catch { return null; }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// #182 operating_contract: DECLARE how you operate (a CONTRACT, never policy —
|
|
764
|
+
// nothing derives urgency/ceiling from it; the human can force/lock it).
|
|
765
|
+
// pidge contract show → print the channel's operating_contract
|
|
766
|
+
// pidge contract set key=value → PATCH it (key ∈ the server's closed allowlist:
|
|
767
|
+
// keep_connection_alive, mirror_in_origin_session,
|
|
768
|
+
// listen_mode=turn_based|always_on, quiet_when_idle)
|
|
769
|
+
async function runContract() {
|
|
770
|
+
const sub = parsed.positionals[1];
|
|
771
|
+
let who;
|
|
772
|
+
try { who = await fetchWhoami(); } catch (e) { die(`pidge: contract failed (network): ${e.message}`, 2); }
|
|
773
|
+
if (who.res.status !== 200) die(`pidge: contract: whoami failed (${who.res.status})`, 2);
|
|
774
|
+
const channelId = who.data.channel && who.data.channel.id;
|
|
775
|
+
|
|
776
|
+
if (sub === 'show' || sub === undefined) {
|
|
777
|
+
const oc = who.data.operating_contract || {};
|
|
778
|
+
console.log(JSON.stringify(oc, null, 2));
|
|
779
|
+
const keys = Object.keys(oc);
|
|
780
|
+
console.error(keys.length
|
|
781
|
+
? `pidge: operating_contract — ${keys.map((k) => `${k}=${JSON.stringify(oc[k].value)}${oc[k].locked ? ' (locked by human)' : ''}`).join(', ')}`
|
|
782
|
+
: 'pidge: no operating_contract declared yet — set one with `pidge contract set listen_mode=turn_based`');
|
|
783
|
+
process.exit(0);
|
|
784
|
+
}
|
|
785
|
+
if (sub !== 'set') die('pidge: usage: pidge contract set <key>=<value> | pidge contract show', 1);
|
|
786
|
+
|
|
787
|
+
const assignment = parsed.positionals[2];
|
|
788
|
+
if (!assignment || !assignment.includes('=')) die('pidge: usage: pidge contract set <key>=<value> (e.g. listen_mode=turn_based)', 1);
|
|
789
|
+
const eq = assignment.indexOf('=');
|
|
790
|
+
const key = assignment.slice(0, eq);
|
|
791
|
+
const raw = assignment.slice(eq + 1);
|
|
792
|
+
// true/false → bool; everything else passes as a string (the server validates
|
|
793
|
+
// the allowlist + types and 422s self-describingly).
|
|
794
|
+
const value = raw === 'true' ? true : raw === 'false' ? false : raw;
|
|
795
|
+
|
|
796
|
+
let res, body;
|
|
797
|
+
try {
|
|
798
|
+
res = await fetch(`${BASE}/api/v1/channels/${channelId}`, {
|
|
799
|
+
method: 'PATCH', headers, body: JSON.stringify({ operating_contract: { [key]: value } }),
|
|
800
|
+
});
|
|
801
|
+
body = await res.text();
|
|
802
|
+
} catch (e) {
|
|
803
|
+
die(`pidge: contract set failed (network): ${e.message}`, 2);
|
|
804
|
+
}
|
|
805
|
+
checkManifestNews(res);
|
|
806
|
+
console.log(body);
|
|
807
|
+
if (!(res.status >= 200 && res.status < 300)) die(`pidge: contract set failed (${res.status}): ${body}`, 2);
|
|
808
|
+
console.error(`pidge: declared ${key}=${JSON.stringify(value)} (a CONTRACT, never policy — the human can force/lock it in the app)`);
|
|
809
|
+
process.exit(0);
|
|
810
|
+
}
|
|
811
|
+
|
|
699
812
|
// doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
|
|
700
813
|
// a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
|
|
701
|
-
async function runDoctor(base = BASE, token = TOKEN) {
|
|
702
|
-
|
|
814
|
+
async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
815
|
+
// sourceLabel is passed by setup (it knows exactly where the key went —
|
|
816
|
+
// a per-agent file, the shared file, or NOWHERE for --print); the bare
|
|
817
|
+
// `doctor` command computes it from the env/file precedence.
|
|
818
|
+
const source = sourceLabel || (token === TOKEN ? tokenSource() : CONFIG_FILE);
|
|
703
819
|
if (!token) {
|
|
704
820
|
console.error('pidge doctor: NO TOKEN — set PIDGE_TOKEN, or onboard with `pidge setup --claim <code>` (the human copies the code from the Pidge app)');
|
|
705
821
|
process.exit(2);
|
|
@@ -738,6 +854,27 @@ async function runDoctor(base = BASE, token = TOKEN) {
|
|
|
738
854
|
console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
|
|
739
855
|
if (devices === 0)
|
|
740
856
|
console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
|
|
857
|
+
// #182 device-reach honesty (gotcha #9): pushable ≠ deliverable. A prod push
|
|
858
|
+
// skips a sandbox/old device, so the agent reaches FEWER than it thinks.
|
|
859
|
+
const reach = data.device_reach;
|
|
860
|
+
if (reach) {
|
|
861
|
+
console.error(`pidge doctor: reach — ${reach.deliverable}/${reach.total} device(s) will actually receive a push (${reach.apns_environment} APNs)`);
|
|
862
|
+
if (reach.total > reach.deliverable)
|
|
863
|
+
console.error(`pidge doctor: 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}.`);
|
|
864
|
+
}
|
|
865
|
+
// #181 ownership: did a DIFFERENT install grab this channel since we claimed?
|
|
866
|
+
// Compare the server's generation/fingerprint to what setup stored locally.
|
|
867
|
+
if (data.claim) {
|
|
868
|
+
const localGen = parseInt(FILE_ENV.PIDGE_CLAIM_GENERATION || '', 10);
|
|
869
|
+
const ourFp = FILE_ENV.PIDGE_FINGERPRINT || agentFingerprint();
|
|
870
|
+
const srvGen = data.claim.claim_generation;
|
|
871
|
+
const srvFp = data.claim.claimed_by_fingerprint;
|
|
872
|
+
if (srvFp && srvFp !== ourFp && Number.isFinite(localGen) && srvGen > localGen) {
|
|
873
|
+
console.error(`pidge doctor: ⚠️ 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.`);
|
|
874
|
+
} else if (srvFp && srvFp !== ourFp && !Number.isFinite(localGen)) {
|
|
875
|
+
console.error(`pidge doctor: 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).`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
741
878
|
if (ON_SHARED_FILE)
|
|
742
879
|
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.`);
|
|
743
880
|
console.error('pidge doctor: all good — try: pidge ask --template decision --title "Pidge funcionando?"');
|
|
@@ -801,7 +938,7 @@ async function runSetup() {
|
|
|
801
938
|
console.log(`export PIDGE_URL=${finalBase}`);
|
|
802
939
|
console.log(`export PIDGE_TOKEN=${data.key}`);
|
|
803
940
|
console.error(`pidge: canal "${channelName}" — modo POR-AGENTE (nada gravado em disco). Cole as duas linhas no ambiente de lançamento DESTE agente (systemd/launcher/cron/profile). Cada agente tem a SUA chave; perdeu, é só pegar outro código no app e re-rodar (a chave do canal é a MESMA). NÃO rode --print de dentro de um agente — a chave apareceria no contexto dele.`);
|
|
804
|
-
await runDoctor(finalBase, data.key);
|
|
941
|
+
await runDoctor(finalBase, data.key, 'fresh claim (per-agent env — not stored on disk)');
|
|
805
942
|
return;
|
|
806
943
|
}
|
|
807
944
|
|
|
@@ -811,9 +948,17 @@ async function runSetup() {
|
|
|
811
948
|
fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
|
|
812
949
|
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
|
|
813
950
|
console.error(`pidge: canal "${channelName}" configurado — chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
|
|
951
|
+
// #181: claim ownership of the channel for THIS install and record the
|
|
952
|
+
// generation locally, so a later `pidge doctor` can DETECT a silent key swap
|
|
953
|
+
// by a different agent (the v25 incident, now caught in code). Best-effort.
|
|
954
|
+
const claim = await claimOwnership(finalBase, data.key);
|
|
955
|
+
if (claim) {
|
|
956
|
+
fs.appendFileSync(CONFIG_FILE, `PIDGE_CLAIM_GENERATION=${claim.claim_generation}\nPIDGE_FINGERPRINT=${agentFingerprint()}\n`, { mode: 0o600 });
|
|
957
|
+
console.error(`pidge: ownership claimed as "${agentLabel()}" (generation ${claim.claim_generation}) — doctor WARNS if another agent takes this channel.`);
|
|
958
|
+
}
|
|
814
959
|
if (!AGENT_ID)
|
|
815
960
|
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.');
|
|
816
|
-
await runDoctor(finalBase, data.key);
|
|
961
|
+
await runDoctor(finalBase, data.key, CONFIG_FILE);
|
|
817
962
|
}
|
|
818
963
|
|
|
819
964
|
// skill install (#110e): persistent Pidge knowledge for Claude Code agents —
|
|
@@ -968,6 +1113,37 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
968
1113
|
process.exit(2);
|
|
969
1114
|
break;
|
|
970
1115
|
}
|
|
1116
|
+
case 'ack': {
|
|
1117
|
+
// #170 read-receipt split: mark messages PROCESSED (green ✓✓) AFTER you've
|
|
1118
|
+
// durably handled them — `listen` only DELIVERS them now. --renew
|
|
1119
|
+
// (state=delivered) instead RENEWS the visibility-timeout lease, a
|
|
1120
|
+
// heartbeat for a long task so the reservation doesn't lapse and re-serve.
|
|
1121
|
+
const ackBody = {};
|
|
1122
|
+
if (v['up-to'] !== undefined) ackBody.up_to = parseInt(v['up-to'], 10);
|
|
1123
|
+
else if (v.ids !== undefined) ackBody.ids = v.ids.split(',').map((s) => parseInt(s.trim(), 10)).filter(Number.isFinite);
|
|
1124
|
+
else die('pidge: usage: pidge ack --up-to <id> | --ids a,b [--renew]', 1);
|
|
1125
|
+
if (v.renew) ackBody.state = 'delivered';
|
|
1126
|
+
let res, raw;
|
|
1127
|
+
try {
|
|
1128
|
+
res = await fetch(`${BASE}/api/v1/messages/ack`, { method: 'POST', headers, body: JSON.stringify(ackBody) });
|
|
1129
|
+
raw = await res.text();
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
die(`pidge: ack failed (network): ${e.message}`, 2);
|
|
1132
|
+
}
|
|
1133
|
+
checkManifestNews(res);
|
|
1134
|
+
console.log(raw);
|
|
1135
|
+
if (!(res.status >= 200 && res.status < 300)) die(`pidge: ack failed (${res.status}): ${raw}`, 2);
|
|
1136
|
+
let adata = {};
|
|
1137
|
+
try { adata = JSON.parse(raw); } catch { /* leave {} */ }
|
|
1138
|
+
if (v.renew) console.error(`pidge: lease renewed on ${adata.renewed ?? 0} message(s) (still yours; ack again when done)`);
|
|
1139
|
+
else console.error(`pidge: processed ${adata.acked ?? 0} message(s) — green ✓✓ (the human sees "lida pelo agente")`);
|
|
1140
|
+
process.exit(0);
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
case 'contract': {
|
|
1144
|
+
await runContract();
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
971
1147
|
case 'inbox': {
|
|
972
1148
|
// #83: what this channel sent — the list (default), the pending slice
|
|
973
1149
|
// (--pending = delivered + still unanswered) or the one-call summary
|
|
@@ -1029,7 +1205,13 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1029
1205
|
return false;
|
|
1030
1206
|
};
|
|
1031
1207
|
|
|
1032
|
-
//
|
|
1208
|
+
// #170 read-receipt split: by DEFAULT a read message is DELIVERED (gray
|
|
1209
|
+
// ✓✓), NOT consumed — the agent ACKS after the work (`pidge ack`), and a
|
|
1210
|
+
// ~10-min server lease re-serves un-acked messages so a crash never loses
|
|
1211
|
+
// one. --ack-on-read restores the pre-0.9 immediate-consume.
|
|
1212
|
+
const ackOnRead = v['ack-on-read'];
|
|
1213
|
+
let ackNoticeShown = false;
|
|
1214
|
+
// Print + (conditionally) ack — shared by the WS and polling paths.
|
|
1033
1215
|
const printAndAck = async (msgs) => {
|
|
1034
1216
|
console.log(JSON.stringify(msgs, null, 2));
|
|
1035
1217
|
// #131: narrate answers so the agent knows WHICH notification spoke back.
|
|
@@ -1041,21 +1223,27 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1041
1223
|
}
|
|
1042
1224
|
}
|
|
1043
1225
|
const upTo = Math.max(...msgs.map((m) => m.id));
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1226
|
+
if (ackOnRead) {
|
|
1227
|
+
try {
|
|
1228
|
+
// fetchT, not fetch: a wedged proxy stalling this ack would otherwise
|
|
1229
|
+
// pin the process forever (the WS drain path awaits printAndAck's exit
|
|
1230
|
+
// with no deadline) — messages are already printed, so a timeout here
|
|
1231
|
+
// just re-serves them next listen (at-least-once).
|
|
1232
|
+
const ack = await fetchT(`${BASE}/api/v1/messages/ack`, {
|
|
1233
|
+
method: 'POST', headers, body: JSON.stringify({ up_to: upTo }),
|
|
1234
|
+
});
|
|
1235
|
+
if (ack.status >= 200 && ack.status < 300) {
|
|
1236
|
+
console.error(`pidge: ${msgs.length} message(s) — acked on read (--ack-on-read); answer via notify, reuse thread_id when present`);
|
|
1237
|
+
} else {
|
|
1238
|
+
console.error(`pidge: WARNING — ack failed (${ack.status}); these messages will be re-served next listen`);
|
|
1239
|
+
}
|
|
1240
|
+
} catch (e) {
|
|
1241
|
+
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
1056
1242
|
}
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1243
|
+
} else if (!ackNoticeShown) {
|
|
1244
|
+
ackNoticeShown = true;
|
|
1245
|
+
// The version-gated BREAKING flip — LOUD on stderr the first time.
|
|
1246
|
+
console.error(`pidge: #170 — ${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.`);
|
|
1059
1247
|
}
|
|
1060
1248
|
gotAny = true;
|
|
1061
1249
|
if (!v.follow) process.exit(0);
|
|
@@ -1111,9 +1299,11 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1111
1299
|
}));
|
|
1112
1300
|
}
|
|
1113
1301
|
const outcome = await Promise.race(sessions);
|
|
1114
|
-
|
|
1302
|
+
// Only a GENUINE deadline exits; an early/spurious 'deadline' or
|
|
1303
|
+
// 'ws-unavailable' degrades to polling below (never an early timeout lie).
|
|
1304
|
+
if (outcome === 'deadline' && Date.now() >= deadline - 1500) {
|
|
1115
1305
|
followEnd();
|
|
1116
|
-
health.exitTimeout(
|
|
1306
|
+
health.exitTimeout('no message from the human');
|
|
1117
1307
|
}
|
|
1118
1308
|
if (outcome === 'got-messages') {
|
|
1119
1309
|
await new Promise(() => {}); // printAndAck is in flight and exits the process
|
|
@@ -1146,7 +1336,7 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1146
1336
|
}
|
|
1147
1337
|
if (Date.now() >= deadline) {
|
|
1148
1338
|
followEnd();
|
|
1149
|
-
health.exitTimeout(
|
|
1339
|
+
health.exitTimeout('no message from the human');
|
|
1150
1340
|
}
|
|
1151
1341
|
const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
|
|
1152
1342
|
if (Date.now() - askedAt < 2000) {
|