pidge-cli 0.9.0 → 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 +12 -2
- package/bin/pidge.js +175 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,16 @@ then gets the answer as JSON — no webhook, no polling loop to write.
|
|
|
18
18
|
> same deadline instead of exiting early, and timeouts report the **real** elapsed time.
|
|
19
19
|
> New: `ack`, `contract`, `--version`; `setup` claims channel ownership and `doctor`
|
|
20
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.
|
|
21
31
|
|
|
22
32
|
## Setup in one command (v0.8.0 — the claim flow)
|
|
23
33
|
|
|
@@ -106,8 +116,8 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
106
116
|
| `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
|
|
107
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). |
|
|
108
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. |
|
|
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`).
|
|
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. |
|
|
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). |
|
|
111
121
|
| `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, **honest device reach**, channel ownership. Exit 0/2. |
|
|
112
122
|
| `whoami` | Which channel does this key speak for (JSON). |
|
|
113
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. |
|
package/bin/pidge.js
CHANGED
|
@@ -132,6 +132,7 @@ const OPTIONS = {
|
|
|
132
132
|
follow: { type: 'boolean' },
|
|
133
133
|
force: { type: 'boolean' }, // setup: overwrite a config owned by ANOTHER channel
|
|
134
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)
|
|
135
136
|
// Fix 2 (#170): read-receipt split — `ack` after the work; listen no longer consumes on read.
|
|
136
137
|
'up-to': { type: 'string' }, // ack: process messages up to this id
|
|
137
138
|
ids: { type: 'string' }, // ack: process this comma-list of ids
|
|
@@ -150,6 +151,8 @@ USAGE
|
|
|
150
151
|
(you run it in YOUR terminal; paste into the
|
|
151
152
|
agent's launcher — never run --print as an agent)
|
|
152
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)
|
|
153
156
|
pidge doctor validate the setup WITHOUT exposing secrets:
|
|
154
157
|
env source, server, key, "canal X · N devices"
|
|
155
158
|
pidge whoami which channel does this key speak for (JSON)
|
|
@@ -289,7 +292,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
|
|
|
289
292
|
// The server advertises its manifest version on every response. When it's newer
|
|
290
293
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
291
294
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
292
|
-
const KNOWN_MANIFEST_VERSION =
|
|
295
|
+
const KNOWN_MANIFEST_VERSION = 28;
|
|
293
296
|
let newsWarned = false;
|
|
294
297
|
function checkManifestNews(res) {
|
|
295
298
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
@@ -315,8 +318,11 @@ const DEGRADED_INTERVAL_S = parseInt(process.env.PIDGE_DEGRADED_INTERVAL || '45'
|
|
|
315
318
|
// When the blocking session began — so a timeout reports the REAL elapsed
|
|
316
319
|
// wall-clock, never the configured deadline. The dogfooding bug (2026-06-14): a
|
|
317
320
|
// WS close 1006 made the CLI exit "timed out after 28800s" when only seconds had
|
|
318
|
-
// passed — the number lied. exitTimeout now reports
|
|
319
|
-
|
|
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();
|
|
320
326
|
const health = {
|
|
321
327
|
okEver: false, fails: 0, firstFailAt: 0, lastNoteAt: 0, degraded: false,
|
|
322
328
|
ok() {
|
|
@@ -338,7 +344,7 @@ const health = {
|
|
|
338
344
|
exitTimeout(message) {
|
|
339
345
|
// REAL elapsed wall-clock — never the configured deadline (the 2026-06-14
|
|
340
346
|
// "timed out after 28800s" lie). If only seconds passed, the number says so.
|
|
341
|
-
const elapsed = Math.round((
|
|
347
|
+
const elapsed = Math.round((performance.now() - SESSION_START_MONO) / 1000);
|
|
342
348
|
if (this.okEver) { console.error(`pidge: ${message} after ${elapsed}s (= 'no answer yet', not a failure)`); process.exit(3); }
|
|
343
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.`);
|
|
344
350
|
process.exit(4);
|
|
@@ -743,6 +749,55 @@ function agentLabel() {
|
|
|
743
749
|
return (process.env.PIDGE_LABEL || AGENT_ID || os.hostname() || 'pidge-cli').slice(0, 80);
|
|
744
750
|
}
|
|
745
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
|
+
|
|
746
801
|
// POST /claim/ownership — stamp WHICH install wears this channel's key (#181), so
|
|
747
802
|
// a multi-agent machine can DETECT a silent key swap. Best-effort: a server that
|
|
748
803
|
// predates it 404s (skip silently); a network blip never breaks setup. Returns
|
|
@@ -760,14 +815,85 @@ async function claimOwnership(base, token) {
|
|
|
760
815
|
} catch { return null; }
|
|
761
816
|
}
|
|
762
817
|
|
|
763
|
-
// #182
|
|
764
|
-
//
|
|
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.
|
|
765
876
|
// pidge contract show → print the channel's operating_contract
|
|
766
|
-
// pidge contract set key=value → PATCH it (key ∈ the
|
|
767
|
-
// keep_connection_alive, mirror_in_origin_session,
|
|
768
|
-
// listen_mode=turn_based|always_on, quiet_when_idle)
|
|
877
|
+
// pidge contract set key=value → PATCH it (key ∈ the closed allowlist above)
|
|
769
878
|
async function runContract() {
|
|
770
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
|
+
|
|
771
897
|
let who;
|
|
772
898
|
try { who = await fetchWhoami(); } catch (e) { die(`pidge: contract failed (network): ${e.message}`, 2); }
|
|
773
899
|
if (who.res.status !== 200) die(`pidge: contract: whoami failed (${who.res.status})`, 2);
|
|
@@ -778,20 +904,10 @@ async function runContract() {
|
|
|
778
904
|
console.log(JSON.stringify(oc, null, 2));
|
|
779
905
|
const keys = Object.keys(oc);
|
|
780
906
|
console.error(keys.length
|
|
781
|
-
? `pidge: operating_contract — ${keys.map((k) => `${k}=${JSON.stringify(oc[k].value)}${oc[k].locked ? ' (
|
|
907
|
+
? `pidge: operating_contract — ${keys.map((k) => `${k}=${JSON.stringify(oc[k].value)}${oc[k].locked ? ' (registered by your human)' : ''}`).join(', ')}`
|
|
782
908
|
: 'pidge: no operating_contract declared yet — set one with `pidge contract set listen_mode=turn_based`');
|
|
783
909
|
process.exit(0);
|
|
784
910
|
}
|
|
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
911
|
|
|
796
912
|
let res, body;
|
|
797
913
|
try {
|
|
@@ -805,7 +921,7 @@ async function runContract() {
|
|
|
805
921
|
checkManifestNews(res);
|
|
806
922
|
console.log(body);
|
|
807
923
|
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)} (
|
|
924
|
+
console.error(`pidge: declared ${key}=${JSON.stringify(value)} (ADVISORY, never policy — the human sees if you honor it; Pidge enforces nothing)`);
|
|
809
925
|
process.exit(0);
|
|
810
926
|
}
|
|
811
927
|
|
|
@@ -854,29 +970,19 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
|
854
970
|
console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
|
|
855
971
|
if (devices === 0)
|
|
856
972
|
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)
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
}
|
|
973
|
+
// #182 device-reach honesty (gotcha #9) + #181 ownership — shared with whoami.
|
|
974
|
+
const unreachable = reportDeviceReach(data);
|
|
975
|
+
reportClaimMismatch(data);
|
|
878
976
|
if (ON_SHARED_FILE)
|
|
879
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
|
+
}
|
|
880
986
|
console.error('pidge doctor: all good — try: pidge ask --template decision --title "Pidge funcionando?"');
|
|
881
987
|
console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version }));
|
|
882
988
|
process.exit(0);
|
|
@@ -928,6 +1034,12 @@ async function runSetup() {
|
|
|
928
1034
|
|
|
929
1035
|
const finalBase = (data.base_url || base).replace(/\/+$/, '');
|
|
930
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);
|
|
931
1043
|
|
|
932
1044
|
// --print: the pure per-agent path — emit the export lines (the HUMAN runs
|
|
933
1045
|
// this in THEIR terminal and pastes them into the agent's launcher). Stores
|
|
@@ -1035,6 +1147,10 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1035
1147
|
if (res.status !== 200) die(`pidge: whoami failed (${res.status}): ${JSON.stringify(data)}`, 2);
|
|
1036
1148
|
console.log(JSON.stringify(data, null, 2));
|
|
1037
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);
|
|
1038
1154
|
process.exit(0);
|
|
1039
1155
|
break;
|
|
1040
1156
|
}
|
|
@@ -1119,6 +1235,8 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1119
1235
|
// (state=delivered) instead RENEWS the visibility-timeout lease, a
|
|
1120
1236
|
// heartbeat for a long task so the reservation doesn't lapse and re-serve.
|
|
1121
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);
|
|
1122
1240
|
if (v['up-to'] !== undefined) ackBody.up_to = parseInt(v['up-to'], 10);
|
|
1123
1241
|
else if (v.ids !== undefined) ackBody.ids = v.ids.split(',').map((s) => parseInt(s.trim(), 10)).filter(Number.isFinite);
|
|
1124
1242
|
else die('pidge: usage: pidge ack --up-to <id> | --ids a,b [--renew]', 1);
|
|
@@ -1194,6 +1312,14 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1194
1312
|
const timeout = num(v.timeout, 600);
|
|
1195
1313
|
let deadline = Date.now() + timeout * 1000;
|
|
1196
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
|
+
}
|
|
1197
1323
|
// #157 P2 --follow: print+ack a batch and KEEP listening until the
|
|
1198
1324
|
// timeout — the supervisor loop without re-spawning a process per batch.
|
|
1199
1325
|
let gotAny = false;
|
|
@@ -1210,7 +1336,9 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1210
1336
|
// ~10-min server lease re-serves un-acked messages so a crash never loses
|
|
1211
1337
|
// one. --ack-on-read restores the pre-0.9 immediate-consume.
|
|
1212
1338
|
const ackOnRead = v['ack-on-read'];
|
|
1213
|
-
|
|
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;
|
|
1214
1342
|
// Print + (conditionally) ack — shared by the WS and polling paths.
|
|
1215
1343
|
const printAndAck = async (msgs) => {
|
|
1216
1344
|
console.log(JSON.stringify(msgs, null, 2));
|
|
@@ -1240,10 +1368,11 @@ ${notes.map((n) => `- ${n}`).join('\n')}
|
|
|
1240
1368
|
} catch (e) {
|
|
1241
1369
|
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
1242
1370
|
}
|
|
1243
|
-
} else if (!
|
|
1244
|
-
|
|
1371
|
+
} else if (!ackNoticeShownThisProcess && !ackNoticeAlreadySeen()) {
|
|
1372
|
+
ackNoticeShownThisProcess = true;
|
|
1373
|
+
markAckNoticeSeen(); // once per install (stamp); a fresh per-turn process won't re-shout
|
|
1245
1374
|
// The version-gated BREAKING flip — LOUD on stderr the first time.
|
|
1246
|
-
console.error(`pidge:
|
|
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.`);
|
|
1247
1376
|
}
|
|
1248
1377
|
gotAny = true;
|
|
1249
1378
|
if (!v.follow) process.exit(0);
|