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.
Files changed (3) hide show
  1. package/README.md +46 -0
  2. package/bin/pidge.js +361 -20
  3. 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
- // #57 token hygiene: when the env vars are unset, fall back to
45
- // ~/.config/pidge/env (KEY=VALUE lines the HUMAN writes once in THEIR terminal)
46
- // so the raw hld_… key never has to ride the agent's chat/context. Explicit env
47
- // vars always win; `export ` prefixes, quotes and #comments are tolerated.
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(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge', 'env');
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] block until the human MESSAGES you from the app, print + ack + exit (#48)
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
- with neither set, ~/.config/pidge/env (KEY=VALUE) is read the
171
- key-free path: the human writes the file once, no secret in chat
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
- if (!TOKEN) die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.config/pidge/env)');
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 = 16;
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
- await waitForAnswer(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
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
- process.exit(0);
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) { finish('got-messages'); await printAndAck(msgs); }
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 outcome = await cableSession({
1093
+ const sessions = [cableSession({
770
1094
  channel: 'ConversationChannel',
771
1095
  deadline,
772
1096
  onUp: (finish) => {
773
- if (!announced) { announced = true; console.error('pidge: listening over the realtime socket (the human sees "ouvindo agora")'); }
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 res = await fetchT(`${BASE}/api/v1/messages${waitS > 0 ? `?wait=${waitS}` : ''}`, { headers }, (waitS + 10) * 1000);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Send rich, actionable iPhone notifications to a human and block until they answer. Built for AI agents.",
5
5
  "keywords": [
6
6
  "pidge",