pidge-cli 0.6.1 → 0.8.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 +46 -0
- package/bin/pidge.js +361 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,48 @@ 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
|
+
## Setup in one command (v0.8.0 — the claim flow)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# The human copies a setup prompt from the Pidge app (Canais → the channel) —
|
|
18
|
+
# it carries a SINGLE-USE claim code (15 min TTL), never the key:
|
|
19
|
+
npx pidge-cli setup --claim <code> --url https://pidge.sh
|
|
20
|
+
# → exchanges the code for the real key, stores it (chmod 600), runs `pidge doctor`.
|
|
21
|
+
# The secret never appears on screen or in any chat (the CLI writes it).
|
|
22
|
+
|
|
23
|
+
npx pidge-cli doctor # validate anytime: env source, server, key, "canal X · N devices"
|
|
24
|
+
npx pidge-cli whoami # which channel does this key speak for (JSON)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Many agents on one machine — isolate them (read this)
|
|
28
|
+
|
|
29
|
+
`~/.config/pidge/env` is **one slot per machine-user**: every agent without its
|
|
30
|
+
own identity reads the same key, so one agent's `setup` makes another agent send
|
|
31
|
+
as the wrong channel (this bit us for real — a cron got hijacked). Each agent
|
|
32
|
+
must have its **own** identity. Cheapest correct setups, in order:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# A. per-agent env var — the cleanest; the human sets it at the agent's launch
|
|
36
|
+
# (systemd unit / launcher / profile). Env var always wins over any file.
|
|
37
|
+
export PIDGE_TOKEN=hld_… # this agent only
|
|
38
|
+
|
|
39
|
+
# B. per-agent config file — set ONE non-secret id at launch; the CLI namespaces
|
|
40
|
+
# the file to ~/.config/pidge/agents/<id>/env and still writes the key for you
|
|
41
|
+
# (no secret in the agent's chat). setup/doctor/everything follow it.
|
|
42
|
+
export PIDGE_AGENT=javier
|
|
43
|
+
npx pidge-cli setup --claim <code>
|
|
44
|
+
|
|
45
|
+
# C. you're at YOUR terminal and want the env var hygienically from a claim:
|
|
46
|
+
npx pidge-cli setup --claim <code> --print # prints `export …`; writes nothing
|
|
47
|
+
# paste the two lines into THAT agent's launcher. NEVER run --print as an agent
|
|
48
|
+
# (the key would land in its context) — that's what A/B are for.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The bare `~/.config/pidge/env` (no `PIDGE_AGENT`) is fine for a **single** agent;
|
|
52
|
+
`pidge doctor` warns loudly when you're on that shared file. Lost the local key?
|
|
53
|
+
Just re-claim — `POST /claim` returns the channel's **same** key, so re-running
|
|
54
|
+
setup restores the exact identity.
|
|
55
|
+
|
|
14
56
|
## Use it (no install — via npx)
|
|
15
57
|
|
|
16
58
|
```bash
|
|
@@ -55,6 +97,10 @@ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
|
|
|
55
97
|
| `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
|
|
56
98
|
| `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
|
|
57
99
|
| `listen` | Block until the human **messages you** from the app; prints the messages, ACKs them, exits `0`. One-shot — loop it. |
|
|
100
|
+
| `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. |
|
|
101
|
+
| `doctor` | Validate the setup **without exposing secrets**: env source, server reachable, key valid, channel + device count. Exit 0/2. |
|
|
102
|
+
| `whoami` | Which channel does this key speak for (JSON). |
|
|
103
|
+
| `skill install` | Write `.claude/skills/pidge/SKILL.md` generated from the live manifest — persistent Pidge knowledge for Claude Code agents; re-run to update. |
|
|
58
104
|
|
|
59
105
|
## Realtime (v0.6.0)
|
|
60
106
|
|
package/bin/pidge.js
CHANGED
|
@@ -41,13 +41,28 @@ const path = require('node:path');
|
|
|
41
41
|
const os = require('node:os');
|
|
42
42
|
const crypto = require('node:crypto');
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
44
|
+
// Per-agent isolation (incident 2026-06-13): ~/.config/pidge/env is one slot
|
|
45
|
+
// per machine-user, so N agents sharing a HOME share an identity — one agent's
|
|
46
|
+
// setup hijacked another's cron. The fix is a NON-secret namespacing var the
|
|
47
|
+
// human sets ONCE at each agent's launch: PIDGE_AGENT=<id> → the config lives
|
|
48
|
+
// at ~/.config/pidge/agents/<id>/env, isolated by construction. The CLI still
|
|
49
|
+
// WRITES the key (the agent never sees it — #57 hygiene intact), it's just
|
|
50
|
+
// per-agent now. No PIDGE_AGENT ⇒ the legacy shared file (single-agent only).
|
|
51
|
+
// (An explicit PIDGE_TOKEN env var still wins over any file — the purest
|
|
52
|
+
// per-agent path.)
|
|
53
|
+
const AGENT_ID = (process.env.PIDGE_AGENT || '').trim().replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64);
|
|
54
|
+
function pidgeConfigDir() {
|
|
55
|
+
const base = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge');
|
|
56
|
+
return AGENT_ID ? path.join(base, 'agents', AGENT_ID) : base;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// #57 token hygiene: when the env vars are unset, fall back to the config file
|
|
60
|
+
// (KEY=VALUE the CLI writes during setup, or the HUMAN writes once) so the raw
|
|
61
|
+
// hld_… key never rides the agent's chat/context. Explicit env vars always win;
|
|
62
|
+
// `export ` prefixes, quotes and #comments are tolerated.
|
|
48
63
|
function configEnv() {
|
|
49
64
|
try {
|
|
50
|
-
const file = path.join(
|
|
65
|
+
const file = path.join(pidgeConfigDir(), 'env');
|
|
51
66
|
const out = {};
|
|
52
67
|
for (let line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
53
68
|
line = line.trim().replace(/^export\s+/, '');
|
|
@@ -90,6 +105,7 @@ const OPTIONS = {
|
|
|
90
105
|
'reply-to': { type: 'string' },
|
|
91
106
|
'correlation-id': { type: 'string' },
|
|
92
107
|
thread: { type: 'string' }, // conversation handle (#49) — same id ⇒ one strand on the phone
|
|
108
|
+
after: { type: 'string' }, // decision queue (#157): held until this cid resolves
|
|
93
109
|
'collapse-key': { type: 'string' },
|
|
94
110
|
param: { type: 'string', multiple: true }, // key=value escape hatch → raw /notify field
|
|
95
111
|
timeout: { type: 'string' },
|
|
@@ -102,17 +118,42 @@ const OPTIONS = {
|
|
|
102
118
|
// realtime (#118): WS by default when the runtime has a WebSocket (Node ≥22)
|
|
103
119
|
realtime: { type: 'boolean' }, // force WS (warn+fallback if unavailable)
|
|
104
120
|
'no-realtime': { type: 'boolean' }, // polling only
|
|
121
|
+
// onboarding v2 (#110)
|
|
122
|
+
claim: { type: 'string' }, // setup --claim <single-use code>
|
|
123
|
+
// #157 P2: listen keeps going after a batch (supervisor loop, one process)
|
|
124
|
+
follow: { type: 'boolean' },
|
|
125
|
+
force: { type: 'boolean' }, // setup: overwrite a config owned by ANOTHER channel
|
|
126
|
+
print: { type: 'boolean' }, // setup: print export lines instead of writing a file (per-agent, human runs it)
|
|
105
127
|
};
|
|
106
128
|
|
|
107
129
|
const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
|
|
108
130
|
|
|
109
131
|
USAGE
|
|
132
|
+
pidge setup --claim CODE [--url BASE] one-shot onboarding (#110): exchange the single-use
|
|
133
|
+
code for the channel key, store it, run doctor.
|
|
134
|
+
MULTI-AGENT: set PIDGE_AGENT=<id> at each agent's launch
|
|
135
|
+
→ isolated config ~/.config/pidge/agents/<id>/env.
|
|
136
|
+
--print emit 'export PIDGE_TOKEN=…' instead of a file
|
|
137
|
+
(you run it in YOUR terminal; paste into the
|
|
138
|
+
agent's launcher — never run --print as an agent)
|
|
139
|
+
--force overwrite a shared file owned by another channel
|
|
140
|
+
pidge doctor validate the setup WITHOUT exposing secrets:
|
|
141
|
+
env source, server, key, "canal X · N devices"
|
|
142
|
+
pidge whoami which channel does this key speak for (JSON)
|
|
110
143
|
pidge ask [options] send AND wait for the answer (prints chosen_action JSON)
|
|
111
144
|
pidge notify [options] send only (prints the 201 JSON)
|
|
112
145
|
pidge wait <correlation_id> [options] block on an already-sent notification
|
|
113
146
|
pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
|
|
114
147
|
pidge inbox [--pending|--summary|--all|--limit N] what you sent: list, pending slice, or counts+latency (#83)
|
|
115
|
-
pidge listen [--timeout N]
|
|
148
|
+
pidge listen [--timeout N] [--all] [--follow]
|
|
149
|
+
block until the human MESSAGES you from the app, print + ack + exit (#48)
|
|
150
|
+
--follow = print+ack and KEEP listening until --timeout
|
|
151
|
+
(exit 0 if any batch landed; one-shot stays the default)
|
|
152
|
+
--all (#131) = the SINGLE EAR: also hear notification ANSWERS
|
|
153
|
+
(kind notification_reply + self-contained ref) — nothing the human
|
|
154
|
+
says can be missed by a looped listen --all
|
|
155
|
+
pidge skill install write .claude/skills/pidge/SKILL.md generated from the
|
|
156
|
+
live manifest (persistent Pidge knowledge for Claude Code)
|
|
116
157
|
pidge --help
|
|
117
158
|
|
|
118
159
|
REALTIME (#118)
|
|
@@ -157,6 +198,9 @@ OPTIONS (notify / ask)
|
|
|
157
198
|
--correlation-id ID idempotency + routing key (auto-generated if omitted)
|
|
158
199
|
--thread ID conversation handle (#49): sends sharing it group as ONE
|
|
159
200
|
strand on the phone — use it for follow-ups
|
|
201
|
+
--after CID decision queue (#157): HELD until that notification is
|
|
202
|
+
answered — chain N decisions so the human sees one at a
|
|
203
|
+
time ("Decisão 2/3" --after <cid-da-1>); snooze doesn't advance
|
|
160
204
|
--collapse-key KEY replace/update a prior notification
|
|
161
205
|
--param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
|
|
162
206
|
fields work without a CLI update; the manifest is the contract
|
|
@@ -166,9 +210,13 @@ OPTIONS (notify / ask)
|
|
|
166
210
|
|
|
167
211
|
ENV
|
|
168
212
|
PIDGE_URL your Pidge server (default http://localhost:3000; HERALD_URL honored)
|
|
169
|
-
PIDGE_TOKEN your channel's bearer key (required; HERALD_TOKEN honored)
|
|
170
|
-
|
|
171
|
-
|
|
213
|
+
PIDGE_TOKEN your channel's bearer key (required; HERALD_TOKEN honored). Setting
|
|
214
|
+
this per agent at launch is the cleanest multi-agent isolation —
|
|
215
|
+
env var always wins over any file.
|
|
216
|
+
PIDGE_AGENT <id> namespacing the config file to ~/.config/pidge/agents/<id>/env
|
|
217
|
+
so N agents on one machine never share an identity (the CLI still
|
|
218
|
+
writes the key — no secret in the agent's chat). Unset ⇒ the legacy
|
|
219
|
+
shared ~/.config/pidge/env (single-agent only).
|
|
172
220
|
|
|
173
221
|
OUTPUT
|
|
174
222
|
stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
|
|
@@ -197,7 +245,9 @@ const command = parsed.positionals[0];
|
|
|
197
245
|
// `pidge --help` / `-h` / `help` → full help on stdout, exit 0. No command → stderr, exit 1.
|
|
198
246
|
if (v.help || command === 'help') { console.log(USAGE); process.exit(0); }
|
|
199
247
|
if (!command) { console.error(USAGE); process.exit(1); }
|
|
200
|
-
|
|
248
|
+
// `setup` is the command that CREATES the token config — it must run without one.
|
|
249
|
+
if (!TOKEN && command !== 'setup')
|
|
250
|
+
die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.config/pidge/env) — or onboard with: pidge setup --claim <code> (ask your human for the code: Pidge app → Canais → o canal → copiar prompt de setup)');
|
|
201
251
|
|
|
202
252
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
203
253
|
const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
|
|
@@ -217,7 +267,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
|
|
|
217
267
|
// The server advertises its manifest version on every response. When it's newer
|
|
218
268
|
// than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
|
|
219
269
|
// the manifest (whats_new) and learns the new capabilities without polling.
|
|
220
|
-
const KNOWN_MANIFEST_VERSION =
|
|
270
|
+
const KNOWN_MANIFEST_VERSION = 25;
|
|
221
271
|
let newsWarned = false;
|
|
222
272
|
function checkManifestNews(res) {
|
|
223
273
|
const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
|
|
@@ -372,6 +422,7 @@ function buildBody() {
|
|
|
372
422
|
if (v['reply-to'] !== undefined) body.reply_to = v['reply-to'];
|
|
373
423
|
if (v['correlation-id'] !== undefined) body.correlation_id = v['correlation-id'];
|
|
374
424
|
if (v.thread !== undefined) body.thread_id = v.thread;
|
|
425
|
+
if (v.after !== undefined) body.after = v.after;
|
|
375
426
|
if (v['collapse-key'] !== undefined) body.collapse_key = v['collapse-key'];
|
|
376
427
|
if (v.actions !== undefined) body.actions = v.actions.split(',').filter(Boolean);
|
|
377
428
|
|
|
@@ -379,6 +430,11 @@ function buildBody() {
|
|
|
379
430
|
if (customs.length) {
|
|
380
431
|
body.custom_actions = customs.map((spec) => {
|
|
381
432
|
const [id, label, ...flags] = spec.split(':');
|
|
433
|
+
// #157 P2: fail fast locally — the rule is stable and the server 422
|
|
434
|
+
// costs a round-trip an agent then has to interpret.
|
|
435
|
+
if (!/^[a-z0-9_]{1,40}$/.test(id || '')) {
|
|
436
|
+
die(`pidge: --custom-action id ${JSON.stringify(id)} is invalid — lowercase letters, digits and underscore only (^[a-z0-9_]{1,40}$)`, 1);
|
|
437
|
+
}
|
|
382
438
|
const ca = { id, label };
|
|
383
439
|
if (flags.includes('destructive')) ca.style = 'destructive';
|
|
384
440
|
if (flags.includes('confirm')) ca.confirm = true;
|
|
@@ -613,8 +669,235 @@ async function waitForAnswer(cid, { timeout, interval }) {
|
|
|
613
669
|
|
|
614
670
|
const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
|
|
615
671
|
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// Onboarding v2 (#110): setup --claim / doctor / whoami / skill install.
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
const CONFIG_DIR = pidgeConfigDir();
|
|
677
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'env');
|
|
678
|
+
// True when we're reading the LEGACY shared file (no PIDGE_AGENT, no env var) —
|
|
679
|
+
// the multi-agent footgun. doctor warns on it.
|
|
680
|
+
const ON_SHARED_FILE = !AGENT_ID && !process.env.PIDGE_TOKEN && !process.env.HERALD_TOKEN && !!FILE_ENV.PIDGE_TOKEN;
|
|
681
|
+
|
|
682
|
+
// Where the token came from — doctor narrates it, setup respects precedence.
|
|
683
|
+
function tokenSource() {
|
|
684
|
+
if (process.env.PIDGE_TOKEN || process.env.HERALD_TOKEN) return 'env var (per-agent)';
|
|
685
|
+
if (FILE_ENV.PIDGE_TOKEN) return CONFIG_FILE + (AGENT_ID ? ` (PIDGE_AGENT=${AGENT_ID})` : ' (shared)');
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// GET /whoami — which channel does this key speak for. Returns {res, data}.
|
|
690
|
+
async function fetchWhoami(base = BASE, token = TOKEN) {
|
|
691
|
+
const res = await fetchT(`${base}/api/v1/whoami`, {
|
|
692
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
693
|
+
});
|
|
694
|
+
let data = {};
|
|
695
|
+
try { data = await res.json(); } catch { /* leave {} */ }
|
|
696
|
+
return { res, data };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
|
|
700
|
+
// a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
|
|
701
|
+
async function runDoctor(base = BASE, token = TOKEN) {
|
|
702
|
+
const source = token === TOKEN ? tokenSource() : CONFIG_FILE; // post-setup call passes the fresh token
|
|
703
|
+
if (!token) {
|
|
704
|
+
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
|
+
process.exit(2);
|
|
706
|
+
}
|
|
707
|
+
console.error(`pidge doctor: token found (${source || 'passed in'}) — never displayed`);
|
|
708
|
+
console.error(`pidge doctor: server ${base}`);
|
|
709
|
+
let out;
|
|
710
|
+
try {
|
|
711
|
+
out = await fetchWhoami(base, token);
|
|
712
|
+
} catch (e) {
|
|
713
|
+
console.error(`pidge doctor: server UNREACHABLE — ${e.message} (check the URL; is it ${base}?)`);
|
|
714
|
+
process.exit(2);
|
|
715
|
+
}
|
|
716
|
+
const { res, data } = out;
|
|
717
|
+
checkManifestNews(res);
|
|
718
|
+
if (res.status === 401) {
|
|
719
|
+
console.error('pidge doctor: server reachable but the key is INVALID/REVOKED — re-onboard: ask your human for a fresh claim code (Pidge app → Canais → o canal → copiar prompt de setup)');
|
|
720
|
+
process.exit(2);
|
|
721
|
+
}
|
|
722
|
+
if (res.status === 404) {
|
|
723
|
+
// pre-#110 server: no /whoami yet — the key may still be fine; prove it on the manifest.
|
|
724
|
+
const m = await fetchT(`${base}/api/v1/manifest`, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
725
|
+
if (m && m.status === 200) {
|
|
726
|
+
console.error('pidge doctor: key VALID (server predates /whoami — channel/device detail unavailable; update the server to see it)');
|
|
727
|
+
console.log(JSON.stringify({ ok: true, base_url: base, channel: null, devices: null }));
|
|
728
|
+
process.exit(0);
|
|
729
|
+
}
|
|
730
|
+
console.error(`pidge doctor: unexpected ${m ? m.status : 'network error'} on the manifest — server looks broken`);
|
|
731
|
+
process.exit(2);
|
|
732
|
+
}
|
|
733
|
+
if (res.status !== 200) {
|
|
734
|
+
console.error(`pidge doctor: unexpected ${res.status} from /whoami — ${JSON.stringify(data)}`);
|
|
735
|
+
process.exit(2);
|
|
736
|
+
}
|
|
737
|
+
const devices = data.devices ?? 0;
|
|
738
|
+
console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
|
|
739
|
+
if (devices === 0)
|
|
740
|
+
console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
|
|
741
|
+
if (ON_SHARED_FILE)
|
|
742
|
+
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
|
+
console.error('pidge doctor: all good — try: pidge ask --template decision --title "Pidge funcionando?"');
|
|
744
|
+
console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version }));
|
|
745
|
+
process.exit(0);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// setup --claim: exchange the single-use code for the key, store it ourselves
|
|
749
|
+
// (the secret never appears on screen or in the chat the prompt was pasted in),
|
|
750
|
+
// then prove the loop with doctor.
|
|
751
|
+
async function runSetup() {
|
|
752
|
+
const code = v.claim;
|
|
753
|
+
if (!code) die('pidge: usage: pidge setup --claim <code> [--url <base>] (the human copies the code from the Pidge app)', 1);
|
|
754
|
+
const base = (v.url || process.env.PIDGE_URL || FILE_ENV.PIDGE_URL || 'https://pidge.sh').replace(/\/+$/, '');
|
|
755
|
+
|
|
756
|
+
// THE SHARED-CONFIG GUARD (real incident, 2026-06-13). Only the FILE path can
|
|
757
|
+
// collide; --print writes nothing, so skip it there. CONFIG_FILE is now
|
|
758
|
+
// per-agent when PIDGE_AGENT is set (no collision by construction), but on the
|
|
759
|
+
// legacy shared file two agents still share it — refuse to clobber a file that
|
|
760
|
+
// still authenticates as some channel unless --force. Checked BEFORE the
|
|
761
|
+
// exchange so the single-use code survives the refusal.
|
|
762
|
+
if (!v.print && !v.force && FILE_ENV.PIDGE_TOKEN) {
|
|
763
|
+
let owner = null;
|
|
764
|
+
try {
|
|
765
|
+
const { res: wres, data: wdata } = await fetchWhoami(base, FILE_ENV.PIDGE_TOKEN);
|
|
766
|
+
if (wres.status === 200 && wdata.channel) owner = wdata.channel.name;
|
|
767
|
+
else if (wres.status !== 401) owner = 'um canal (servidor não confirmou)';
|
|
768
|
+
// 401 ⇒ the stored key is dead — overwriting a corpse needs no --force.
|
|
769
|
+
} catch {
|
|
770
|
+
owner = 'um canal (servidor inalcançável para confirmar)';
|
|
771
|
+
}
|
|
772
|
+
if (owner) {
|
|
773
|
+
die(`pidge: ${CONFIG_FILE} já guarda a chave de "${owner}". Sobrescrever faria qualquer agente que lê esse arquivo enviar como o canal novo (incidente real: um cron foi sequestrado assim). O jeito certo de rodar VÁRIOS agentes na mesma máquina: dê a cada um um PIDGE_AGENT=<id> no launch (cada um ganha ~/.config/pidge/agents/<id>/env isolado), ou um PIDGE_TOKEN próprio, ou rode com --print e cole o export no launcher DESTE agente. Substituir mesmo assim? --force (o claim code continua válido — nada foi consumido).`, 2);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let res, data = {};
|
|
778
|
+
try {
|
|
779
|
+
res = await fetchT(`${base}/api/v1/claim`, {
|
|
780
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
781
|
+
body: JSON.stringify({ code }),
|
|
782
|
+
});
|
|
783
|
+
try { data = await res.json(); } catch { /* leave {} */ }
|
|
784
|
+
} catch (e) {
|
|
785
|
+
die(`pidge: claim failed (network): ${e.message} — is the server URL right? (${base})`, 2);
|
|
786
|
+
}
|
|
787
|
+
if (res.status === 404)
|
|
788
|
+
die('pidge: claim code unknown, EXPIRED (15 min TTL) or already used — ask your human for a fresh one (Pidge app → Canais → o canal → copiar prompt de setup)', 2);
|
|
789
|
+
if (!(res.status >= 200 && res.status < 300) || !data.key)
|
|
790
|
+
die(`pidge: claim failed (${res.status}): ${JSON.stringify(data)}`, 2);
|
|
791
|
+
|
|
792
|
+
const finalBase = (data.base_url || base).replace(/\/+$/, '');
|
|
793
|
+
const channelName = data.channel && data.channel.name;
|
|
794
|
+
|
|
795
|
+
// --print: the pure per-agent path — emit the export lines (the HUMAN runs
|
|
796
|
+
// this in THEIR terminal and pastes them into the agent's launcher). Stores
|
|
797
|
+
// nothing; the key shows on screen, so DON'T let an agent run --print (the
|
|
798
|
+
// key would land in its context — that's what the file path is for). stdout
|
|
799
|
+
// is eval-able; the guidance goes to stderr.
|
|
800
|
+
if (v.print) {
|
|
801
|
+
console.log(`export PIDGE_URL=${finalBase}`);
|
|
802
|
+
console.log(`export PIDGE_TOKEN=${data.key}`);
|
|
803
|
+
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);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// File path (default): the CLI writes the key — the agent never sees it
|
|
809
|
+
// (#57). Per-agent when PIDGE_AGENT is set; otherwise the legacy shared file.
|
|
810
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
811
|
+
fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
|
|
812
|
+
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
|
|
813
|
+
console.error(`pidge: canal "${channelName}" configurado — chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
|
|
814
|
+
if (!AGENT_ID)
|
|
815
|
+
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);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// skill install (#110e): persistent Pidge knowledge for Claude Code agents —
|
|
820
|
+
// a skill generated FROM the live manifest (so it can't drift), versioned with
|
|
821
|
+
// manifest_version (re-run to update; whats_new is the changelog).
|
|
822
|
+
async function runSkillInstall() {
|
|
823
|
+
let res, m;
|
|
824
|
+
try {
|
|
825
|
+
res = await fetchT(`${BASE}/api/v1/manifest`, { headers });
|
|
826
|
+
m = await res.json();
|
|
827
|
+
} catch (e) {
|
|
828
|
+
die(`pidge: could not read the manifest: ${e.message}`, 2);
|
|
829
|
+
}
|
|
830
|
+
if (res.status !== 200) die(`pidge: manifest read failed (${res.status})`, 2);
|
|
831
|
+
const table = (m.templates && m.templates.decision_table) || [];
|
|
832
|
+
const profileTable = (m.profiles && m.profiles.decision_table) || [];
|
|
833
|
+
const notes = m.notes || [];
|
|
834
|
+
const exits = (m.cli && m.cli.output) || '';
|
|
835
|
+
const skill = `---
|
|
836
|
+
name: pidge
|
|
837
|
+
description: Send rich, actionable iPhone notifications to your human and get their decision back (Pidge). Use when finishing long tasks (report), needing a decision/approval, sending FYIs with substance, or anything time-anchored. Also covers reading the human's replies/messages back.
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
# Pidge — notify your human, get answers back
|
|
841
|
+
|
|
842
|
+
Generated from manifest v${m.manifest_version} of ${BASE} — re-run \`pidge skill install\` to update (any API response header X-Pidge-Manifest-Version > ${m.manifest_version} means there's news).
|
|
843
|
+
|
|
844
|
+
All commands: \`npx pidge-cli …\` (Node ≥18; reads ~/.config/pidge/env — no token in context). Not set up? \`pidge doctor\` tells you; onboard with \`pidge setup --claim <code>\` (the human copies the code from the Pidge app).
|
|
845
|
+
|
|
846
|
+
## Pick the right send (decision table)
|
|
847
|
+
|
|
848
|
+
${table.map((r) => `- ${r}`).join('\n')}
|
|
849
|
+
|
|
850
|
+
## How it intrudes (profiles — the human owns them)
|
|
851
|
+
|
|
852
|
+
${profileTable.map((r) => `- ${r}`).join('\n')}
|
|
853
|
+
|
|
854
|
+
## The contract
|
|
855
|
+
|
|
856
|
+
${notes.map((n) => `- ${n}`).join('\n')}
|
|
857
|
+
|
|
858
|
+
## Getting answers
|
|
859
|
+
|
|
860
|
+
- \`pidge ask …\` blocks and prints chosen_action JSON; \`pidge wait <cid>\` blocks on an existing send.
|
|
861
|
+
- \`pidge listen\` blocks until the human MESSAGES you from the app (composer) — run it when idle.
|
|
862
|
+
- ${exits}
|
|
863
|
+
|
|
864
|
+
## Full spec
|
|
865
|
+
|
|
866
|
+
\`curl $PIDGE_URL/api/v1/manifest -H "Authorization: Bearer $PIDGE_TOKEN"\` — the always-current contract (fields, templates, custom actions, media, threads, realtime).
|
|
867
|
+
`;
|
|
868
|
+
const dir = path.join(process.cwd(), '.claude', 'skills', 'pidge');
|
|
869
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
870
|
+
const file = path.join(dir, 'SKILL.md');
|
|
871
|
+
fs.writeFileSync(file, skill);
|
|
872
|
+
console.error(`pidge: skill written to ${file} (manifest v${m.manifest_version}) — your future sessions in this project know Pidge now`);
|
|
873
|
+
console.log(JSON.stringify({ ok: true, file, manifest_version: m.manifest_version }));
|
|
874
|
+
process.exit(0);
|
|
875
|
+
}
|
|
876
|
+
|
|
616
877
|
(async () => {
|
|
617
878
|
switch (command) {
|
|
879
|
+
case 'setup': {
|
|
880
|
+
await runSetup(); // exits via runDoctor
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
case 'doctor': {
|
|
884
|
+
await runDoctor();
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
case 'whoami': {
|
|
888
|
+
const { res, data } = await fetchWhoami().catch((e) => { die(`pidge: whoami failed (network): ${e.message}`, 2); });
|
|
889
|
+
checkManifestNews(res);
|
|
890
|
+
if (res.status !== 200) die(`pidge: whoami failed (${res.status}): ${JSON.stringify(data)}`, 2);
|
|
891
|
+
console.log(JSON.stringify(data, null, 2));
|
|
892
|
+
console.error(`pidge: you are canal "${data.channel && data.channel.name}" · ${data.devices ?? '?'} device(s)`);
|
|
893
|
+
process.exit(0);
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
case 'skill': {
|
|
897
|
+
if (parsed.positionals[1] !== 'install') die('pidge: usage: pidge skill install', 1);
|
|
898
|
+
await runSkillInstall();
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
618
901
|
case 'notify': {
|
|
619
902
|
const { ok, info, raw } = await doNotify();
|
|
620
903
|
console.log(raw);
|
|
@@ -639,7 +922,20 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
639
922
|
const { ok, info } = await doNotify();
|
|
640
923
|
if (!ok) process.exit(2);
|
|
641
924
|
console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
|
|
642
|
-
|
|
925
|
+
// #132: no --timeout ⇒ obey the template's suggestion from the 201 echo
|
|
926
|
+
// (human decisions take 30-40 min in the wild; a 600 s default misread
|
|
927
|
+
// them as silence). Explicit --timeout always wins.
|
|
928
|
+
let timeout = num(v.timeout, NaN);
|
|
929
|
+
if (!Number.isFinite(timeout)) {
|
|
930
|
+
if (info.suggested_ask_timeout) {
|
|
931
|
+
timeout = info.suggested_ask_timeout;
|
|
932
|
+
const mins = Math.round(timeout / 60);
|
|
933
|
+
console.error(`pidge: timeout ${mins} min — suggested by template ${info.template || v.template} (override with --timeout)`);
|
|
934
|
+
} else {
|
|
935
|
+
timeout = 600;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
|
|
643
939
|
break;
|
|
644
940
|
}
|
|
645
941
|
case 'wait': {
|
|
@@ -715,12 +1011,35 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
715
1011
|
// the whole session never had a healthy round-trip (#119).
|
|
716
1012
|
// At-least-once: the ack happens AFTER the print — a crash re-serves them;
|
|
717
1013
|
// dedupe by id if you've seen one before.
|
|
1014
|
+
// --all (#131): the SINGLE EAR — the queue also serves notification
|
|
1015
|
+
// ANSWERS (kind notification_reply, with a self-contained ref), so a
|
|
1016
|
+
// fire-and-forget notify can't lose its reply. Without --all the original
|
|
1017
|
+
// composer-only contract stands (no double-consumption for ask/wait users).
|
|
718
1018
|
const timeout = num(v.timeout, 600);
|
|
719
1019
|
let deadline = Date.now() + timeout * 1000;
|
|
1020
|
+
const queueQs = v.all ? '?all=true' : '';
|
|
1021
|
+
// #157 P2 --follow: print+ack a batch and KEEP listening until the
|
|
1022
|
+
// timeout — the supervisor loop without re-spawning a process per batch.
|
|
1023
|
+
let gotAny = false;
|
|
1024
|
+
const followEnd = () => {
|
|
1025
|
+
if (v.follow && gotAny) {
|
|
1026
|
+
console.error(`pidge: --follow window ended after ${timeout}s — batches were delivered`);
|
|
1027
|
+
process.exit(0);
|
|
1028
|
+
}
|
|
1029
|
+
return false;
|
|
1030
|
+
};
|
|
720
1031
|
|
|
721
|
-
// Print + ack + exit 0 — shared by the WS and polling paths.
|
|
1032
|
+
// Print + ack (+ exit 0 unless --follow) — shared by the WS and polling paths.
|
|
722
1033
|
const printAndAck = async (msgs) => {
|
|
723
1034
|
console.log(JSON.stringify(msgs, null, 2));
|
|
1035
|
+
// #131: narrate answers so the agent knows WHICH notification spoke back.
|
|
1036
|
+
for (const m of msgs) {
|
|
1037
|
+
if (m.kind === 'notification_reply' && m.ref) {
|
|
1038
|
+
const said = m.text ? `: ${String(m.text).slice(0, 120)}` : '';
|
|
1039
|
+
console.error(`pidge: reply to your notification ${m.ref.correlation_id} ("${m.ref.title}") — ${m.action_id || m.ref.event_kind}${said}`);
|
|
1040
|
+
if (m.truncated) console.error('pidge: that reply hit the server cap (truncated:true) — tell your human the tail was lost');
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
724
1043
|
const upTo = Math.max(...msgs.map((m) => m.id));
|
|
725
1044
|
try {
|
|
726
1045
|
// fetchT, not fetch: a wedged proxy stalling this ack would otherwise
|
|
@@ -738,7 +1057,9 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
738
1057
|
} catch (e) {
|
|
739
1058
|
console.error(`pidge: WARNING — ack failed (network: ${e.message}); these messages will be re-served next listen`);
|
|
740
1059
|
}
|
|
741
|
-
|
|
1060
|
+
gotAny = true;
|
|
1061
|
+
if (!v.follow) process.exit(0);
|
|
1062
|
+
console.error('pidge: --follow — still listening');
|
|
742
1063
|
};
|
|
743
1064
|
|
|
744
1065
|
// Realtime path (#118): hold ConversationChannel — the human sees "ouvindo
|
|
@@ -750,12 +1071,15 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
750
1071
|
if (draining) return;
|
|
751
1072
|
draining = true;
|
|
752
1073
|
try {
|
|
753
|
-
const res = await fetchT(`${BASE}/api/v1/messages`, { headers });
|
|
1074
|
+
const res = await fetchT(`${BASE}/api/v1/messages${queueQs}`, { headers });
|
|
754
1075
|
checkManifestNews(res);
|
|
755
1076
|
if (res.status === 200) {
|
|
756
1077
|
health.ok();
|
|
757
1078
|
const msgs = (await res.json().catch(() => ({}))).messages || [];
|
|
758
|
-
if (msgs.length) {
|
|
1079
|
+
if (msgs.length) {
|
|
1080
|
+
if (!v.follow) finish('got-messages');
|
|
1081
|
+
await printAndAck(msgs);
|
|
1082
|
+
}
|
|
759
1083
|
} else if (res.status >= 500) {
|
|
760
1084
|
health.fail(`backlog read ${res.status}`);
|
|
761
1085
|
}
|
|
@@ -766,16 +1090,29 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
766
1090
|
}
|
|
767
1091
|
};
|
|
768
1092
|
let announced = false;
|
|
769
|
-
const
|
|
1093
|
+
const sessions = [cableSession({
|
|
770
1094
|
channel: 'ConversationChannel',
|
|
771
1095
|
deadline,
|
|
772
1096
|
onUp: (finish) => {
|
|
773
|
-
if (!announced) { announced = true; console.error(
|
|
1097
|
+
if (!announced) { announced = true; console.error(`pidge: listening over the realtime socket${v.all ? ' — single ear: composer + notification answers (#131)' : ''} (the human sees "ouvindo agora")`); }
|
|
774
1098
|
drain(finish);
|
|
775
1099
|
},
|
|
776
1100
|
onFrame: (m, finish) => { if (m.type === 'message') drain(finish); },
|
|
777
|
-
});
|
|
1101
|
+
})];
|
|
1102
|
+
// --all (#131): answers broadcast on InboxChannel, not Conversation — a
|
|
1103
|
+
// second subscription wakes the same HTTP drain (the queue is the ledger;
|
|
1104
|
+
// the loser session leaks until exit, harmless in a one-shot process).
|
|
1105
|
+
if (v.all) {
|
|
1106
|
+
sessions.push(cableSession({
|
|
1107
|
+
channel: 'InboxChannel',
|
|
1108
|
+
deadline,
|
|
1109
|
+
onUp: (finish) => drain(finish),
|
|
1110
|
+
onFrame: (m, finish) => { if (m.type === 'event' && m.responded) drain(finish); },
|
|
1111
|
+
}));
|
|
1112
|
+
}
|
|
1113
|
+
const outcome = await Promise.race(sessions);
|
|
778
1114
|
if (outcome === 'deadline') {
|
|
1115
|
+
followEnd();
|
|
779
1116
|
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
780
1117
|
}
|
|
781
1118
|
if (outcome === 'got-messages') {
|
|
@@ -788,7 +1125,10 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
788
1125
|
const waitS = health.degraded ? 0 : Math.max(0, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
|
|
789
1126
|
const askedAt = Date.now();
|
|
790
1127
|
try {
|
|
791
|
-
const
|
|
1128
|
+
const qs = new URLSearchParams();
|
|
1129
|
+
if (waitS > 0) qs.set('wait', String(waitS));
|
|
1130
|
+
if (v.all) qs.set('all', 'true');
|
|
1131
|
+
const res = await fetchT(`${BASE}/api/v1/messages${qs.size ? `?${qs}` : ''}`, { headers }, (waitS + 10) * 1000);
|
|
792
1132
|
checkManifestNews(res);
|
|
793
1133
|
if (res.status === 200) {
|
|
794
1134
|
health.ok();
|
|
@@ -805,6 +1145,7 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
|
|
|
805
1145
|
health.fail(`network: ${e.message}`);
|
|
806
1146
|
}
|
|
807
1147
|
if (Date.now() >= deadline) {
|
|
1148
|
+
followEnd();
|
|
808
1149
|
health.exitTimeout(`timed out after ${timeout}s — no message from the human`);
|
|
809
1150
|
}
|
|
810
1151
|
const pace = health.degraded ? DEGRADED_INTERVAL_S : num(v.interval, 5);
|