pidge-cli 0.14.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 — #274 CLI redesign (F1)
4
+
5
+ -m/--body-markdown-file input chain, --gated, English hello, --template off the help menu (still accepted), nag knows v46, --wait defaults to 60 min for decisions.
6
+
7
+ F3/F4: skill rewritten (two approval paths, English gold examples, no content_template menu, appendix from v46); setup → skill → hello fuse with graceful-degrade.
8
+
3
9
  ## 0.14.0 — 2026-06-28
4
10
 
5
11
  The married vocabulary (perfis) — the CLI now speaks the SAME language as the server
package/bin/pidge.js CHANGED
@@ -107,7 +107,8 @@ const OPTIONS = {
107
107
  help: { type: 'boolean', short: 'h' },
108
108
  title: { type: 'string' },
109
109
  body: { type: 'string' },
110
- 'body-markdown': { type: 'string' },
110
+ 'body-markdown': { type: 'string', short: 'm' },
111
+ 'body-markdown-file': { type: 'string' }, // a path, or "-" to read stdin (#274)
111
112
  subtitle: { type: 'string' },
112
113
  template: { type: 'string' }, // content/action pattern (manifest `templates`)
113
114
  profile: { type: 'string' }, // delivery profile id (manifest `profiles`)
@@ -115,6 +116,7 @@ const OPTIONS = {
115
116
  'lead-minutes': { type: 'string' }, // notify/countdown lead before event_at
116
117
  urgency: { type: 'string' }, // normal | persistent | alarm (low-level — prefer --profile)
117
118
  escalate: { type: 'boolean' }, // #246: alert type — force an AlarmKit alarm (escalate:true)
119
+ gated: { type: 'boolean' }, // #274: one Face-ID confirm action (replaces content_template:sensitive)
118
120
  image: { type: 'string' }, // banner+feed image: local path → uploaded; URL → as-is
119
121
  file: { type: 'string' }, // real artifact (xlsx/pdf/csv…): local path → uploaded
120
122
  url: { type: 'string' }, // deep link the app opens on tap (#45)
@@ -237,9 +239,8 @@ OPTIONS (notify / ask)
237
239
  --body TEXT message shown on the banner
238
240
  --body-markdown MD rich body for the tap-through detail screen
239
241
  --subtitle TEXT
240
- --template ID content/action pattern WHAT you're asking: context (FYI,
241
- no buttons) · decision (yes/no/reply) · approval · reminder ·
242
- nudge · sensitive (gated, Face ID). Composes with --profile.
242
+ --gated add a Face-ID confirm on the consequential action (money/deletion)
243
+ --body-markdown-file F read the markdown body from a file (or "-" for stdin)
243
244
  --profile ID low-level alias of the TYPE axis (the HUMAN owns what it
244
245
  does): message · important · urgent · event · live ·
245
246
  the user's custom profiles. Prefer the typed subcommands
@@ -314,8 +315,9 @@ const OPTION_DOCS = {
314
315
  title: '--title TEXT (required) the headline',
315
316
  body: '--body TEXT the message shown on the banner',
316
317
  'body-markdown': '--body-markdown MD rich body for the tap-through detail screen',
318
+ 'body-markdown-file': '--body-markdown-file F read the markdown body from a file (or "-" for stdin) — avoids shell-quoting long markdown',
317
319
  subtitle: '--subtitle TEXT a secondary line under the title',
318
- template: '--template ID content/action pattern: context · decision · approval · reminder · nudge · sensitive',
320
+ gated: '--gated add a Face-ID confirm on the consequential action (money/deletion). Pair with a louder profile if it must also be loud.',
319
321
  profile: '--profile ID low-level alias of the TYPE (the human owns it): message · important · urgent · event · live · custom',
320
322
  'event-at': '--event-at ISO8601 WHEN the thing happens (required by event)',
321
323
  'lead-minutes': '--lead-minutes N notify/countdown N min before event_at (5–240)',
@@ -358,13 +360,13 @@ const OPTION_DOCS = {
358
360
  'quiet-nag': '--quiet-nag silence the "server has new capabilities" nag for this run',
359
361
  };
360
362
  // Content flags shared by every send.
361
- const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'subtitle', 'template', 'profile',
363
+ const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'body-markdown-file', 'subtitle', 'template', 'profile',
362
364
  'event-at', 'lead-minutes', 'urgency', 'image', 'file', 'url', 'copy', 'actions',
363
365
  'custom-action', 'deliver-at', 'reply-to', 'correlation-id', 'thread', 'after',
364
366
  'collapse-key', 'param'];
365
367
  // Typed sends also carry the RESPONSE axis: --wait (block on the answer) + the
366
368
  // blocking knobs. (`live` is status-only — it never answers, so it skips these.)
367
- const SEND_OPTS = [...CONTENT_OPTS, 'wait', 'timeout', 'interval', 'realtime', 'no-realtime'];
369
+ const SEND_OPTS = [...CONTENT_OPTS, 'gated', 'wait', 'timeout', 'interval', 'realtime', 'no-realtime'];
368
370
 
369
371
  const HELP = {
370
372
  setup: {
@@ -386,7 +388,7 @@ const HELP = {
386
388
  hello: {
387
389
  summary: 'first-contact WOW (#217): your channel\'s debut handshake, narrated live by a 3-stage Live Activity. send + wait in one.',
388
390
  usage: 'pidge hello [options]',
389
- body: 'A thin wrapper over `ask --template onboarding` with friendly default copy. Run it as your FIRST contact on a fresh channel.',
391
+ body: 'First contact on a fresh channel: send the debut handshake and block until your human confirms. The server narrates a 3-stage Live Activity.',
390
392
  opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
391
393
  },
392
394
  // AXIS 1 — the married catalog of 5 (perfis-S1/S2). The TYPE you pick IS how the
@@ -425,7 +427,7 @@ const HELP = {
425
427
  ask: {
426
428
  summary: 'a DECISION — = important + --wait; needs --actions. Blocks until the human answers (prints chosen_action JSON).',
427
429
  usage: 'pidge ask --title TEXT --actions yes,no,reply [--reply-to URL] [options]',
428
- body: 'Shorthand for `important --wait` that REQUIRES a way to answer — --actions (catalog or JSON), --custom-action, or a --template that supplies them. Holds a WebSocket (or polls) until a TERMINAL answer; a snooze/reschedule re-fires (ask keeps waiting, prints snooze_until). `live` is refused (it never answers).',
430
+ body: 'Shorthand for important --wait that REQUIRES a way to answer — --actions (catalog or JSON) or --custom-action. Holds a WebSocket (or polls) until a TERMINAL answer; a snooze/reschedule re-fires.',
429
431
  opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
430
432
  },
431
433
  approval: {
@@ -559,7 +561,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
559
561
  // The server advertises its manifest version on every response. When it's newer
560
562
  // than what this CLI shipped knowing, nudge on stderr — the agent re-reads the
561
563
  // manifest (whats_new) and learns the new capabilities without polling.
562
- const KNOWN_MANIFEST_VERSION = 42;
564
+ const KNOWN_MANIFEST_VERSION = 46;
563
565
  const NAG_TTL_MS = 24 * 60 * 60 * 1000; // #241: at most one nag per 24 h
564
566
  let newsWarned = false;
565
567
 
@@ -836,7 +838,13 @@ function buildBody(extra = {}) {
836
838
  if (!v.title) die('pidge: --title is required', 1);
837
839
  const body = { title: v.title };
838
840
  if (v.body !== undefined) body.body = v.body;
839
- if (v['body-markdown'] !== undefined) body.body_markdown = v['body-markdown'];
841
+ if (v['body-markdown-file'] !== undefined) {
842
+ body.body_markdown = v['body-markdown-file'] === '-'
843
+ ? fs.readFileSync(0, 'utf8')
844
+ : fs.readFileSync(v['body-markdown-file'], 'utf8');
845
+ } else if (v['body-markdown'] !== undefined) {
846
+ body.body_markdown = v['body-markdown'];
847
+ }
840
848
  if (v.subtitle !== undefined) body.subtitle = v.subtitle;
841
849
  if (v.template !== undefined) body.template = v.template;
842
850
  if (v.profile !== undefined) body.profile = v.profile;
@@ -873,6 +881,15 @@ function buildBody(extra = {}) {
873
881
  for (const spec of v['custom-action'] || []) customActions.push(customActionFromSpec(spec));
874
882
  if (customActions.length) body.custom_actions = customActions;
875
883
 
884
+ // #274: --gated synthesizes ONE Face-ID confirm on the consequential action
885
+ // (money/deletion) — the replacement for the retired content_template:sensitive.
886
+ // Skip if the agent already supplied a biometric action (don't double-gate).
887
+ if (v.gated && !(body.custom_actions || []).some((c) => c.biometric)) {
888
+ body.custom_actions = (body.custom_actions || []).concat([
889
+ { id: 'confirm_action', label: 'Confirm', style: 'destructive', confirm: true, biometric: true, terminal: true },
890
+ ]);
891
+ }
892
+
876
893
  // #246: subcommand-supplied raw fields (template_kind, alert's escalate). Applied
877
894
  // before the --param loop so a raw --param can still override in a pinch.
878
895
  Object.assign(body, extra);
@@ -1018,7 +1035,7 @@ async function doTypedSend(kind, { wait = false, extra = {}, requireAnswerable =
1018
1035
  if (wait && (kind === 'live' || v.profile === 'tracking'))
1019
1036
  die(`pidge: \`${label}\`${kind === 'live' ? '' : ' --profile tracking'} can't --wait — ${kind === 'live' ? '`live` is' : 'tracking is'} status-only and never produces an answer (drop --wait, or ask with a real type)`, 1);
1020
1037
  if (requireAnswerable && !hasAnswerAffordance())
1021
- die(`pidge: --actions required for ${label}. Use --actions yes,no (or approve,reject), --custom-action, or a --template that supplies them.`, 1);
1038
+ die(`pidge: --actions required for ${label}. Add buttons with --actions yes,no (or approve,reject) or --custom-action id:label.`, 1);
1022
1039
 
1023
1040
  if (!wait) {
1024
1041
  const { ok, info, raw } = await doNotify({ template_kind: kind, ...extra });
@@ -1044,6 +1061,9 @@ async function doTypedSend(kind, { wait = false, extra = {}, requireAnswerable =
1044
1061
  if (info.suggested_ask_timeout) {
1045
1062
  timeout = info.suggested_ask_timeout;
1046
1063
  console.error(`pidge: timeout ${Math.round(timeout / 60)} min — suggested by template ${info.template || v.template} (override with --timeout)`);
1064
+ } else if (info.requires_action) {
1065
+ timeout = 3600; // #274/#132: a human decision (buttons present) takes 30-40 min, not 600 s of "silence"
1066
+ console.error(`pidge: no template suggestion — defaulting --wait to 60 min for a decision (override with --timeout)`);
1047
1067
  } else {
1048
1068
  timeout = 600;
1049
1069
  }
@@ -1569,9 +1589,9 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
1569
1589
  console.error(`pidge doctor: realtime: INDISPONÍVEL — ${rt.reason}. O \`listen\` degrada pra polling (funciona, menos instantâneo); use --no-realtime pra fixar o piso.`);
1570
1590
  }
1571
1591
  // #229: lead with `pidge hello` — the first-contact WOW (send + wait in one),
1572
- // the same debut the /agent-setup guide leads with. It's a thin wrapper over
1573
- // `ask --template onboarding` (the underlying mechanism, if you need it raw).
1574
- console.error('pidge doctor: all good — try: pidge hello (first-contact WOW — send + wait in one; equivalent: pidge ask --template onboarding)');
1592
+ // the same debut the /agent-setup guide leads with. (#274: no --template hint
1593
+ // `pidge hello` IS the entry point; the content_template surface is off the menu.)
1594
+ console.error('pidge doctor: all good — try: pidge hello (first-contact WOW — send + wait in one)');
1575
1595
  console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version, realtime }));
1576
1596
  process.exit(0);
1577
1597
  }
@@ -1638,6 +1658,7 @@ async function runSetup() {
1638
1658
  console.log(`export PIDGE_URL=${finalBase}`);
1639
1659
  console.log(`export PIDGE_TOKEN=${data.key}`);
1640
1660
  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.`);
1661
+ await fuseSkillAndHello(finalBase, data.key);
1641
1662
  await runDoctor(finalBase, data.key, 'fresh claim (per-agent env — not stored on disk)');
1642
1663
  return;
1643
1664
  }
@@ -1658,77 +1679,150 @@ async function runSetup() {
1658
1679
  }
1659
1680
  if (!AGENT_ID)
1660
1681
  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.');
1682
+ await fuseSkillAndHello(finalBase, data.key);
1661
1683
  await runDoctor(finalBase, data.key, CONFIG_FILE);
1662
1684
  }
1663
1685
 
1664
- // skill install (#110e): persistent Pidge knowledge for Claude Code agents
1665
- // a skill generated FROM the live manifest (so it can't drift), versioned with
1666
- // manifest_version (re-run to update; whats_new is the changelog).
1667
- async function runSkillInstall() {
1686
+ // #274 F4: setup skill hello. Best-effort, run right BEFORE the post-setup
1687
+ // doctor (runDoctor process.exit()s, so this can't trail it). A skill-install
1688
+ // failure is ONE stderr line NEVER a `--help`/USAGE dump (the graceful-degrade
1689
+ // invariant). `pidge hello` stays a printed NEXT step: we don't auto-fire a push
1690
+ // the human didn't ask for. base+key are the freshly-claimed ones (the manifest
1691
+ // is public, so this works even on the --print path where no token is on disk).
1692
+ async function fuseSkillAndHello(base, token) {
1693
+ try {
1694
+ const r = await installSkill(base, token);
1695
+ console.error(`pidge: skill written to ${r.file} (manifest v${r.manifest_version}) — your future sessions in this project know Pidge now`);
1696
+ } catch (e) {
1697
+ console.error(`pidge: skill install skipped (${e.message}) — run \`pidge skill install\` later.`);
1698
+ }
1699
+ console.error('pidge: next → `pidge hello` to send your first handshake and watch it confirm on the lock screen.');
1700
+ }
1701
+
1702
+ // skill install (#110e; rewritten #274 F3): persistent Pidge knowledge for AI
1703
+ // agents — the live manifest's APPENDIX (profiles / notes / exits) wrapped around
1704
+ // a HAND-AUTHORED, failure-mode-first spine. The dead content_template
1705
+ // `decision_table` is NEVER pulled again, so even an old manifest can't reinject
1706
+ // the v46 collision. Non-exiting: RETURNS {file, manifest_version} and THROWS on
1707
+ // failure, so callers (`skill install` AND the setup fuse) choose die-vs-degrade.
1708
+ async function installSkill(base = BASE, token = TOKEN) {
1709
+ const hdrs = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
1668
1710
  let res, m;
1669
1711
  try {
1670
- res = await fetchT(`${BASE}/api/v1/manifest`, { headers });
1712
+ res = await fetchT(`${base}/api/v1/manifest`, { headers: hdrs });
1671
1713
  m = await res.json();
1672
1714
  } catch (e) {
1673
- die(`pidge: could not read the manifest: ${e.message}`, 2);
1715
+ throw new Error(`could not read the manifest: ${e.message}`);
1674
1716
  }
1675
- if (res.status !== 200) die(`pidge: manifest read failed (${res.status})`, 2);
1676
- const table = (m.templates && m.templates.decision_table) || [];
1717
+ if (res.status !== 200) throw new Error(`manifest read failed (${res.status})`);
1718
+ // The ONLY generated parts (the appendix). m.templates.* is deliberately UNREAD.
1677
1719
  const profileTable = (m.profiles && m.profiles.decision_table) || [];
1678
1720
  const notes = m.notes || [];
1679
1721
  const exits = (m.cli && m.cli.output) || '';
1680
1722
  const skill = `---
1681
1723
  name: pidge
1682
- description: Send rich, actionable iPhone notifications to your human and get their decision back (Pidge). Pick a type (message/important/urgent/event/live) and, orthogonally, a response (buttons + send-and-go vs wait). Use when finishing long tasks, needing a decision/approval, sending updates with substance, or anything time-anchored. Also covers reading the human's replies/messages back.
1724
+ description: Send rich, actionable iPhone notifications to your human and get their decision back (Pidge). Every send is a TYPE (message/important/urgent/event/live) plus an OPTIONAL response (buttons + send-and-go vs wait). Use when finishing long tasks, needing a decision/approval, sending updates with substance, or anything time-anchored. Also covers reading the human's replies back.
1683
1725
  ---
1684
1726
 
1685
1727
  # Pidge — notify your human, get answers back
1686
1728
 
1687
- 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).
1729
+ 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).
1730
+
1731
+ All commands: \`npx pidge-cli …\` (Node ≥18; reads \`~/.config/pidge/env\` — no token in your context). Not set up? Run \`pidge doctor\`. Onboard with \`pidge setup --claim <code>\` (the human copies the code from the Pidge app), then \`pidge hello\`.
1732
+
1733
+ ## One breath
1688
1734
 
1689
- 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).
1735
+ Every send is **a TYPE + a markdown body + an OPTIONAL response**. The TYPE (one of five) decides how much it may intrude the human already configured how each arrives. The RESPONSE (buttons? wait or not?) is a second, orthogonal axis. **There is no content "template" to choose.**
1690
1736
 
1691
- ## Two axes: the TYPE + the RESPONSE
1737
+ ## THE PICKER situation exact command
1692
1738
 
1693
- You and your human speak the SAME language. You pick ONE **type** (how much it may
1694
- intrude — the human already configured how each arrives); then, ORTHOGONALLY, you
1695
- decide the **response** (buttons? wait or not?).
1739
+ | Your situation | Run |
1740
+ |---|---|
1741
+ | Just inform a result/log, no action needed | \`pidge message\` |
1742
+ | A pendency they should act on (can wait) ⭐ DEFAULT | \`pidge important\` |
1743
+ | You need a decision and CAN'T proceed without it | \`pidge important --actions yes,no --wait\` |
1744
+ | YOU are asking for a formal go/no-go (money/risk) | \`pidge approval\` |
1745
+ | A thing with a known TIME | \`pidge event --event-at <ISO8601>\` |
1746
+ | A live status you'll keep updating | \`pidge live\` |
1747
+ | WAKE them now — rare, real, <1/day | \`pidge urgent\` |
1696
1748
 
1697
- ### Axis 1 the type (one married list of 5)
1749
+ \`important\` is the default. On the fence between informing and asking, pick \`important\`. \`message\` is only for a true no-action FYI. (\`fyi\`/\`report\`/\`ask\`/\`alert\` still work as silent aliases → message/important/important/urgent.) Run \`pidge <type> --help\` for each one's flags.
1698
1750
 
1699
- | You want to... | Use | The human sees / clears when |
1700
- |---|---|---|
1701
- | just inform, no action | \`pidge message\` | quiet banner; clears when they OPEN it |
1702
- | a pendency they should resolve ⭐ DEFAULT | \`pidge important\` | "waiting-for-you" card; clears on **Done** |
1703
- | a go/no-go DECISION (approve/choose) | \`pidge approval\` | Approve/Reject + **Face ID**; clears when they decide |
1704
- | a thing with a known TIME | \`pidge event --event-at <ISO>\` | countdown + reminder; passed / Done |
1705
- | TRACK something live | \`pidge live\` | Live Activity on the lock; you end it |
1706
- | WAKE them now (rare, real) | \`pidge urgent\` | **alarm** through silent/Focus; Done cuts it |
1751
+ ## Approval has two paths know which one you're in
1707
1752
 
1708
- \`important\` is the default on the fence between informing and asking, pick it.
1709
- (Forget \`fyi\`/\`report\` — they're gone; every send is title + markdown, only the
1710
- DELIVERY differs. The old names still work as aliases → message/important/urgent.)
1753
+ **Path A — YOU request it (\`pidge approval\`).** You decided this needs a human sign-off. \`pidge approval\` = \`important\` + an **Approve** (Face-ID gated) / **Reject** pair + \`--wait\`. You send it, you block, and you get \`chosen_action.action_id: "grant"\` (approved) or \`"deny"\` (rejected) back. Use it for money, deletions, irreversible actions.
1711
1754
 
1712
- ### Axis 2 — the response (composes on ANY type)
1755
+ **Path Byour HUMAN requires it (a profile knob).** In the app, the human can turn ON **"Require approval · Face ID"** on any profile (the \`ack_requires_biometric\` knob — **OFF by default everywhere**). When it's ON for, say, \`important\`, then **every ordinary send on that profile silently becomes an Approve-with-Face-ID decision** — even a plain \`pidge important\` with no buttons. The server injects a single \`approve\` action, so the send reads back \`actions:["approve"], requires_action:true, acknowledgeable:false\`, the banner is detail-only, and **the human's tap reaches you as \`chosen_action.action_id: "approve"\`** (poll / webhook / \`pidge listen --all\`). You didn't ask — they imposed it.
1713
1756
 
1714
- "Asking for a reply" is separate from the type you don't need \`approval\` to get a button:
1715
- - **Free text** → ALWAYS available; the human can write back on any notification.
1716
- - **Buttons** optional, any type: \`--actions yes,no\` (catalog) or \`--custom-action\` (e.g. \`confirm/postpone\`).
1717
- - **Face ID** → \`:biometric\` locks a sensitive button (\`approval\` turns it on by default). A flag, not a type.
1757
+ **Same screen ("Approve + Face ID"), opposite origin: you REQUEST (A, ids \`grant\`/\`deny\`) vs they REQUIRE (B, id \`approve\`).** To tell at runtime: a send that comes back \`acknowledgeable:false\` + \`requires_action:true\` when you didn't add buttons means Path B is on for that profile — treat the \`approve\` as the positive decision it is. (To check a profile's knob ahead of time, read \`ack_requires_biometric\` from the live manifest: \`curl $PIDGE_URL/api/v1/manifest -H "Authorization: Bearer $PIDGE_TOKEN"\` → \`profiles\`.) Caution: Path B on a busy profile means one approval per send — the human's deliberate high-trust choice.
1758
+
1759
+ ## The response axis (composes on ANY type)
1760
+
1761
+ Asking for a reply is orthogonal to the type — you don't need \`approval\` to get a button.
1762
+ - **Free text** is always available; the human can write back on anything.
1763
+ - **Buttons** are optional on any type: \`--actions yes,no\` (catalog) or \`--custom-action id:label\`.
1764
+ - **Face ID** on a consequential action: \`--gated\` injects one confirm-with-Face-ID button (use it for money/deletion). It does NOT change loudness — pair with a louder profile if it must also be loud. A flag, not a type.
1718
1765
  - **send-and-go vs wait** — the choice that decides how YOU work:
1719
- - **send-and-go** (fire and continue): the answer arrives later in \`pidge listen --all\`. For a turn-based agent.
1720
- - **wait** (block until they tap): \`--wait\` (or \`pidge ask\`). For when you can't proceed without the decision.
1721
- - \`approval\` is a RECIPE, not magic: = \`important\` + Approve/Reject + Face ID + \`--wait\`.
1766
+ - *send-and-go* (default): fire and continue; the answer arrives later in \`pidge listen --all\`.
1767
+ - *wait*: \`--wait\` (or \`pidge ask\`) **blocks** until they tap. Use it when you can't proceed.
1768
+ - **Exit codes on a \`--wait\`/\`ask\`:** \`0\` = answered (\`chosen_action\` JSON on stdout) · **\`3\` = no answer yet → NOT a failure** (back off, or treat a blocking go/no-go as "no/hold" and re-ask later) · \`2\` = error.
1769
+
1770
+ Need a TYPED reply (a time/value/name)? \`--actions reply\` ALONE — never \`yes,no,reply\` together (the human taps the easy button and you get a useless "Yes"). ONE question per send.
1722
1771
 
1723
- Need a TYPED reply (a time/value/name)? \`--actions reply\` ALONE — never yes/no+reply
1724
- together (the human taps the easy button and you get a useless "Yes"). ONE question per send.
1772
+ ## Anti-slop rules (judgment a recipe can't teach)
1725
1773
 
1726
- Available subcommands: \`pidge message · important · urgent · event · live\` (+ the
1727
- \`ask\`/\`approval\` shortcuts; \`fyi/report/alert\` aliases; \`notify\` deprecated). Run \`pidge <type> --help\` for each one's flags.
1774
+ 1. **One send = one fact = one ask.** Never two questions in a notification.
1775
+ 2. **Default to \`important\`.** \`message\` only for true no-action FYIs; \`urgent\` is a contract, not a volume knob **<1/day**, abuse caps your channel.
1776
+ 3. **There is no content-template menu.** Every send is type + markdown + optional buttons. If you're reaching for \`--template context/report/digest/sensitive\`, stop — that surface is gone (the field still parses as silent back-compat, but don't teach or rely on it).
1777
+ 4. **Typed answer? \`--actions reply\` ALONE** — never \`yes,no,reply\` together.
1778
+ 5. **Trust the 201 echo over your intent** — \`degraded\`/\`render_mode\`/\`registered_devices\`. \`registered_devices:0\` ⇒ it went nowhere; don't wait.
1779
+ 6. **Don't spam to signal importance.** Consolidate into one markdown body; use \`--collapse-key\` for self-replacing progress, \`--thread\` only for follow-ups over time.
1780
+ 7. **Be listening when the answer lands, or you lose it.** Ack only AFTER the work is durably done.
1781
+ 8. **English only, phone-friendly markdown.** Narrow tables (they render), no emoji-spam.
1728
1782
 
1729
- ## Pick the right send (decision table)
1783
+ ## Gold examples (full commands)
1784
+
1785
+ Pendency with a real table → \`important\`:
1786
+ \`\`\`bash
1787
+ pidge important --title "Weekly metrics ready" \\
1788
+ --body-markdown $'| Metric | This week | Δ |\\n|---|---|---|\\n| Signups | 1,204 | +8% |\\n| Churn | 1.9% | −0.3pp |' \\
1789
+ --actions reply
1790
+ \`\`\`
1730
1791
 
1731
- ${table.map((r) => `- ${r}`).join('\n')}
1792
+ Blocking decision ask→wait loop (handle exit 3):
1793
+ \`\`\`bash
1794
+ pidge important --title "Run the schema migration?" \\
1795
+ --body-markdown "Dropping \\\`legacy_orders\\\` (412k rows, archived 2025). Not reversible. Safe mid-deploy?" \\
1796
+ --actions yes,no --wait --timeout 3600
1797
+ # exit 0 → read chosen_action.action_id (yes|no); exit 3 → no answer, treat as NO / hold, re-ask
1798
+ \`\`\`
1799
+
1800
+ Agent-initiated approval (money) → \`pidge approval\`:
1801
+ \`\`\`bash
1802
+ pidge approval --title "Place \\$4,200 purchase order?" \\
1803
+ --body-markdown "Vendor: Acme · PO #4471 · moves real money." \\
1804
+ --wait --timeout 3600
1805
+ # = important + Approve(Face ID)/Reject + wait; chosen_action.action_id: grant|deny
1806
+ \`\`\`
1807
+
1808
+ Time-anchored → \`event\` (needs \`--event-at\` in the human's tz):
1809
+ \`\`\`bash
1810
+ pidge event --event-at "2026-06-30T15:00:00-03:00" --title "Call with accountant"
1811
+ \`\`\`
1812
+
1813
+ Long markdown without shell-quoting pain → pipe it:
1814
+ \`\`\`bash
1815
+ generate_report | pidge important --title "Report ready" --body-markdown-file - --actions reply
1816
+ \`\`\`
1817
+
1818
+ ## Gotchas we already paid for
1819
+
1820
+ - **There is no \`pidge reply\`.** \`reply\` is a built-in action id, not a command. To answer the human's composer message, send a normal \`pidge message --thread <id>\` reusing the message's \`thread_id\`.
1821
+ - **\`urgent\` is a trust contract, not a button.** It arms an AlarmKit alarm; once delivered you **cannot abort it** (\`pidge cancel\` → 409). Real + unpostponable only, <1/day. Never test it without warning the human.
1822
+ - **A 201 ≠ "seen."** \`registered_devices:0\` goes nowhere; \`delivered\` is APNs dispatch, not eyes; only \`seen_at\`/an answer is the human.
1823
+ - **The ask reply-vs-yes/no trap.** \`--actions yes,no,reply\` lets the human dodge a typed answer with one tap — use \`--actions reply\` alone when you need text.
1824
+ - **\`event\` is quiet today** — \`event --event-at\` schedules; the countdown LA-as-primitive is still being built.
1825
+ - **content_template still parses as input** (back-compat) but is OFF the menu — if a legacy habit sends \`--template report\`, it silently maps; don't rely on it, don't teach it.
1732
1826
 
1733
1827
  ## How it intrudes (profiles — the human owns them)
1734
1828
 
@@ -1740,40 +1834,25 @@ ${notes.map((n) => `- ${n}`).join('\n')}
1740
1834
 
1741
1835
  ## Getting answers
1742
1836
 
1743
- - \`pidge ask …\` blocks and prints chosen_action JSON; \`pidge wait <cid>\` blocks on an existing send.
1837
+ - \`pidge ask …\` blocks and prints \`chosen_action\` JSON; \`pidge wait <cid>\` blocks on an existing send.
1744
1838
  - \`pidge listen\` blocks until the human MESSAGES you from the app (composer) — run it when idle.
1745
1839
  - ${exits}
1746
1840
 
1747
- ## Stay "always-on" while you're turn-based (#244)
1841
+ ## Stay "always-on" while you're turn-based
1748
1842
 
1749
- 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:
1750
-
1751
- ### Path 1 an interactive listening window (active session)
1752
- \`\`\`bash
1753
- pidge listen --follow --timeout 300 # hold for 5 min (--timeout is SECONDS), printing messages as they arrive
1754
- \`\`\`
1755
- 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.
1756
-
1757
- ### Path 2 — a supervisor that polls, no daemon (24/7)
1758
- A \`cron\` job or \`systemd\` timer invokes you every N minutes; each tick runs ONE one-shot listen and exits:
1759
- \`\`\`bash
1760
- pidge listen --timeout 50 # block up to 50s for a message, print it, exit 0 (exit 3 = nothing this tick)
1761
- \`\`\`
1762
- 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.
1763
-
1764
- > \`--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).
1843
+ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE without a daemon:
1844
+ - **Active session:** \`pidge listen --follow --timeout 300\` holds for 5 min, printing messages as they arrive. \`--follow\` traps the turn — use it only when you intend to sit and wait.
1845
+ - **Supervisor poll (24/7):** a cron/systemd timer invokes you every N min; each tick runs ONE one-shot \`pidge listen --timeout 50\` (block up to 50s, print, exit 0; exit 3 = nothing this tick), do the work, \`pidge ack --up-to <id>\`, sleep. \`--timeout\` is always SECONDS. Do NOT background \`pidge listen\` with \`&\`.
1765
1846
 
1766
1847
  ## Full spec
1767
1848
 
1768
- \`curl $PIDGE_URL/api/v1/manifest -H "Authorization: Bearer $PIDGE_TOKEN"\` — the always-current contract (fields, templates, custom actions, media, threads, realtime).
1849
+ \`curl $PIDGE_URL/api/v1/manifest -H "Authorization: Bearer $PIDGE_TOKEN"\` — the always-current contract (fields, profiles, custom actions, media, threads, realtime).
1769
1850
  `;
1770
1851
  const dir = path.join(process.cwd(), '.claude', 'skills', 'pidge');
1771
1852
  fs.mkdirSync(dir, { recursive: true });
1772
1853
  const file = path.join(dir, 'SKILL.md');
1773
1854
  fs.writeFileSync(file, skill);
1774
- console.error(`pidge: skill written to ${file} (manifest v${m.manifest_version}) — your future sessions in this project know Pidge now`);
1775
- console.log(JSON.stringify({ ok: true, file, manifest_version: m.manifest_version }));
1776
- process.exit(0);
1855
+ return { file, manifest_version: m.manifest_version };
1777
1856
  }
1778
1857
 
1779
1858
  (async () => {
@@ -1801,8 +1880,11 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
1801
1880
  }
1802
1881
  case 'skill': {
1803
1882
  if (parsed.positionals[1] !== 'install') die('pidge: usage: pidge skill install', 1);
1804
- await runSkillInstall();
1805
- break;
1883
+ let r;
1884
+ try { r = await installSkill(); } catch (e) { die(`pidge: ${e.message}`, 2); }
1885
+ console.error(`pidge: skill written to ${r.file} (manifest v${r.manifest_version}) — your future sessions in this project know Pidge now`);
1886
+ console.log(JSON.stringify({ ok: true, file: r.file, manifest_version: r.manifest_version }));
1887
+ process.exit(0);
1806
1888
  }
1807
1889
  // === AXIS 1 — the married catalog of 5 (perfis-S1/S2). Each stamps the
1808
1890
  // canonical template_kind. AXIS 2 (response) is orthogonal: --actions/
@@ -1878,8 +1960,8 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
1878
1960
  if (v.profile === 'tracking')
1879
1961
  die('pidge: `hello --profile tracking` makes no sense — the handshake waits for a confirmation, which tracking (Live-Activity-only) never produces', 1);
1880
1962
  v.template = 'onboarding';
1881
- if (v.title === undefined) v.title = 'Seu agente está pronto 🐦';
1882
- if (v.body === undefined) v.body = 'Toque em Feito para confirmar que me recebeu você vai ver o teste fechar na tela.';
1963
+ if (v.title === undefined) v.title = 'Your agent is ready 🐦';
1964
+ if (v.body === undefined) v.body = 'Tap Doneto confirm you received me — proves the round-trip works.';
1883
1965
  const cid = v['correlation-id'] || crypto.randomUUID();
1884
1966
  v['correlation-id'] = cid;
1885
1967
  console.error(`pidge: correlation_id=${cid}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",