pidge-cli 0.11.1 → 0.13.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/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ ## 0.13.1 — 2026-06-26
4
+
5
+ Polish from an agent E2E (2026-06-26). No breaking changes.
6
+
7
+ - **fix:** the manifest-version nudge no longer scolds "your CLI is stale, UPDATE it".
8
+ pidge is a thin pipe — `--param KEY=VALUE` carries any new `/notify` field NOW, so a
9
+ server manifest bump almost never needs a CLI release. The nudge is reframed as "new
10
+ capabilities + how to use them today" and `KNOWN_MANIFEST_VERSION` is bumped 31 → 36
11
+ (the current server), silencing the false-positive on `@latest`. (#26)
12
+ - **fix:** the public manifest (#249-A) curl in that nudge drops the mandatory Bearer —
13
+ the catalog reads without a key; the Bearer is shown only as the optional way to also
14
+ see the channel's own config. (#26)
15
+ - **fix:** the realtime reconnect log no longer reads "realtime socket **socket** closed"
16
+ (doubled word) and the counter no longer sticks at "attempt 1/4" — it now shows a
17
+ monotonic "reconnect #N" so a connect→drop flap visibly advances instead of looking
18
+ like a stuck loop. (#25)
19
+
20
+ ## 0.13.0 — 2026-06-25
21
+
22
+ Template system (#246) — the agent now declares an intent TYPE; the server maps it to
23
+ the human's delivery profile (the human never sees the type). Soft-rollout: typeless
24
+ sends still work in 0.13.x (server falls back to `fyi`); 0.14 will require a type.
25
+
26
+ - **feat:** 6 type subcommands — `pidge fyi` · `report` · `ask` · `event` · `alert` ·
27
+ `live`. Each stamps `template_kind` on the `/notify` payload. fyi/report/event/alert/
28
+ live are fire-and-forget (like `notify`); `ask` send+waits for the answer. (#246)
29
+ - **feat:** `pidge notify` (and `pidge send`) is **deprecated** with a local warning —
30
+ it still works for one minor (0.13.x; the server falls back to `fyi`) and will 422 in
31
+ 0.14. Use a typed send instead. (#246)
32
+ - **feat:** local validation before the round-trip — `ask` requires a way to answer
33
+ (`--actions`, `--custom-action`, or a `--template` that supplies them); `event`
34
+ requires a valid ISO8601 `--event-at`. Friendly exit-1 errors, nothing sent. (#246)
35
+ - **feat:** `alert --escalate` adds `escalate: true` (ask the Urgente profile for an
36
+ AlarmKit alarm that breaks through silent/Focus; the human's profile decides). (#246)
37
+ - **feat:** an unknown subcommand now points at the type catalog instead of dumping the
38
+ whole USAGE; `pidge <type> --help` shows each typed send's own flags. (#246)
39
+ - **docs:** `pidge skill install` writes a **"Choose the right type"** catalog table
40
+ into the generated `SKILL.md`. (#246)
41
+
42
+ ## 0.12.0 — 2026-06-25
43
+
44
+ CLI bugs batch, all reported by an agent in real production use. No breaking changes.
45
+
46
+ - **fix:** `pidge <sub> --help` shows the SUBCOMMAND's own help (its synopsis + own
47
+ flags), not the global USAGE dump — e.g. `pidge ask --help` now leads with ask's
48
+ `--actions`/`--timeout` instead of burying them. `pidge --help` and `pidge help`
49
+ still show the full command overview; `pidge help <cmd>` is the focused form. (#240)
50
+ - **feat:** the "server has new capabilities" manifest-version nag is throttled to
51
+ **once per 24 h** (cached in `~/.config/pidge/state.json`, per-agent when
52
+ `PIDGE_AGENT` is set) and only re-fires when the server version actually changed —
53
+ no more a nag on every call. New `--quiet-nag` flag and `PIDGE_QUIET_NAG=1` env to
54
+ silence it entirely (scripts/CI). (#241)
55
+ - **feat:** `--actions` accepts a **JSON array** of custom `{id,label,…}` actions for
56
+ custom labels — `--actions '[{"id":"approve","label":"Aprovar agora"},{"id":"defer","label":"Deixa pra amanhã"}]'`.
57
+ A leading `[` selects JSON; bad JSON / a missing `id`/`label` is a friendly LOCAL
58
+ error (exit 1, nothing sent). The short form `--actions yes,no,reply` is unchanged,
59
+ and JSON composes with `--custom-action`. (#242)
60
+ - **docs:** the manifest re-read instruction (the version nag) now shows the
61
+ **authenticated** curl — `curl -H "Authorization: Bearer $PIDGE_TOKEN" $PIDGE_URL/api/v1/manifest`
62
+ — so an agent that follows it doesn't take a 401. (#243)
63
+ - **docs:** `pidge skill install` now writes an **"always-on for turn-based agents"**
64
+ recipe into the generated `SKILL.md` — an interactive listening window
65
+ (`pidge listen --follow`) and a no-daemon supervisor poll (looped one-shot
66
+ `pidge listen`). (#244)
package/README.md CHANGED
@@ -11,6 +11,15 @@ then gets the answer as JSON — no webhook, no polling loop to write.
11
11
  > current spec (fields, profiles, guarantees). This CLI is a thin pipe over it — any
12
12
  > new server field works without a CLI update via `--param key=value`.
13
13
 
14
+ > **New in v0.12.0** — CLI bugs batch (all reported by an agent in real use): **`pidge
15
+ > <sub> --help`** now shows that subcommand's own help (its flags), not the global dump
16
+ > (#240); the **manifest-version nag is throttled to once / 24 h** (cached in
17
+ > `~/.config/pidge/state.json`) with `--quiet-nag` / `PIDGE_QUIET_NAG=1` to silence it
18
+ > (#241); **`--actions` accepts a JSON array** of custom `{id,label}` actions for custom
19
+ > labels (#242, the short `yes,no,reply` form unchanged); the nag's manifest re-read
20
+ > example now shows the **`Authorization: Bearer`** header so it doesn't 401 (#243); and
21
+ > `skill install` writes an **"always-on for turn-based agents"** recipe (#244).
22
+ >
14
23
  > **New in v0.9.0** (ships with Pidge manifest v27): **`listen` no longer consumes on
15
24
  > read** — a read message is DELIVERED, and you `ack` it after the work (a ~10-min
16
25
  > server lease re-serves un-acked messages, so a crash never loses one; `--ack-on-read`
@@ -192,10 +201,12 @@ WebSocket → ?wait= long-poll (capped 25 s server-side) → plain GETs ever
192
201
  and saves on the phone; uploaded automatically (≤25 MB)
193
202
  --url URL deep link the app opens when the user taps (PR, dashboard, log)
194
203
  --copy TEXT value offered as tap-to-copy on the detail (code, token)
195
- --actions LIST comma list: yes,no,approve,reject,accept,decline,later,
196
- done,snooze,reschedule,reply,mute
204
+ --actions LIST|JSON comma list of catalog ids: yes,no,approve,reject,accept,
205
+ decline,later,done,snooze,reschedule,reply,mute — OR a JSON
206
+ array of custom actions for your own labels:
207
+ '[{"id":"approve","label":"Aprovar agora"},{"id":"defer","label":"Depois"}]'
197
208
  --custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]"
198
- (repeatable — your own buttons)
209
+ (repeatable — your own buttons; composes with --actions JSON)
199
210
  --deliver-at ISO8601 schedule for later
200
211
  --reply-to URL also POST the answer to your webhook (HMAC-signed)
201
212
  --correlation-id ID idempotency + routing key (auto-generated if omitted)
package/bin/pidge.js CHANGED
@@ -103,6 +103,7 @@ const OPTIONS = {
103
103
  'event-at': { type: 'string' }, // WHEN the thing happens (profile event)
104
104
  'lead-minutes': { type: 'string' }, // notify/countdown lead before event_at
105
105
  urgency: { type: 'string' }, // normal | persistent | alarm (low-level — prefer --profile)
106
+ escalate: { type: 'boolean' }, // #246: alert type — force an AlarmKit alarm (escalate:true)
106
107
  image: { type: 'string' }, // banner+feed image: local path → uploaded; URL → as-is
107
108
  file: { type: 'string' }, // real artifact (xlsx/pdf/csv…): local path → uploaded
108
109
  url: { type: 'string' }, // deep link the app opens on tap (#45)
@@ -126,6 +127,7 @@ const OPTIONS = {
126
127
  // realtime (#118): WS by default when the runtime has a WebSocket (Node ≥22)
127
128
  realtime: { type: 'boolean' }, // force WS (warn+fallback if unavailable)
128
129
  'no-realtime': { type: 'boolean' }, // polling only
130
+ 'quiet-nag': { type: 'boolean' }, // #241: silence the manifest-version nag for this run
129
131
  // onboarding v2 (#110)
130
132
  claim: { type: 'string' }, // setup --claim <single-use code>
131
133
  // #157 P2: listen keeps going after a batch (supervisor loop, one process)
@@ -161,8 +163,14 @@ USAGE
161
163
  narrated LIVE on the lock screen by a 3-stage Live Activity
162
164
  (Conectando → toque para confirmar → Concluído ✓). send + wait
163
165
  in one — run it as your FIRST contact on a fresh channel.
164
- pidge ask [options] send AND wait for the answer (prints chosen_action JSON)
165
- pidge notify [options] send only (prints the 201 JSON)
166
+ TYPED SENDS (#246 pick the one that matches your INTENT):
167
+ pidge fyi [options] passive info, no action log/registro (template_kind fyi)
168
+ pidge report [options] a curated result the human will want to read now (report)
169
+ pidge ask [options] a DECISION — send AND wait; needs --actions (prints chosen_action JSON)
170
+ pidge event [options] a scheduled thing with a time — needs --event-at (event)
171
+ pidge alert [options] an anomaly/error; --escalate forces an AlarmKit alarm (alert)
172
+ pidge live [options] an in-flight task with incremental updates (live)
173
+ pidge notify [options] DEPRECATED (0.13.x) — send without a type (server falls back to fyi)
166
174
  pidge wait <correlation_id> [options] block on an already-sent notification
167
175
  pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
168
176
  pidge inbox [--pending|--summary|--all|--limit N] what you sent: list, pending slice, or counts+latency (#83)
@@ -268,6 +276,191 @@ only: it never produces an answer, so \`ask\` refuses it.
268
276
 
269
277
  Full spec (the contract — always current): GET $PIDGE_URL/api/v1/manifest`;
270
278
 
279
+ // ---------------------------------------------------------------------------
280
+ // #240: per-subcommand help. `pidge <cmd> --help` (and `pidge help <cmd>`) must
281
+ // show the focused help for THAT command — its synopsis, what it does, and only
282
+ // the flags that apply — instead of dumping the global USAGE (the bug an agent
283
+ // hit: `pidge ask --help` listed the global flags, burying ask's own
284
+ // --actions/--timeout). The global USAGE stays the no-command / `pidge --help`
285
+ // view. One option dictionary feeds both so the text can't drift.
286
+ // ---------------------------------------------------------------------------
287
+ const OPTION_DOCS = {
288
+ title: '--title TEXT (required) the headline',
289
+ body: '--body TEXT the message shown on the banner',
290
+ 'body-markdown': '--body-markdown MD rich body for the tap-through detail screen',
291
+ subtitle: '--subtitle TEXT a secondary line under the title',
292
+ template: '--template ID content/action pattern: context · decision · approval · reminder · nudge · sensitive',
293
+ profile: '--profile ID delivery profile (the human owns it): default · event · escalating · custom',
294
+ 'event-at': '--event-at ISO8601 WHEN the thing happens (required by profile event)',
295
+ 'lead-minutes': '--lead-minutes N notify/countdown N min before event_at (5–240)',
296
+ urgency: '--urgency LEVEL normal | persistent | alarm (low-level — prefer --profile)',
297
+ escalate: '--escalate alert: force an AlarmKit alarm that breaks through silent/Focus',
298
+ image: '--image PATH_OR_URL banner+feed image: a local path is uploaded; an https URL is sent as-is',
299
+ file: '--file PATH a real artifact (xlsx/pdf/csv…) uploaded for the human (≤25 MB)',
300
+ url: '--url URL deep link the app opens on tap (PR, dashboard, log)',
301
+ copy: '--copy TEXT tap-to-copy value on the detail screen',
302
+ actions: '--actions LIST|JSON comma list from the catalog (yes,no,reply) OR a JSON array of {"id","label"} custom actions',
303
+ 'custom-action': '--custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]" (repeatable)',
304
+ 'deliver-at': '--deliver-at ISO8601 schedule the send for later',
305
+ 'reply-to': '--reply-to URL also POST the answer to your webhook (HMAC-signed)',
306
+ 'correlation-id': '--correlation-id ID idempotency + routing key (auto-generated if omitted)',
307
+ thread: '--thread ID conversation handle (#49): same id ⇒ one strand on the phone',
308
+ after: '--after CID decision queue (#157): held until that notification is answered',
309
+ 'collapse-key': '--collapse-key KEY replace/update a prior notification',
310
+ param: '--param KEY=VALUE pass ANY raw /notify field (repeatable) — the manifest is the contract',
311
+ timeout: '--timeout SECONDS how long to block (ask: 600 · wait: 300 · listen: 600)',
312
+ interval: '--interval SECONDS FALLBACK poll cadence (default 30) — normally unused (WS/long-poll)',
313
+ realtime: '--realtime force the realtime WebSocket (warn + fall back to polling if unavailable)',
314
+ 'no-realtime': '--no-realtime polling only (skip the WebSocket)',
315
+ pending: '--pending only delivered + still-unanswered notifications',
316
+ summary: '--summary counts + answer latency (one call)',
317
+ 'all-inbox': '--all whole-account scope (not just this channel)',
318
+ 'all-listen': '--all single ear: also hear notification ANSWERS, not just messages (#131)',
319
+ limit: '--limit N cap the number of rows',
320
+ claim: '--claim CODE the single-use setup code (the human copies it from the Pidge app)',
321
+ 'url-base': '--url BASE the Pidge server base URL (default https://pidge.sh)',
322
+ print: '--print emit `export …` lines instead of writing a file (per-agent; you run it)',
323
+ force: '--force overwrite a shared config owned by another channel',
324
+ 'listen-mode': '--listen-mode MODE declare how you operate: turn_based | persistent | external_daemon',
325
+ follow: '--follow KEEP listening until --timeout (supervisor-only; traps a turn-based agent)',
326
+ 'ack-on-read': '--ack-on-read consume messages on read (pre-0.9 immediate-consume)',
327
+ 'up-to': '--up-to ID process every message up to this id',
328
+ ids: '--ids a,b process this comma-list of ids',
329
+ renew: '--renew heartbeat the visibility-timeout lease instead of processing',
330
+ window: '--window N reachability window in seconds (default 30)',
331
+ 'quiet-nag': '--quiet-nag silence the "server has new capabilities" nag for this run',
332
+ };
333
+ // Content flags shared by notify / ask / hello.
334
+ const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'subtitle', 'template', 'profile',
335
+ 'event-at', 'lead-minutes', 'urgency', 'image', 'file', 'url', 'copy', 'actions',
336
+ 'custom-action', 'deliver-at', 'reply-to', 'correlation-id', 'thread', 'after',
337
+ 'collapse-key', 'param'];
338
+
339
+ const HELP = {
340
+ setup: {
341
+ summary: 'one-shot onboarding (#110): exchange a single-use claim code for the channel key, store it, run doctor.',
342
+ usage: 'pidge setup --claim CODE [--url BASE] [--print] [--force] [--listen-mode MODE]',
343
+ body: 'The CLI writes the key itself (chmod 600) — it never appears on screen or in the agent\'s chat. MULTI-AGENT: set PIDGE_AGENT=<id> at each agent\'s launch for an isolated config.',
344
+ opts: ['claim', 'url-base', 'print', 'force', 'listen-mode'],
345
+ },
346
+ doctor: {
347
+ summary: 'validate the setup WITHOUT exposing secrets (env source, server, key, device reach, realtime probe).',
348
+ usage: 'pidge doctor',
349
+ opts: [],
350
+ },
351
+ whoami: {
352
+ summary: 'which channel does this key speak for (prints the identity JSON).',
353
+ usage: 'pidge whoami',
354
+ opts: [],
355
+ },
356
+ hello: {
357
+ summary: 'first-contact WOW (#217): your channel\'s debut handshake, narrated live by a 3-stage Live Activity. send + wait in one.',
358
+ usage: 'pidge hello [options]',
359
+ body: 'A thin wrapper over `ask --template onboarding` with friendly default copy. Run it as your FIRST contact on a fresh channel.',
360
+ opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
361
+ },
362
+ fyi: {
363
+ summary: 'send passive info the human can read later — no action (#246 type fyi → profile Mensagem).',
364
+ usage: 'pidge fyi --title TEXT [--body TEXT | --body-markdown MD] [--image PATH] [--url URL]',
365
+ body: 'Fire-and-forget: stdout is the raw 201. Use it for logs, registros and neutral summaries — if you need a DECISION use `pidge ask`.',
366
+ opts: [...CONTENT_OPTS],
367
+ },
368
+ report: {
369
+ summary: 'send a curated result/digest the human will want to read now (#246 type report → Relevante).',
370
+ usage: 'pidge report --title TEXT [--body-markdown MD] [--image PATH] [--url URL]',
371
+ body: 'Fire-and-forget, like fyi, but flagged as worth reading now (the feed gives it a highlighted hairline).',
372
+ opts: [...CONTENT_OPTS],
373
+ },
374
+ ask: {
375
+ summary: 'ask the human a yes/no/choice and block until they answer (#246 type ask → Relevante + ação badge).',
376
+ usage: 'pidge ask --title TEXT --actions yes,no,reply [--reply-to URL] [options]',
377
+ body: 'Sends, then holds a WebSocket (or polls) until a TERMINAL answer. REQUIRES a way to answer — --actions (catalog or JSON), --custom-action, or a --template that supplies them. A snooze/reschedule re-fires (ask keeps waiting, prints snooze_until). profile "tracking" is refused (it never produces an answer).',
378
+ opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
379
+ },
380
+ event: {
381
+ summary: 'surface a scheduled thing with a known time — countdown Live Activity (#246 type event → Evento).',
382
+ usage: 'pidge event --title TEXT --event-at ISO8601 [--lead-minutes N] [--body-markdown MD]',
383
+ body: 'REQUIRES --event-at (ISO8601, e.g. 2026-06-26T14:00-03:00 — no offset ⇒ the user\'s timezone). --lead-minutes (5–240) starts the countdown N min before.',
384
+ opts: [...CONTENT_OPTS],
385
+ },
386
+ alert: {
387
+ summary: 'flag an anomaly/error needing attention; --escalate forces an AlarmKit alarm (#246 type alert → Urgente).',
388
+ usage: 'pidge alert --title TEXT [--body TEXT | --body-markdown MD] [--escalate]',
389
+ body: 'Fire-and-forget. The channel\'s Urgente profile decides the modality; --escalate asks for an AlarmKit alarm that breaks through silent/Focus (the human\'s profile still has the final say).',
390
+ opts: [...CONTENT_OPTS, 'escalate'],
391
+ },
392
+ live: {
393
+ summary: 'track an in-flight task (deploy/build/trip) with incremental updates (#246 type live → Live Activity).',
394
+ usage: 'pidge live --title TEXT [--body TEXT] [--lead-minutes N]',
395
+ body: 'Fire-and-forget. Records the live type; the LA-as-primitive is being built — today the send is delivered as a normal notification.',
396
+ opts: [...CONTENT_OPTS],
397
+ },
398
+ notify: {
399
+ summary: 'DEPRECATED (0.13.x) — send WITHOUT a type; the server falls back to fyi. Use a typed send instead.',
400
+ usage: 'pidge notify [options]',
401
+ body: 'Kept for one minor for compat retro — it warns and still sends (template_kind defaults to fyi server-side; 0.14 will 422). Prefer `pidge fyi/report/ask/event/alert/live`.',
402
+ opts: [...CONTENT_OPTS],
403
+ },
404
+ wait: {
405
+ summary: 'block on an already-sent notification until it is answered (prints chosen_action JSON).',
406
+ usage: 'pidge wait <correlation_id> [options]',
407
+ opts: ['timeout', 'interval', 'realtime', 'no-realtime'],
408
+ },
409
+ cancel: {
410
+ summary: 'cancel a still-scheduled notification before it fires (#56; idempotent; 409 once it reached the phone).',
411
+ usage: 'pidge cancel <correlation_id>',
412
+ opts: [],
413
+ },
414
+ inbox: {
415
+ summary: 'what you sent: the list (default), the pending slice, or counts + answer latency (#83).',
416
+ usage: 'pidge inbox [--pending | --summary] [--all] [--limit N]',
417
+ opts: ['pending', 'summary', 'all-inbox', 'limit'],
418
+ },
419
+ listen: {
420
+ summary: 'block until the human MESSAGES you from the app, print, ACK after the work, exit (#48).',
421
+ usage: 'pidge listen [--timeout N] [--all] [--ack-on-read] [--follow]',
422
+ body: 'One-shot by design (loop it, don\'t daemonize). #170: a read message is DELIVERED (gray ✓✓), NOT done — ack it AFTER the work with `pidge ack --up-to <id>` (a ~10-min lease re-serves un-acked messages, so a crash never loses one).',
423
+ opts: ['timeout', 'all-listen', 'ack-on-read', 'follow', 'interval', 'realtime', 'no-realtime'],
424
+ },
425
+ ack: {
426
+ summary: 'mark messages PROCESSED (green ✓✓) after you handled them, or --renew the lease on a long task (#170).',
427
+ usage: 'pidge ack --up-to <id> | --ids a,b [--renew]',
428
+ opts: ['up-to', 'ids', 'renew'],
429
+ },
430
+ contract: {
431
+ summary: 'DECLARE how you operate (#182) — ADVISORY, never policy (the human SEES if you honor it).',
432
+ usage: 'pidge contract set <key>=<value> | pidge contract show',
433
+ body: 'Keys: keep_connection_alive, mirror_in_origin_session, listen_mode=turn_based|persistent|external_daemon, quiet_when_idle. An unknown key / bad value is rejected locally (exit 1).',
434
+ opts: [],
435
+ },
436
+ selftest: {
437
+ summary: 'prove your listener works by ROUND-TRIP (#205): fire a nonce, run the listener, confirm it acks in time.',
438
+ usage: 'pidge selftest [--window N]',
439
+ body: 'PASS exit 0 / FAIL exit 2 (with the likely cause). Run it as the last onboarding step + whenever sends seem to go unheard.',
440
+ opts: ['window'],
441
+ },
442
+ skill: {
443
+ summary: 'write .claude/skills/pidge/SKILL.md generated from the live manifest (persistent Pidge knowledge for Claude Code).',
444
+ usage: 'pidge skill install',
445
+ opts: [],
446
+ },
447
+ };
448
+
449
+ // Render the focused help for one command, or the global USAGE when the topic is
450
+ // unknown / absent (so `pidge --help` and `pidge help` keep the full overview).
451
+ function helpFor(topic) {
452
+ const h = HELP[topic];
453
+ if (!h) return USAGE;
454
+ const lines = [`pidge ${topic} — ${h.summary}`, '', 'USAGE', ` ${h.usage}`];
455
+ if (h.body) { lines.push('', h.body); }
456
+ if (h.opts && h.opts.length) {
457
+ lines.push('', 'OPTIONS');
458
+ for (const key of h.opts) lines.push(` ${OPTION_DOCS[key] || key}`);
459
+ }
460
+ lines.push('', 'Run `pidge --help` for all commands; GET $PIDGE_URL/api/v1/manifest is the full contract (Bearer auth).');
461
+ return lines.join('\n');
462
+ }
463
+
271
464
  let parsed;
272
465
  try {
273
466
  parsed = parseArgs({ options: OPTIONS, allowPositionals: true });
@@ -276,9 +469,18 @@ try {
276
469
  }
277
470
  const v = parsed.values;
278
471
  const command = parsed.positionals[0];
279
-
280
- // `pidge --help` / `-h` / `help` full help on stdout, exit 0. No command → stderr, exit 1.
281
- if (v.help || command === 'help') { console.log(USAGE); process.exit(0); }
472
+ // #241: silence the manifest-version nag entirely (per run via --quiet-nag, or
473
+ // per environment via PIDGE_QUIET_NAG=1) for scripts and CI where the nudge is noise.
474
+ const QUIET_NAG = !!v['quiet-nag'] || process.env.PIDGE_QUIET_NAG === '1';
475
+
476
+ // Help on stdout, exit 0. #240: `pidge <cmd> --help` / `pidge help <cmd>` show the
477
+ // FOCUSED help for that command (its synopsis + own flags); `pidge --help` / `help`
478
+ // with no command show the global USAGE. No command at all → USAGE on stderr, exit 1.
479
+ if (v.help || command === 'help') {
480
+ const topic = command === 'help' ? parsed.positionals[1] : command;
481
+ console.log(helpFor(topic));
482
+ process.exit(0);
483
+ }
282
484
  if (!command) { console.error(USAGE); process.exit(1); }
283
485
  // `setup` is the command that CREATES the token config — it must run without one.
284
486
  if (!TOKEN && command !== 'setup')
@@ -300,17 +502,52 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
300
502
  }
301
503
 
302
504
  // The server advertises its manifest version on every response. When it's newer
303
- // than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
304
- // the manifest (whats_new) and learns the new capabilities without polling.
305
- const KNOWN_MANIFEST_VERSION = 31;
505
+ // than what this CLI shipped knowing, nudge on stderr — the agent re-reads the
506
+ // manifest (whats_new) and learns the new capabilities without polling.
507
+ const KNOWN_MANIFEST_VERSION = 36;
508
+ const NAG_TTL_MS = 24 * 60 * 60 * 1000; // #241: at most one nag per 24 h
306
509
  let newsWarned = false;
510
+
511
+ // #241: a tiny per-install state cache (~/.config/pidge/state.json, per-agent
512
+ // when PIDGE_AGENT is set — same dir as the env file). Best-effort: a read-only
513
+ // fs just means the throttle falls back to once-per-process. Date is fine here
514
+ // (this is the CLI process, not a workflow script).
515
+ function stateFilePath() { return path.join(pidgeConfigDir(), 'state.json'); }
516
+ function readState() {
517
+ try { return JSON.parse(fs.readFileSync(stateFilePath(), 'utf8')) || {}; } catch { return {}; }
518
+ }
519
+ function writeState(patch) {
520
+ try {
521
+ const next = { ...readState(), ...patch };
522
+ fs.mkdirSync(pidgeConfigDir(), { recursive: true, mode: 0o700 });
523
+ fs.writeFileSync(stateFilePath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
524
+ } catch { /* best-effort — the nag just won't persist its throttle */ }
525
+ }
526
+
307
527
  function checkManifestNews(res) {
308
- const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
309
- if (v > KNOWN_MANIFEST_VERSION && !newsWarned) {
310
- newsWarned = true;
311
- // #119: a pinned npx ref never updates itself — give the CONCRETE command.
312
- console.error(`pidge: the server has NEW capabilities (manifest v${v}; this CLI knows v${KNOWN_MANIFEST_VERSION}) re-read GET $PIDGE_URL/api/v1/manifest (see whats_new) and UPDATE the CLI: npm i -g pidge-cli@latest (npx users: run npx pidge-cli@latest, a pinned ref never self-updates)`);
528
+ if (QUIET_NAG || newsWarned) return;
529
+ const ver = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
530
+ // (c) only when the server is ahead of what THIS CLI knows.
531
+ if (!(ver > KNOWN_MANIFEST_VERSION)) return;
532
+ // #241 throttle: nag at most once per 24 h, and after that window only when the
533
+ // server version actually CHANGED — so 5 calls in a row (or a steady server)
534
+ // don't re-spam. A recent OR unchanged record suppresses; the record's seenAt is
535
+ // stamped only on a real nag (suppressed runs don't roll the 24 h clock forward).
536
+ const last = readState().manifestVersion;
537
+ if (last && last.seenAt) {
538
+ const recent = (Date.now() - Date.parse(last.seenAt)) < NAG_TTL_MS; // (a)
539
+ const unchanged = last.value === ver; // (b)
540
+ if (recent || unchanged) { newsWarned = true; return; }
313
541
  }
542
+ newsWarned = true;
543
+ writeState({ manifestVersion: { value: ver, seenAt: new Date().toISOString() } });
544
+ // #26: pidge is a THIN PIPE — a server manifest bump almost never needs a CLI
545
+ // release, because --param carries any new /notify field NOW. So the nudge is
546
+ // "new capabilities + how to use them today", NOT "your CLI is stale, update it".
547
+ // #249-A: the manifest is PUBLIC — the curl reads the catalog without a key
548
+ // (a key only adds your channel's own config). Updating the CLI is the LAST,
549
+ // optional step (only to gain native flags), never the headline.
550
+ console.error(`pidge: the server has NEW capabilities (manifest v${ver}; this CLI knows v${KNOWN_MANIFEST_VERSION}) — pidge is a thin pipe, so you can use any new /notify field RIGHT NOW via --param KEY=VALUE. Read the catalog (whats_new) in the public manifest: curl $PIDGE_URL/api/v1/manifest (public; add -H "Authorization: Bearer $PIDGE_TOKEN" to also see your channel's config). Updating the CLI only matters to gain native flags: npx pidge-cli@latest (a pinned ref never self-updates). Silence this with --quiet-nag or PIDGE_QUIET_NAG=1.`);
314
551
  }
315
552
 
316
553
  // ---------------------------------------------------------------------------
@@ -408,7 +645,9 @@ function cableSubscribe({ channel, onUp, onFrame, onDown, base = BASE, token = T
408
645
  if (f.identifier === identifier && f.message) onFrame(f.message);
409
646
  };
410
647
  ws.onerror = () => { /* onclose follows with the code */ };
411
- ws.onclose = (e) => die(`socket closed (${e.code})`);
648
+ // #25: the reconnect log prefixes "realtime socket …", so the reason must NOT
649
+ // start with "socket" again (was "socket socket closed (1006)").
650
+ ws.onclose = (e) => die(`closed (${e.code})`);
412
651
  return { close: () => { closed = true; clearInterval(beatCheck); try { ws.close(); } catch { /* noop */ } } };
413
652
  }
414
653
 
@@ -418,7 +657,8 @@ function cableSubscribe({ channel, onUp, onFrame, onDown, base = BASE, token = T
418
657
  // `finish(reason)` to end the session (e.g. when the answer landed over HTTP).
419
658
  // Resolves 'deadline' | 'ws-unavailable'.
420
659
  async function cableSession({ channel, deadline, onUp, onFrame }) {
421
- let wsFails = 0;
660
+ let wsFails = 0; // consecutive drops SINCE the last healthy connect — the degrade gate
661
+ let wsReconnects = 0; // monotonic total this session — what we DISPLAY (never reset)
422
662
  while (Date.now() < deadline) {
423
663
  const outcome = await new Promise((resolve) => {
424
664
  let sub = null;
@@ -441,12 +681,17 @@ async function cableSession({ channel, deadline, onUp, onFrame }) {
441
681
  if (outcome === 'deadline') return 'deadline';
442
682
  if (!outcome.startsWith('down: ')) return outcome; // caller-driven finish (e.g. 'answered')
443
683
  wsFails++;
684
+ wsReconnects++;
444
685
  const MAX_WS_FAILS = 4; // then fall back to polling for the rest of the session
445
686
  if (wsFails >= MAX_WS_FAILS) return 'ws-unavailable';
446
687
  // env override = a test/ops hook (keeps the forced-1006 degrade test fast)
447
688
  const base = parseInt(process.env.PIDGE_WS_BACKOFF_MS || '2000', 10) || 2000;
448
689
  const backoff = Math.min(base * wsFails, base * 5);
449
- console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} reconnecting in ${Math.round(backoff / 1000)}s (attempt ${wsFails}/${MAX_WS_FAILS})`);
690
+ // #25: show the MONOTONIC reconnect count, not the consecutive-fail counter
691
+ // a connect→drop FLAP resets wsFails (onUp forgives a healthy connect), so the
692
+ // old "attempt 1/4" repeated forever and looked like a stuck loop. The cumulative
693
+ // "#N" visibly advances; the polling fallback is spelled out so the ceiling is clear.
694
+ console.error(`pidge: realtime socket ${outcome.replace('down: ', '')} — reconnecting in ${Math.round(backoff / 1000)}s (reconnect #${wsReconnects}; falls back to polling after ${MAX_WS_FAILS} consecutive failures)`);
450
695
  await sleep(backoff);
451
696
  }
452
697
  return 'deadline';
@@ -487,8 +732,52 @@ function probeRealtime(base, token) {
487
732
  });
488
733
  }
489
734
 
490
- // Map CLI flags the /notify JSON body, including only what was provided.
491
- function buildBody() {
735
+ // #242: a custom action id is lowercase letters, digits and underscore (≤40)
736
+ // the same rule the server enforces, validated LOCALLY so a typo fails fast.
737
+ const CUSTOM_ACTION_ID = /^[a-z0-9_]{1,40}$/;
738
+
739
+ // --custom-action "id:label[:destructive][:confirm][:biometric][:terminal]"
740
+ function customActionFromSpec(spec) {
741
+ const [id, label, ...flags] = spec.split(':');
742
+ // #157 P2: fail fast locally — the rule is stable and the server 422 costs a
743
+ // round-trip an agent then has to interpret.
744
+ if (!CUSTOM_ACTION_ID.test(id || '')) {
745
+ die(`pidge: --custom-action id ${JSON.stringify(id)} is invalid — lowercase letters, digits and underscore only (^[a-z0-9_]{1,40}$)`, 1);
746
+ }
747
+ const ca = { id, label };
748
+ if (flags.includes('destructive')) ca.style = 'destructive';
749
+ if (flags.includes('confirm')) ca.confirm = true;
750
+ if (flags.includes('biometric')) ca.biometric = true;
751
+ if (flags.includes('terminal')) ca.terminal = true;
752
+ return ca;
753
+ }
754
+
755
+ // #242: one item of a JSON --actions array → a custom_actions spec. Validates
756
+ // {id,label} and passes the optional gating fields the server understands.
757
+ function customActionFromJson(item, i) {
758
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
759
+ die(`pidge: --actions[${i}] must be an object with "id" and "label" (e.g. {"id":"approve","label":"Aprovar agora"})`, 1);
760
+ }
761
+ if (typeof item.id !== 'string' || !CUSTOM_ACTION_ID.test(item.id)) {
762
+ die(`pidge: --actions[${i}].id ${JSON.stringify(item.id)} is invalid — lowercase letters, digits and underscore only (^[a-z0-9_]{1,40}$)`, 1);
763
+ }
764
+ if (typeof item.label !== 'string' || !item.label.trim()) {
765
+ die(`pidge: --actions[${i}].label is required — a non-empty string`, 1);
766
+ }
767
+ const ca = { id: item.id, label: item.label };
768
+ if (item.sf_symbol !== undefined) ca.sf_symbol = item.sf_symbol;
769
+ if (item.style !== undefined) ca.style = item.style;
770
+ if (item.destructive) ca.style = 'destructive';
771
+ if (item.confirm !== undefined) ca.confirm = !!item.confirm;
772
+ if (item.biometric !== undefined) ca.biometric = !!item.biometric;
773
+ if (item.terminal !== undefined) ca.terminal = !!item.terminal;
774
+ return ca;
775
+ }
776
+
777
+ // Map CLI flags → the /notify JSON body, including only what was provided. `extra`
778
+ // carries subcommand-supplied raw fields (#246: the typed sends' template_kind and
779
+ // alert's escalate) — merged below, before the --param escape hatch.
780
+ function buildBody(extra = {}) {
492
781
  if (!v.title) die('pidge: --title is required', 1);
493
782
  const body = { title: v.title };
494
783
  if (v.body !== undefined) body.body = v.body;
@@ -507,25 +796,31 @@ function buildBody() {
507
796
  if (v.thread !== undefined) body.thread_id = v.thread;
508
797
  if (v.after !== undefined) body.after = v.after;
509
798
  if (v['collapse-key'] !== undefined) body.collapse_key = v['collapse-key'];
510
- if (v.actions !== undefined) body.actions = v.actions.split(',').filter(Boolean);
511
-
512
- const customs = v['custom-action'] || [];
513
- if (customs.length) {
514
- body.custom_actions = customs.map((spec) => {
515
- const [id, label, ...flags] = spec.split(':');
516
- // #157 P2: fail fast locally — the rule is stable and the server 422
517
- // costs a round-trip an agent then has to interpret.
518
- if (!/^[a-z0-9_]{1,40}$/.test(id || '')) {
519
- die(`pidge: --custom-action id ${JSON.stringify(id)} is invalid — lowercase letters, digits and underscore only (^[a-z0-9_]{1,40}$)`, 1);
520
- }
521
- const ca = { id, label };
522
- if (flags.includes('destructive')) ca.style = 'destructive';
523
- if (flags.includes('confirm')) ca.confirm = true;
524
- if (flags.includes('biometric')) ca.biometric = true;
525
- if (flags.includes('terminal')) ca.terminal = true;
526
- return ca;
527
- });
799
+
800
+ // --actions: the short comma form (built-in catalog ids → body.actions) OR a
801
+ // JSON array of custom {id,label,…} specs (#242 → body.custom_actions). A
802
+ // leading '[' selects JSON; bad JSON is a friendly LOCAL error (exit 1), never
803
+ // a silent fall-through that drops the labels and sends a plain notification.
804
+ // --custom-action specs APPEND to whatever the JSON form produced, so both can coexist.
805
+ const customActions = [];
806
+ if (v.actions !== undefined) {
807
+ const trimmed = v.actions.trim();
808
+ if (trimmed.startsWith('[')) {
809
+ let arr;
810
+ try { arr = JSON.parse(trimmed); }
811
+ catch (e) { die(`pidge: --actions looks like JSON but didn't parse (${e.message}). Use a JSON array of {"id","label"} objects, or the short form yes,no,reply`, 1); }
812
+ if (!Array.isArray(arr)) die('pidge: --actions JSON must be an ARRAY of {"id","label"} objects', 1);
813
+ arr.forEach((item, i) => customActions.push(customActionFromJson(item, i)));
814
+ } else {
815
+ body.actions = trimmed.split(',').filter(Boolean);
816
+ }
528
817
  }
818
+ for (const spec of v['custom-action'] || []) customActions.push(customActionFromSpec(spec));
819
+ if (customActions.length) body.custom_actions = customActions;
820
+
821
+ // #246: subcommand-supplied raw fields (template_kind, alert's escalate). Applied
822
+ // before the --param loop so a raw --param can still override in a pinch.
823
+ Object.assign(body, extra);
529
824
 
530
825
  // Escape hatch: any raw /notify field, so a NEW server field documented in the
531
826
  // manifest works the day it ships — no CLI release needed. JSON values parse
@@ -597,8 +892,8 @@ async function resolveMedia(body) {
597
892
  // POST /notify. Returns { ok, info, raw }. Emits to STDERR what an agent most
598
893
  // needs to KNOW (0 devices / no banner buttons / an armed alarm / a policy
599
894
  // degrade), so stdout stays free for machine output.
600
- async function doNotify() {
601
- const payload = buildBody();
895
+ async function doNotify(extra = {}) {
896
+ const payload = buildBody(extra);
602
897
  await resolveMedia(payload);
603
898
  let res, raw;
604
899
  try {
@@ -637,6 +932,25 @@ async function doNotify() {
637
932
  return { ok, info, raw };
638
933
  }
639
934
 
935
+ // #246: the typed send subcommands (fyi/report/event/alert/live) share notify's
936
+ // fire-and-forget shape — stamp template_kind, POST, print the raw 201, exit
937
+ // (0 ok / 2 failed). `ask` is the one type that send+waits (it needs a decision)
938
+ // and so keeps its own case. `extra` carries alert's escalate:true.
939
+ async function doTypedNotify(kind, extra = {}) {
940
+ const { ok, info, raw } = await doNotify({ template_kind: kind, ...extra });
941
+ console.log(raw);
942
+ if (ok && info.correlation_id)
943
+ console.error(`pidge: correlation_id=${info.correlation_id} (use: pidge wait ${info.correlation_id})`);
944
+ process.exit(ok ? 0 : 2);
945
+ }
946
+
947
+ // #246: `pidge notify` / `pidge send` (no type) are deprecated for ONE minor
948
+ // (0.13.x) — they still send, and the server falls back to template_kind "fyi"
949
+ // (soft-rollout). 0.14 will 422 a typeless send. The warning is local (stderr).
950
+ function warnDeprecatedSend(name) {
951
+ console.error(`pidge: \`pidge ${name}\` is deprecated — use a TYPE instead: fyi · report · ask · event · alert · live (see \`pidge help\`). Server-side fallback to \`fyi\` continues in 0.13.x; will be removed in 0.14.`);
952
+ }
953
+
640
954
  // Poll GET /notifications/:cid until a TERMINAL answer, print chosen_action JSON to
641
955
  // stdout, exit 0. A snooze (snooze / reschedule-to-a-time) is non-terminal — it
642
956
  // re-fires — so keep waiting through it. Exits 3 on timeout.
@@ -1262,6 +1576,24 @@ Generated from manifest v${m.manifest_version} of ${BASE} — re-run \`pidge ski
1262
1576
 
1263
1577
  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).
1264
1578
 
1579
+ ## Choose the right type (REQUIRED in 0.14+)
1580
+
1581
+ Every send needs a type. Pick by intent:
1582
+
1583
+ | You want to... | Use | Example |
1584
+ |---|---|---|
1585
+ | Log something the human can read later, no action | \`pidge fyi\` | "Build completed in 2m12s" |
1586
+ | Deliver a curated result/digest worth reading now | \`pidge report\` | "Daily standup summary" |
1587
+ | Ask the human a yes/no/choice — block until they answer | \`pidge ask\` | "Approve deploy v3.2?" with \`--actions yes,no\` |
1588
+ | Surface a scheduled thing (with time) | \`pidge event\` | "Sprint review 14h" with \`--event-at ...\` |
1589
+ | Anomaly/error needing attention; add \`--escalate\` for AlarmKit | \`pidge alert\` | "API 503 errors spiked" |
1590
+ | Track an in-flight task with incremental updates | \`pidge live\` | "Deploy v3.2 — building..." |
1591
+
1592
+ If unsure: \`fyi\` for passive info, \`ask\` if you need a decision. NEVER use \`pidge send\`
1593
+ without a type — in 0.14 it'll 422. (In 0.13.x it warns locally + server falls back to fyi.)
1594
+
1595
+ Available CLI commands (typed sends): \`pidge fyi\` · \`pidge report\` · \`pidge ask\` · \`pidge event\` · \`pidge alert\` · \`pidge live\` (and \`pidge notify\`, deprecated). Run \`pidge <type> --help\` for each one's own flags.
1596
+
1265
1597
  ## Pick the right send (decision table)
1266
1598
 
1267
1599
  ${table.map((r) => `- ${r}`).join('\n')}
@@ -1280,6 +1612,25 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1280
1612
  - \`pidge listen\` blocks until the human MESSAGES you from the app (composer) — run it when idle.
1281
1613
  - ${exits}
1282
1614
 
1615
+ ## Stay "always-on" while you're turn-based (#244)
1616
+
1617
+ A turn-based agent (Claude Code, ChatGPT, anything that only runs when invoked) can still be COMMANDABLE by your human. Two ways, neither needs a daemon:
1618
+
1619
+ ### Path 1 — an interactive listening window (active session)
1620
+ \`\`\`bash
1621
+ pidge listen --follow --timeout 300 # hold for 5 min (--timeout is SECONDS), printing messages as they arrive
1622
+ \`\`\`
1623
+ Good while you're actively working. You stay online until the window closes. \`--follow\` is supervisor-style — it traps the turn — so only use it when you intend to sit and wait.
1624
+
1625
+ ### Path 2 — a supervisor that polls, no daemon (24/7)
1626
+ A \`cron\` job or \`systemd\` timer invokes you every N minutes; each tick runs ONE one-shot listen and exits:
1627
+ \`\`\`bash
1628
+ pidge listen --timeout 50 # block up to 50s for a message, print it, exit 0 (exit 3 = nothing this tick)
1629
+ \`\`\`
1630
+ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --up-to <id>\`, then sleep until the next tick. Real always-on without being a daemon. With Claude Code, the built-in \`/loop\` (auto-wake every N min) drives the same loop.
1631
+
1632
+ > \`--timeout\` is always SECONDS (not "5m"). One-shot \`pidge listen\` is the polling primitive — loop it from your supervisor; do NOT background it with \`&\` (an orphaned listener eats the queue).
1633
+
1283
1634
  ## Full spec
1284
1635
 
1285
1636
  \`curl $PIDGE_URL/api/v1/manifest -H "Authorization: Bearer $PIDGE_TOKEN"\` — the always-current contract (fields, templates, custom actions, media, threads, realtime).
@@ -1321,7 +1672,34 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1321
1672
  await runSkillInstall();
1322
1673
  break;
1323
1674
  }
1324
- case 'notify': {
1675
+ // #246: typed sends — fyi/report/event/alert/live stamp template_kind and
1676
+ // fire-and-forget. ask is separate (it send+waits). notify/send are the
1677
+ // deprecated typeless path (server falls back to fyi during the soft-rollout).
1678
+ case 'fyi':
1679
+ case 'report':
1680
+ await doTypedNotify(command);
1681
+ break;
1682
+ case 'event': {
1683
+ // event needs a TIME — validate locally (ISO8601) so the agent fails fast
1684
+ // instead of taking the server's event_at_required 422 round-trip.
1685
+ if (v['event-at'] === undefined)
1686
+ die('pidge: --event-at required for event. Use ISO8601: --event-at 2026-06-26T14:00-03:00', 1);
1687
+ if (Number.isNaN(Date.parse(v['event-at'])))
1688
+ die(`pidge: --event-at ${JSON.stringify(v['event-at'])} is not a valid ISO8601 datetime. Use e.g. --event-at 2026-06-26T14:00-03:00`, 1);
1689
+ await doTypedNotify('event');
1690
+ break;
1691
+ }
1692
+ case 'alert':
1693
+ // --escalate ⇒ escalate:true (ask the channel's Urgente profile for an
1694
+ // AlarmKit alarm that breaks through silent/Focus; the human's profile decides).
1695
+ await doTypedNotify('alert', v.escalate ? { escalate: true } : {});
1696
+ break;
1697
+ case 'live':
1698
+ await doTypedNotify('live');
1699
+ break;
1700
+ case 'notify':
1701
+ case 'send': {
1702
+ warnDeprecatedSend(command);
1325
1703
  const { ok, info, raw } = await doNotify();
1326
1704
  console.log(raw);
1327
1705
  if (ok && info.correlation_id)
@@ -1361,13 +1739,18 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1361
1739
  if (v.profile === 'tracking')
1362
1740
  die('pidge: `ask --profile tracking` makes no sense — tracking never produces an answer (use the live_activities API; need a decision? send a real profile)', 1);
1363
1741
  if (!v.title) die('pidge: --title is required', 1);
1742
+ // #246: an ask DECLARES a decision — it must say HOW the human answers.
1743
+ // --actions (catalog or JSON), --custom-action, or a --template that supplies
1744
+ // them all satisfy it; none ⇒ a local error (the spec's "no hidden default").
1745
+ if (v.actions === undefined && !(v['custom-action'] || []).length && v.template === undefined)
1746
+ die('pidge: --actions required for ask. Use --actions yes,no,reply or a JSON array.', 1);
1364
1747
  // The cid is minted CLIENT-side when not given, and printed as the FIRST
1365
1748
  // stderr line (greppable) — a killed/crashed ask always leaves the handle
1366
1749
  // behind, so the agent can `pidge wait <cid>` instead of re-sending.
1367
1750
  const cid = v['correlation-id'] || crypto.randomUUID();
1368
1751
  v['correlation-id'] = cid;
1369
1752
  console.error(`pidge: correlation_id=${cid}`);
1370
- const { ok, info } = await doNotify();
1753
+ const { ok, info } = await doNotify({ template_kind: 'ask' });
1371
1754
  if (!ok) process.exit(2);
1372
1755
  console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
1373
1756
  // #132: no --timeout ⇒ obey the template's suggestion from the 201 echo
@@ -1669,6 +2052,8 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1669
2052
  break;
1670
2053
  }
1671
2054
  default:
1672
- die(USAGE, 1);
2055
+ // #246: name the bad command and point at the type catalog (a friendlier
2056
+ // landing than dumping the whole USAGE on a typo).
2057
+ die(`pidge: unknown subcommand '${command}'. Try: fyi · report · ask · event · alert · live · notify (deprecated). pidge --help`, 1);
1673
2058
  }
1674
2059
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.11.1",
3
+ "version": "0.13.1",
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",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "bin",
20
20
  "README.md",
21
+ "CHANGELOG.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "scripts": {