pidge-cli 0.13.1 → 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,40 @@
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
+
9
+ ## 0.14.0 — 2026-06-28
10
+
11
+ The married vocabulary (perfis) — the CLI now speaks the SAME language as the server
12
+ (manifest v42) and the app: ONE list of 5 message types, with RESPONSE as a separate
13
+ axis. No scripts break — the old names keep working as aliases.
14
+
15
+ - **feat:** the typed sends are renamed to the canonical 5 — `pidge message` ·
16
+ `important` · `urgent` · `event` · `live` (message←fyi, important←report, urgent←alert;
17
+ event/live unchanged). `important` is the recommended default. The wire sends the new
18
+ `template_kind`. (perfis-S1)
19
+ - **feat (compat):** the OLD names still work as aliases — `pidge fyi`→message,
20
+ `report`→important, `alert`→urgent — mapped to the new type, with a one-line rename
21
+ note on stderr. Muscle-memory and existing scripts are untouched. (perfis-S1)
22
+ - **feat:** RESPONSE is now its own axis, composing on ANY type — `--actions`/
23
+ `--custom-action` (buttons) + the new **`--wait`** (block until the human answers,
24
+ then print `chosen_action` JSON; without it = fire-and-forget). This is the explicit
25
+ "send-and-go vs wait". (perfis-S2)
26
+ - **feat:** `pidge ask` is now the shortcut for `important --wait` (still REQUIRES a way
27
+ to answer; preserved behavior). There is no `ask` TYPE in the married catalog — asking
28
+ is a type + buttons + wait. (perfis-S2)
29
+ - **feat:** `pidge approval` — a new go/no-go RECIPE = `important` + Approve/Reject +
30
+ Face ID on Approve + `--wait`. Sent as `custom_actions` (only custom actions carry
31
+ `biometric`, and a custom id can't reuse a built-in like approve/reject — so the ids
32
+ are `grant`/`deny`). Pass your own `--actions`/`--custom-action` to override the
33
+ pair. (perfis-S2)
34
+ - **docs:** USAGE, per-command help and the generated `SKILL.md` rewritten around the two
35
+ axes (type + response) — mirrors the human's app, drops the dead fyi/report framing.
36
+ - **chore:** `KNOWN_MANIFEST_VERSION` 36 → 42 (the live server), silencing the news nag.
37
+
3
38
  ## 0.13.1 — 2026-06-26
4
39
 
5
40
  Polish from an agent E2E (2026-06-26). No breaking changes.
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.14.0** — the **married vocabulary** (perfis): the CLI now speaks the same
15
+ > language as the server (manifest v42) and the app. **One list of 5 types** —
16
+ > `pidge message · important · urgent · event · live` (message←fyi, important←report,
17
+ > urgent←alert; old names still work as aliases). **RESPONSE is a separate axis**:
18
+ > `--actions`/`--custom-action` add buttons on ANY type, and **`--wait`** blocks until
19
+ > the human answers — the explicit *send-and-go vs wait*. `pidge ask` is now the shortcut
20
+ > for `important --wait`; new **`pidge approval`** = important + Approve/Reject + Face
21
+ > ID + wait. `important` is the recommended default.
22
+ >
14
23
  > **New in v0.12.0** — CLI bugs batch (all reported by an agent in real use): **`pidge
15
24
  > <sub> --help`** now shows that subcommand's own help (its flags), not the global dump
16
25
  > (#240); the **manifest-version nag is throttled to once / 24 h** (cached in
@@ -113,39 +122,73 @@ export PIDGE_TOKEN=hld_xxx # your channel's bearer key
113
122
  # (or skip the exports: the CLI reads ~/.config/pidge/env — KEY=VALUE — so the
114
123
  # key never has to appear in an agent's chat; explicit env vars win)
115
124
 
116
- # Send AND wait for the answer (the one an agent wants):
117
- npx pidge-cli ask \
118
- --title "Aprovar deploy?" --actions yes,no,reply --timeout 600
125
+ # Just inform fire-and-forget (clears when the human opens it):
126
+ npx pidge-cli message --title "Build green" --body "2m12s"
127
+
128
+ # A pendency they should resolve — the DEFAULT type ("waiting-for-you" card):
129
+ npx pidge-cli important --title "Review PR #42" --url https://github.com/…/pull/42
119
130
 
120
- # Urgent escalates to an AlarmKit alarm if the human doesn't answer in minutes:
131
+ # Send AND wait for the answer (the one an agent wants) = important + --wait:
121
132
  npx pidge-cli ask \
122
- --title "Posso rodar a migration?" --profile escalating --actions yes,no
133
+ --title "Approve deploy?" --actions yes,no,reply --timeout 600
134
+
135
+ # A go/no-go with Face ID — the approval RECIPE (= important + Approve/Reject + wait):
136
+ npx pidge-cli approval --title "Deploy to production?"
137
+
138
+ # Urgent — breaks through silent/Focus; --escalate forces an AlarmKit alarm:
139
+ npx pidge-cli urgent --title "Balance dropped below $5k" --escalate
123
140
 
124
141
  # A thing with a known time — push at T−lead + a lock-screen countdown to the event:
125
- npx pidge-cli notify \
126
- --title "Reunião com o time" --profile event --event-at "2026-06-10T15:00:00"
142
+ npx pidge-cli event \
143
+ --title "Team meeting" --event-at "2026-06-10T15:00:00"
127
144
 
128
145
  # A chart you generated — uploaded for you, shown on the banner + feed:
129
- npx pidge-cli notify --title "Gráfico pronto" --image ./chart.png
146
+ npx pidge-cli message --title "Chart ready" --image ./chart.png
130
147
 
131
148
  # A real artifact — the human previews it on the phone, shares it, saves to Files:
132
- npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
149
+ npx pidge-cli important --title "Report" --file ./report.xlsx
133
150
  ```
134
151
 
135
- `ask` prints the chosen action as JSON to **stdout** and exits `0`:
152
+ `ask`/`approval` (and any `--wait` send) print the chosen action as JSON to
153
+ **stdout** and exit `0`:
136
154
 
137
155
  ```json
138
156
  { "kind": "acted", "action_id": "yes", "label": "Sim", "text": null,
139
157
  "at": "2026-06-08T18:19:51Z", "snooze_until": null }
140
158
  ```
141
159
 
160
+ ## Two axes: the TYPE + the RESPONSE
161
+
162
+ You pick **one type** (how much it may intrude — the human already configured how each
163
+ arrives), then ORTHOGONALLY decide the **response** (buttons? wait or not?).
164
+
165
+ **Axis 1 — type** (the married catalog of 5):
166
+
167
+ | Type | For | Clears when |
168
+ |---|---|---|
169
+ | `pidge message` | just inform, no action | the human OPENS it |
170
+ | `pidge important` ⭐ | a pendency they should resolve (the DEFAULT) | **Feito** |
171
+ | `pidge urgent` | wake them now (rare, real); `--escalate` = AlarmKit alarm | Feito (cuts the alarm) |
172
+ | `pidge event --event-at <ISO>` | a thing with a known time (countdown LA) | passed / Feito |
173
+ | `pidge live` | track something live (Live Activity) | you end it |
174
+
175
+ **Axis 2 — response** (composes on ANY type): `--actions yes,no` / `--custom-action`
176
+ add buttons (free text is always available); `--wait` blocks until the human answers
177
+ (else fire-and-forget — the answer arrives later in `pidge listen --all`). Two shortcuts
178
+ bundle both: **`pidge ask`** = `important --wait` (needs `--actions`); **`pidge approval`**
179
+ = `important` + Approve/Reject + Face ID + `--wait`.
180
+
181
+ > Old names still work as **aliases**: `fyi`→message, `report`→important, `alert`→urgent.
182
+
142
183
  ## Commands
143
184
 
144
185
  | Command | What it does |
145
186
  |---|---|
187
+ | `message` / `important` / `urgent` / `event` / `live` | The 5 message types (axis 1). Fire-and-forget by default; add `--actions`/`--wait` (axis 2) to ask for a reply. `important` is the recommended default. |
188
+ | `ask` | `important --wait` shortcut: send **and block** until the human answers; prints the chosen action JSON. Requires a way to answer (`--actions`/`--custom-action`/`--template`). |
189
+ | `approval` | Go/no-go RECIPE: `important` + Approve (Face ID) / Reject + `--wait`. Pass your own `--actions` to override the pair. |
146
190
  | `hello` | **v0.11.0 (#217):** your channel's **first-contact WOW** — send the onboarding handshake **and block** until the human confirms. The server narrates a 3-stage Live Activity on the lock screen (Conectando → toque para confirmar → Concluído ✓) so they *see* the agent→human→agent loop close. Run it as your **first** contact on a fresh channel. A thin `ask --template onboarding` wrapper with friendly default copy. |
147
- | `ask` | Send a notification **and block** until the human answers; prints the chosen action JSON. The default for agents. |
148
- | `notify` | Send only. Prints the raw 201 JSON; the `correlation_id` + warnings go to stderr. |
191
+ | `notify` | **Deprecated** — send without a type (the server picks the channel default). Prefer a typed send. Prints the raw 201 JSON; the `correlation_id` + warnings go to stderr. |
149
192
  | `wait <correlation_id>` | Block on an already-sent notification until it's answered. |
150
193
  | `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
151
194
  | `inbox` | What you sent: list, `--pending` slice, or `--summary` (counts + answer latency). |
@@ -188,11 +231,11 @@ WebSocket → ?wait= long-poll (capped 25 s server-side) → plain GETs ever
188
231
  --body TEXT the message shown on the banner
189
232
  --body-markdown MD rich body for the tap-through detail screen
190
233
  --subtitle TEXT
191
- --profile ID delivery profile — the HUMAN owns what each one does:
192
- default · event (needs --event-at; countdown Live Activity) ·
193
- escalating (alarm if unanswered minutes after delivery) ·
194
- the user's custom profiles. See the manifest's `profiles`.
195
- --event-at ISO8601 WHEN the thing happens (a FACT; required by profile event)
234
+ --profile ID low-level alias of the TYPE — the HUMAN owns what each does:
235
+ message · important · urgent · event (needs --event-at) ·
236
+ live · the user's custom profiles. Prefer the typed
237
+ subcommands above; an explicit --profile still wins.
238
+ --event-at ISO8601 WHEN the thing happens (a FACT; required by event)
196
239
  --lead-minutes N notify/start the countdown N min before event_at (5–240)
197
240
  --urgency LEVEL normal | persistent | alarm (low-level — prefer --profile)
198
241
  --image PATH_OR_URL image on the banner + feed: a local path is uploaded for you
@@ -207,13 +250,16 @@ WebSocket → ?wait= long-poll (capped 25 s server-side) → plain GETs ever
207
250
  '[{"id":"approve","label":"Aprovar agora"},{"id":"defer","label":"Depois"}]'
208
251
  --custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]"
209
252
  (repeatable — your own buttons; composes with --actions JSON)
253
+ --wait RESPONSE axis: block until the human answers (ANY type), then
254
+ print chosen_action JSON. Without it: fire-and-forget (the
255
+ answer arrives later in `pidge listen --all`). ask/approval imply it.
210
256
  --deliver-at ISO8601 schedule for later
211
257
  --reply-to URL also POST the answer to your webhook (HMAC-signed)
212
258
  --correlation-id ID idempotency + routing key (auto-generated if omitted)
213
259
  --collapse-key KEY replace/update a prior notification
214
260
  --param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
215
261
  fields work day-one, no CLI update needed
216
- --timeout SECONDS ask: default 600 · wait: default 300
262
+ --timeout SECONDS how long --wait blocks (ask/approval: template suggestion ~3600 · wait: 300)
217
263
  --interval SECONDS FALLBACK poll cadence (default 30) — normally unused: WS or
218
264
  the server-held long-poll (?wait=25) make answers ~instant
219
265
  --realtime force the WebSocket (Node ≥22); --no-realtime = polling only
@@ -224,9 +270,10 @@ WebSocket → ?wait= long-poll (capped 25 s server-side) → plain GETs ever
224
270
  - **`ask` prints `correlation_id=<cid>` as its FIRST stderr line** (minted client-side
225
271
  when you don't pass one) — a killed `ask` always leaves the handle behind, so you
226
272
  can `pidge wait <cid>` instead of re-sending.
227
- - **stdout is always machine-readable.** `notify` → the raw 201 JSON; `ask`/`wait` →
228
- the `chosen_action` JSON. Everything human (warnings, the correlation_id, snooze
229
- notices, armed-escalation and policy-degrade narration) goes to **stderr**.
273
+ - **stdout is always machine-readable.** A fire-and-forget send → the raw 201 JSON; a
274
+ `--wait` send / `ask` / `approval` / `wait` the `chosen_action` JSON. Everything
275
+ human (warnings, the correlation_id, snooze notices, armed-escalation and
276
+ policy-degrade narration) goes to **stderr**.
230
277
  - **Exit codes:** `0` answered · `3` timed out (= *no answer yet*, NOT a failure —
231
278
  back off and retry later) · `4` timed out **without one healthy round-trip all
232
279
  session** (the CHANNEL looks broken — server/network — tell your human) ·
@@ -234,11 +281,11 @@ WebSocket → ?wait= long-poll (capped 25 s server-side) → plain GETs ever
234
281
  - **Responses are one-and-done.** Every answer closes the notification EXCEPT a
235
282
  **snooze** (or a reschedule that set a new time), which re-fires later. `ask`/`wait`
236
283
  keep polling through a snooze and print `snooze_until` so you can schedule a re-check.
237
- - **Profiles degrade, never reject.** An over-ceiling profile is delivered at the
238
- channel's allowed level — read `degraded`/`degrade_reason` in the 201 (narrated on
239
- stderr). That's the human's policy working; don't retry harder.
240
- - **`ask --profile tracking` is refused** — tracking is Live-Activity-only and never
241
- produces an answer.
284
+ - **Types degrade, never reject.** An over-ceiling type is delivered at the channel's
285
+ allowed level — read `degraded`/`degrade_reason` in the 201 (narrated on stderr).
286
+ That's the human's policy working; don't retry harder.
287
+ - **`--wait` / `ask` on `live` is refused** — `live` is status-only and never produces
288
+ an answer.
242
289
  - A genuine follow-up question is a **new** notification, never a second answer on
243
290
  the same one.
244
291
 
package/bin/pidge.js CHANGED
@@ -11,14 +11,25 @@
11
11
  // ~/.config/pidge/env — KEY=VALUE — is read instead, so the key can live
12
12
  // OUTSIDE the agent's chat/context entirely, #57)
13
13
  //
14
- // # send AND block until the human answers, then print the chosen action as JSON
15
- // pidge ask --title "Aprovar deploy?" --actions yes,no,reply --timeout 600
14
+ // TWO AXES (perfis, manifest v40+): (1) the TYPE one married list of 5 the
15
+ // human configured how to receive: message · important · urgent · event · live;
16
+ // (2) the RESPONSE — buttons (--actions/--custom-action) + send-and-go vs wait
17
+ // (--wait blocks until the human answers). Response composes onto ANY type.
16
18
  //
17
- // # urgent: escalates to an AlarmKit alarm if unanswered (see the manifest's profiles)
18
- // pidge ask --title "Posso rodar a migration?" --profile escalating --actions yes,no
19
+ // # just inform fire-and-forget (prints the raw 201)
20
+ // pidge message --title "Build green" --body "2m12s"
19
21
  //
20
- // # a thing with a known time: push at T−lead + a lock-screen countdown to the event
21
- // pidge notify --title "Reunião com o time" --profile event --event-at "2026-06-10T15:00:00"
22
+ // # a pendency the human should resolve (the DEFAULT type) + block on the answer
23
+ // pidge important --title "Approve deploy?" --actions yes,no,reply --wait
24
+ //
25
+ // # a go/no-go decision with Face ID — the approval RECIPE (= important + wait + gate)
26
+ // pidge approval --title "Deploy to production?"
27
+ //
28
+ // # urgent: breaks through silent/Focus, escalates to an AlarmKit alarm
29
+ // pidge urgent --title "Balance dropped below $5k" --escalate
30
+ //
31
+ // # a thing with a known time: push at T−lead + a lock-screen countdown
32
+ // pidge event --title "Team meeting" --event-at "2026-06-10T15:00:00"
22
33
  //
23
34
  // # block on an already-sent notification (by correlation_id)
24
35
  // pidge wait order-7 --timeout 300
@@ -96,7 +107,8 @@ const OPTIONS = {
96
107
  help: { type: 'boolean', short: 'h' },
97
108
  title: { type: 'string' },
98
109
  body: { type: 'string' },
99
- 'body-markdown': { type: 'string' },
110
+ 'body-markdown': { type: 'string', short: 'm' },
111
+ 'body-markdown-file': { type: 'string' }, // a path, or "-" to read stdin (#274)
100
112
  subtitle: { type: 'string' },
101
113
  template: { type: 'string' }, // content/action pattern (manifest `templates`)
102
114
  profile: { type: 'string' }, // delivery profile id (manifest `profiles`)
@@ -104,6 +116,7 @@ const OPTIONS = {
104
116
  'lead-minutes': { type: 'string' }, // notify/countdown lead before event_at
105
117
  urgency: { type: 'string' }, // normal | persistent | alarm (low-level — prefer --profile)
106
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)
107
120
  image: { type: 'string' }, // banner+feed image: local path → uploaded; URL → as-is
108
121
  file: { type: 'string' }, // real artifact (xlsx/pdf/csv…): local path → uploaded
109
122
  url: { type: 'string' }, // deep link the app opens on tap (#45)
@@ -119,6 +132,9 @@ const OPTIONS = {
119
132
  param: { type: 'string', multiple: true }, // key=value escape hatch → raw /notify field
120
133
  timeout: { type: 'string' },
121
134
  interval: { type: 'string' },
135
+ // perfis-S2 response axis: --wait blocks until the human answers (composes on
136
+ // ANY type — send-and-go vs wait). ask/approval imply it.
137
+ wait: { type: 'boolean' },
122
138
  // inbox flags (#83)
123
139
  pending: { type: 'boolean' },
124
140
  summary: { type: 'boolean' },
@@ -163,14 +179,20 @@ USAGE
163
179
  narrated LIVE on the lock screen by a 3-stage Live Activity
164
180
  (Conectando → toque para confirmar → Concluído ✓). send + wait
165
181
  in one — run it as your FIRST contact on a fresh channel.
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)
182
+ AXIS 1 — TYPE (the married list of 5; the human configured how each arrives):
183
+ pidge message [options] just inform, no action — clears when the human OPENS it
184
+ pidge important [options] ⭐DEFAULT a pendency the human should resolve ("waiting-for-you" card)
185
+ pidge urgent [options] breaks through silent/Focus; --escalate forces an AlarmKit alarm
186
+ pidge event [options] a scheduled thing — needs --event-at (countdown Live Activity)
187
+ pidge live [options] an in-flight task with incremental updates (Live Activity)
188
+ AXIS 2 RESPONSE (composes on ANY type above): --actions/--custom-action add
189
+ buttons; text reply is ALWAYS available; --wait blocks until the human answers
190
+ (send-and-go vs --wait). Two shortcuts bundle both axes:
191
+ pidge ask [options] = important + --wait; needs --actions (prints chosen_action JSON)
192
+ pidge approval [options] = important + Approve/Reject + Face ID + --wait (a go/no-go)
193
+ COMPAT aliases (old names still work → mapped to the new type):
194
+ pidge fyi→message · report→important · alert→urgent (event/live unchanged)
195
+ pidge notify [options] DEPRECATED — send without a type; prefer a TYPE above
174
196
  pidge wait <correlation_id> [options] block on an already-sent notification
175
197
  pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
176
198
  pidge inbox [--pending|--summary|--all|--limit N] what you sent: list, pending slice, or counts+latency (#83)
@@ -217,13 +239,12 @@ OPTIONS (notify / ask)
217
239
  --body TEXT message shown on the banner
218
240
  --body-markdown MD rich body for the tap-through detail screen
219
241
  --subtitle TEXT
220
- --template ID content/action pattern WHAT you're asking: context (FYI,
221
- no buttons) · decision (yes/no/reply) · approval · reminder ·
222
- nudge · sensitive (gated, Face ID). Composes with --profile.
223
- --profile ID delivery profile (the HUMAN owns what it does): default ·
224
- event (needs --event-at; countdown Live Activity) ·
225
- escalating (alarm if unanswered minutes after delivery) ·
226
- the user's custom profiles. See the manifest's \`profiles\`.
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)
244
+ --profile ID low-level alias of the TYPE axis (the HUMAN owns what it
245
+ does): message · important · urgent · event · live ·
246
+ the user's custom profiles. Prefer the typed subcommands
247
+ above; an explicit --profile still wins. See the manifest.
227
248
  --event-at ISO8601 WHEN the thing happens (a FACT; required by profile event)
228
249
  --lead-minutes N notify/start countdown N min before event_at (5–240)
229
250
  --urgency LEVEL normal | persistent | alarm (low-level — prefer --profile)
@@ -233,9 +254,13 @@ OPTIONS (notify / ask)
233
254
  shares and saves on the phone; uploaded automatically (≤25 MB)
234
255
  --url URL deep link the app opens when the user taps (PR, dashboard, log)
235
256
  --copy TEXT value offered as tap-to-copy on the detail (code, token)
236
- --actions LIST comma list: yes,no,approve,reject,accept,decline,later,
237
- done,snooze,reschedule,reply,mute
257
+ --actions LIST RESPONSE axis — comma list: yes,no,approve,reject,accept,
258
+ decline,later,done,snooze,reschedule,reply,mute (or a JSON
259
+ array of custom {id,label} objects). Composes on ANY type.
238
260
  --custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]" (repeatable)
261
+ --wait RESPONSE axis — block until the human answers (any type),
262
+ then print chosen_action JSON. Without it: fire-and-forget
263
+ (the answer arrives later in \`pidge listen --all\`). ask/approval imply it.
239
264
  --deliver-at ISO8601 schedule for later
240
265
  --reply-to URL also POST the answer to your webhook (HMAC-signed)
241
266
  --correlation-id ID idempotency + routing key (auto-generated if omitted)
@@ -247,7 +272,8 @@ OPTIONS (notify / ask)
247
272
  --collapse-key KEY replace/update a prior notification
248
273
  --param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
249
274
  fields work without a CLI update; the manifest is the contract
250
- --timeout SECONDS ask: 600 · wait: 300
275
+ --timeout SECONDS how long --wait blocks (ask/approval: template's suggestion,
276
+ ~3600 for a decision · wait: 300) — explicit always wins
251
277
  --interval SECONDS FALLBACK poll cadence (default 30) — normally unused: WS or
252
278
  the server-held long-poll (?wait=25) make answers ~instant
253
279
 
@@ -262,17 +288,18 @@ ENV
262
288
  shared ~/.config/pidge/env (single-agent only).
263
289
 
264
290
  OUTPUT
265
- stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
266
- human notices go to stderr. Exit: 0 answered · 3 timed out (no answer yet,
267
- not a failure) · 4 timed out WITHOUT ONE healthy round-trip all session (the
268
- CHANNEL looks broken server/network not the human ignoring you: surface
269
- it instead of retrying blindly, #119) · 2 error · 1 usage.
270
-
271
- Responses are one-and-done EXCEPT snooze/reschedule (they re-fire); ask/wait keep
272
- polling through a snooze and print snooze_until. Follow-up = a NEW notification.
273
- An over-ceiling profile is delivered DEGRADED, never rejected read the 201's
274
- degraded/degrade_reason (narrated on stderr). profile "tracking" is Live-Activity-
275
- only: it never produces an answer, so \`ask\` refuses it.
291
+ stdout is machine-readable (a fire-and-forget sendthe raw 201 JSON; a --wait
292
+ send / ask / approval / wait→chosen_action JSON); human notices go to stderr.
293
+ Exit: 0 answered · 3 timed out (no answer yet, not a failure) · 4 timed out
294
+ WITHOUT ONE healthy round-trip all session (the CHANNEL looks broken
295
+ server/network not the human ignoring you: surface it instead of retrying
296
+ blindly, #119) · 2 error · 1 usage.
297
+
298
+ Responses are one-and-done EXCEPT snooze/reschedule (they re-fire); a --wait send
299
+ keeps polling through a snooze and prints snooze_until. Follow-up = a NEW
300
+ notification. An over-ceiling type is delivered DEGRADED, never rejected — read
301
+ the 201's degraded/degrade_reason (narrated on stderr). \`live\` is status-only:
302
+ it never produces an answer, so --wait/ask refuse it.
276
303
 
277
304
  Full spec (the contract — always current): GET $PIDGE_URL/api/v1/manifest`;
278
305
 
@@ -288,19 +315,21 @@ const OPTION_DOCS = {
288
315
  title: '--title TEXT (required) the headline',
289
316
  body: '--body TEXT the message shown on the banner',
290
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',
291
319
  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)',
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.',
321
+ profile: '--profile ID low-level alias of the TYPE (the human owns it): message · important · urgent · event · live · custom',
322
+ 'event-at': '--event-at ISO8601 WHEN the thing happens (required by event)',
295
323
  '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',
324
+ urgency: '--urgency LEVEL normal | persistent | alarm (low-level — prefer the typed subcommand)',
325
+ escalate: '--escalate urgent: force an AlarmKit alarm that breaks through silent/Focus',
298
326
  image: '--image PATH_OR_URL banner+feed image: a local path is uploaded; an https URL is sent as-is',
299
327
  file: '--file PATH a real artifact (xlsx/pdf/csv…) uploaded for the human (≤25 MB)',
300
328
  url: '--url URL deep link the app opens on tap (PR, dashboard, log)',
301
329
  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',
330
+ actions: '--actions LIST|JSON RESPONSE axis: comma list from the catalog (yes,no,reply) OR a JSON array of {"id","label"} custom actions — composes on ANY type',
303
331
  'custom-action': '--custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]" (repeatable)',
332
+ wait: '--wait RESPONSE axis: block until the human answers (any type), then print chosen_action JSON (ask/approval imply it)',
304
333
  'deliver-at': '--deliver-at ISO8601 schedule the send for later',
305
334
  'reply-to': '--reply-to URL also POST the answer to your webhook (HMAC-signed)',
306
335
  'correlation-id': '--correlation-id ID idempotency + routing key (auto-generated if omitted)',
@@ -308,7 +337,7 @@ const OPTION_DOCS = {
308
337
  after: '--after CID decision queue (#157): held until that notification is answered',
309
338
  'collapse-key': '--collapse-key KEY replace/update a prior notification',
310
339
  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)',
340
+ timeout: '--timeout SECONDS how long --wait blocks (ask/approval: template suggestion ~3600 · wait: 300 · listen: 600)',
312
341
  interval: '--interval SECONDS FALLBACK poll cadence (default 30) — normally unused (WS/long-poll)',
313
342
  realtime: '--realtime force the realtime WebSocket (warn + fall back to polling if unavailable)',
314
343
  'no-realtime': '--no-realtime polling only (skip the WebSocket)',
@@ -330,11 +359,14 @@ const OPTION_DOCS = {
330
359
  window: '--window N reachability window in seconds (default 30)',
331
360
  'quiet-nag': '--quiet-nag silence the "server has new capabilities" nag for this run',
332
361
  };
333
- // Content flags shared by notify / ask / hello.
334
- const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'subtitle', 'template', 'profile',
362
+ // Content flags shared by every send.
363
+ const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'body-markdown-file', 'subtitle', 'template', 'profile',
335
364
  'event-at', 'lead-minutes', 'urgency', 'image', 'file', 'url', 'copy', 'actions',
336
365
  'custom-action', 'deliver-at', 'reply-to', 'correlation-id', 'thread', 'after',
337
366
  'collapse-key', 'param'];
367
+ // Typed sends also carry the RESPONSE axis: --wait (block on the answer) + the
368
+ // blocking knobs. (`live` is status-only — it never answers, so it skips these.)
369
+ const SEND_OPTS = [...CONTENT_OPTS, 'gated', 'wait', 'timeout', 'interval', 'realtime', 'no-realtime'];
338
370
 
339
371
  const HELP = {
340
372
  setup: {
@@ -356,50 +388,75 @@ const HELP = {
356
388
  hello: {
357
389
  summary: 'first-contact WOW (#217): your channel\'s debut handshake, narrated live by a 3-stage Live Activity. send + wait in one.',
358
390
  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.',
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.',
360
392
  opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
361
393
  },
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],
394
+ // AXIS 1 — the married catalog of 5 (perfis-S1/S2). The TYPE you pick IS how the
395
+ // human configured it to arrive. RESPONSE (--actions/--wait) composes on any of them.
396
+ message: {
397
+ summary: 'just inform passive info the human reads when they want; no action (clears when they OPEN it).',
398
+ usage: 'pidge message --title TEXT [--body TEXT | --body-markdown MD] [--image PATH] [--url URL]',
399
+ body: 'Fire-and-forget by default (stdout is the raw 201). Use it for logs, registros and neutral summaries. Need a decision? add --actions + --wait, or use `pidge important`/`pidge approval`. (Replaces the old `fyi`.)',
400
+ opts: [...SEND_OPTS],
367
401
  },
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],
402
+ important: {
403
+ summary: ' the DEFAULT — a pendency the human should resolve ("waiting-for-you" card; clears on Done).',
404
+ usage: 'pidge important --title TEXT [--actions yes,no,reply] [--wait] [--body-markdown MD]',
405
+ body: 'Fire-and-forget by default; add --actions/--custom-action for quick-tap buttons and --wait to block until the human answers (prints chosen_action JSON). The most-used type — on the fence between informing and asking, pick this. (Replaces the old `report`.)',
406
+ opts: [...SEND_OPTS],
373
407
  },
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'],
408
+ urgent: {
409
+ summary: 'breaks through silent/Focus; --escalate forces an AlarmKit alarm. Use for the real and inadiável (<1/day).',
410
+ usage: 'pidge urgent --title TEXT [--escalate] [--actions yes,no] [--wait]',
411
+ body: 'A contract of trust: reserve it for what truly can\'t wait. --escalate asks for an AlarmKit alarm that rings through silent + Focus (the human\'s settings still decide). Once DELIVERED an urgent only stops when answered you can\'t abort it. (Replaces the old `alert`.)',
412
+ opts: [...SEND_OPTS, 'escalate'],
379
413
  },
380
414
  event: {
381
- summary: 'surface a scheduled thing with a known time — countdown Live Activity (#246 type event → Evento).',
415
+ summary: 'a scheduled thing with a known time — countdown Live Activity (needs --event-at).',
382
416
  usage: 'pidge event --title TEXT --event-at ISO8601 [--lead-minutes N] [--body-markdown MD]',
383
417
  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'],
418
+ opts: [...SEND_OPTS],
391
419
  },
392
420
  live: {
393
- summary: 'track an in-flight task (deploy/build/trip) with incremental updates (#246 type live Live Activity).',
421
+ summary: 'track an in-flight task (deploy/build/trip) with incremental updates (Live Activity). Status-only never answers.',
394
422
  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.',
423
+ body: 'Fire-and-forget. Records the live type; the LA-as-primitive is being built — today the send is delivered as a normal notification. Use judgement, not a recipe: show what the human WANTS to watch evolve.',
396
424
  opts: [...CONTENT_OPTS],
397
425
  },
426
+ // AXIS 2 — the two response shortcuts (bundle a type + buttons + --wait).
427
+ ask: {
428
+ summary: 'a DECISION — = important + --wait; needs --actions. Blocks until the human answers (prints chosen_action JSON).',
429
+ usage: 'pidge ask --title TEXT --actions yes,no,reply [--reply-to URL] [options]',
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.',
431
+ opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
432
+ },
433
+ approval: {
434
+ summary: 'a go/no-go RECIPE — = important + Approve/Reject + Face ID on Approve + --wait.',
435
+ usage: 'pidge approval --title TEXT [--body-markdown MD] [options]',
436
+ body: 'The easy shortcut for an explicit approval: injects an Approve (Face-ID gated) / Reject pair and blocks on the answer. Pass your own --actions/--custom-action to override the default pair. A gated action is detail-screen only (the banner shows no quick buttons by design — gotcha #19).',
437
+ opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
438
+ },
439
+ // COMPAT aliases — old names map to the new type (kept so scripts don't break).
440
+ fyi: {
441
+ summary: 'COMPAT alias of `pidge message` (renamed in 0.14 — the married catalog). Still works; prefer `message`.',
442
+ usage: 'pidge fyi … (→ pidge message …)',
443
+ opts: [...SEND_OPTS],
444
+ },
445
+ report: {
446
+ summary: 'COMPAT alias of `pidge important` (renamed in 0.14). Still works; prefer `important`.',
447
+ usage: 'pidge report … (→ pidge important …)',
448
+ opts: [...SEND_OPTS],
449
+ },
450
+ alert: {
451
+ summary: 'COMPAT alias of `pidge urgent` (renamed in 0.14). Still works; prefer `urgent`.',
452
+ usage: 'pidge alert … (→ pidge urgent …)',
453
+ opts: [...SEND_OPTS, 'escalate'],
454
+ },
398
455
  notify: {
399
- summary: 'DEPRECATED (0.13.x) — send WITHOUT a type; the server falls back to fyi. Use a typed send instead.',
456
+ summary: 'DEPRECATED — send WITHOUT a type; the server falls back to its default. Use a typed send instead.',
400
457
  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],
458
+ body: 'Kept for compat — it warns and still sends (no template_kind; the server picks the channel default). Prefer `pidge message/important/urgent/event/live` (or the `ask`/`approval` shortcuts).',
459
+ opts: [...SEND_OPTS],
403
460
  },
404
461
  wait: {
405
462
  summary: 'block on an already-sent notification until it is answered (prints chosen_action JSON).',
@@ -504,7 +561,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
504
561
  // The server advertises its manifest version on every response. When it's newer
505
562
  // than what this CLI shipped knowing, nudge on stderr — the agent re-reads the
506
563
  // manifest (whats_new) and learns the new capabilities without polling.
507
- const KNOWN_MANIFEST_VERSION = 36;
564
+ const KNOWN_MANIFEST_VERSION = 46;
508
565
  const NAG_TTL_MS = 24 * 60 * 60 * 1000; // #241: at most one nag per 24 h
509
566
  let newsWarned = false;
510
567
 
@@ -781,7 +838,13 @@ function buildBody(extra = {}) {
781
838
  if (!v.title) die('pidge: --title is required', 1);
782
839
  const body = { title: v.title };
783
840
  if (v.body !== undefined) body.body = v.body;
784
- 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
+ }
785
848
  if (v.subtitle !== undefined) body.subtitle = v.subtitle;
786
849
  if (v.template !== undefined) body.template = v.template;
787
850
  if (v.profile !== undefined) body.profile = v.profile;
@@ -818,6 +881,15 @@ function buildBody(extra = {}) {
818
881
  for (const spec of v['custom-action'] || []) customActions.push(customActionFromSpec(spec));
819
882
  if (customActions.length) body.custom_actions = customActions;
820
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
+
821
893
  // #246: subcommand-supplied raw fields (template_kind, alert's escalate). Applied
822
894
  // before the --param loop so a raw --param can still override in a pinch.
823
895
  Object.assign(body, extra);
@@ -932,23 +1004,83 @@ async function doNotify(extra = {}) {
932
1004
  return { ok, info, raw };
933
1005
  }
934
1006
 
935
- // #246: the typed send subcommands (fyi/report/event/alert/live) share notify's
936
- // fire-and-forget shapestamp 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);
1007
+ // The RESPONSE axis (perfis-S2): true when the send carries SOME way for the human
1008
+ // to answer with a tap built-in actions, custom actions, or a content --template
1009
+ // that supplies them. Free-text reply is ALWAYS available, so this is only about
1010
+ // buttons. `ask` requires it; `approval` injects a default pair when it's absent.
1011
+ const hasAnswerAffordance = () =>
1012
+ v.actions !== undefined || (v['custom-action'] || []).length > 0 || v.template !== undefined;
1013
+
1014
+ // The `approval` recipe's default button pair (perfis-S2 follow-up). Sent as
1015
+ // CUSTOM actions, NOT built-ins: only custom_actions can carry `biometric` (Face
1016
+ // ID), and a custom id may NOT reuse a built-in id like approve/reject (the server
1017
+ // 422s "collides with a built-in") — so the ids are grant/deny. Face ID gates the
1018
+ // consequential "Approve"; "Reject" is the safe (destructive-styled) out. A gated
1019
+ // action is detail-screen only (no banner buttons — gotcha #19), by design.
1020
+ const APPROVAL_ACTIONS = [
1021
+ { id: 'grant', label: 'Approve', biometric: true, terminal: true },
1022
+ { id: 'deny', label: 'Reject', style: 'destructive', terminal: true },
1023
+ ];
1024
+
1025
+ // The married catalog of 5 (perfis-S1): one send, stamped with the canonical
1026
+ // `template_kind` (message/important/urgent/event/live). The RESPONSE axis is
1027
+ // orthogonal: with `wait:false` it's fire-and-forget (print the raw 201, exit);
1028
+ // with `wait:true` it mints a cid, sends, and BLOCKS until a terminal answer
1029
+ // (print chosen_action JSON). `requireAnswerable` gates `ask`. `extra` carries
1030
+ // raw fields (urgent's escalate:true, approval's injected custom_actions).
1031
+ async function doTypedSend(kind, { wait = false, extra = {}, requireAnswerable = false, label = kind } = {}) {
1032
+ if (!v.title) die('pidge: --title is required', 1);
1033
+ // `live` is status-only — it never produces an answer, so --wait would block the
1034
+ // full timeout believing the human is deciding. Refuse it (mirror the old ask guard).
1035
+ if (wait && (kind === 'live' || v.profile === 'tracking'))
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);
1037
+ if (requireAnswerable && !hasAnswerAffordance())
1038
+ die(`pidge: --actions required for ${label}. Add buttons with --actions yes,no (or approve,reject) or --custom-action id:label.`, 1);
1039
+
1040
+ if (!wait) {
1041
+ const { ok, info, raw } = await doNotify({ template_kind: kind, ...extra });
1042
+ console.log(raw);
1043
+ if (ok && info.correlation_id)
1044
+ console.error(`pidge: correlation_id=${info.correlation_id} (use: pidge wait ${info.correlation_id})`);
1045
+ process.exit(ok ? 0 : 2);
1046
+ }
1047
+
1048
+ // --wait: the cid is minted CLIENT-side when not given, and printed as the FIRST
1049
+ // stderr line (greppable) — a killed/crashed wait always leaves the handle behind,
1050
+ // so the agent can `pidge wait <cid>` instead of re-sending.
1051
+ const cid = v['correlation-id'] || crypto.randomUUID();
1052
+ v['correlation-id'] = cid;
1053
+ console.error(`pidge: correlation_id=${cid}`);
1054
+ const { ok, info } = await doNotify({ template_kind: kind, ...extra });
1055
+ if (!ok) process.exit(2);
1056
+ console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
1057
+ // #132: no --timeout ⇒ obey the template's suggestion from the 201 echo (human
1058
+ // decisions take 30-40 min; a 600 s default misreads them as silence). Explicit wins.
1059
+ let timeout = num(v.timeout, NaN);
1060
+ if (!Number.isFinite(timeout)) {
1061
+ if (info.suggested_ask_timeout) {
1062
+ timeout = info.suggested_ask_timeout;
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)`);
1067
+ } else {
1068
+ timeout = 600;
1069
+ }
1070
+ }
1071
+ await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
1072
+ }
1073
+
1074
+ // A compat alias (perfis-S1): the OLD type name still works, mapped to the new
1075
+ // canonical one — a one-line note points at the rename so muscle-memory migrates.
1076
+ function warnRenamed(oldName, newName) {
1077
+ console.error(`pidge: \`pidge ${oldName}\` was renamed → use \`pidge ${newName}\` (the married catalog of 5; the alias keeps working).`);
945
1078
  }
946
1079
 
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).
1080
+ // `pidge notify` / `pidge send` (no type) are deprecated they still send, and the
1081
+ // server falls back to the channel default. Prefer a typed send. Warning is local.
950
1082
  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.`);
1083
+ console.error(`pidge: \`pidge ${name}\` is deprecated — use a TYPE instead: message · important · urgent · event · live (or the ask/approval shortcuts; see \`pidge help\`). It still sends (no template_kind the server picks the channel default).`);
952
1084
  }
953
1085
 
954
1086
  // Poll GET /notifications/:cid until a TERMINAL answer, print chosen_action JSON to
@@ -1457,9 +1589,9 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
1457
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.`);
1458
1590
  }
1459
1591
  // #229: lead with `pidge hello` — the first-contact WOW (send + wait in one),
1460
- // the same debut the /agent-setup guide leads with. It's a thin wrapper over
1461
- // `ask --template onboarding` (the underlying mechanism, if you need it raw).
1462
- 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)');
1463
1595
  console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version, realtime }));
1464
1596
  process.exit(0);
1465
1597
  }
@@ -1526,6 +1658,7 @@ async function runSetup() {
1526
1658
  console.log(`export PIDGE_URL=${finalBase}`);
1527
1659
  console.log(`export PIDGE_TOKEN=${data.key}`);
1528
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);
1529
1662
  await runDoctor(finalBase, data.key, 'fresh claim (per-agent env — not stored on disk)');
1530
1663
  return;
1531
1664
  }
@@ -1546,102 +1679,180 @@ async function runSetup() {
1546
1679
  }
1547
1680
  if (!AGENT_ID)
1548
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);
1549
1683
  await runDoctor(finalBase, data.key, CONFIG_FILE);
1550
1684
  }
1551
1685
 
1552
- // skill install (#110e): persistent Pidge knowledge for Claude Code agents
1553
- // a skill generated FROM the live manifest (so it can't drift), versioned with
1554
- // manifest_version (re-run to update; whats_new is the changelog).
1555
- 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' };
1556
1710
  let res, m;
1557
1711
  try {
1558
- res = await fetchT(`${BASE}/api/v1/manifest`, { headers });
1712
+ res = await fetchT(`${base}/api/v1/manifest`, { headers: hdrs });
1559
1713
  m = await res.json();
1560
1714
  } catch (e) {
1561
- die(`pidge: could not read the manifest: ${e.message}`, 2);
1715
+ throw new Error(`could not read the manifest: ${e.message}`);
1562
1716
  }
1563
- if (res.status !== 200) die(`pidge: manifest read failed (${res.status})`, 2);
1564
- 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.
1565
1719
  const profileTable = (m.profiles && m.profiles.decision_table) || [];
1566
1720
  const notes = m.notes || [];
1567
1721
  const exits = (m.cli && m.cli.output) || '';
1568
1722
  const skill = `---
1569
1723
  name: pidge
1570
- 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.
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.
1571
1725
  ---
1572
1726
 
1573
1727
  # Pidge — notify your human, get answers back
1574
1728
 
1575
- 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).
1576
1730
 
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).
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\`.
1578
1732
 
1579
- ## Choose the right type (REQUIRED in 0.14+)
1733
+ ## One breath
1580
1734
 
1581
- Every send needs a type. Pick by intent:
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.**
1582
1736
 
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..." |
1737
+ ## THE PICKER situation exact command
1591
1738
 
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.)
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\` |
1594
1748
 
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.
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.
1596
1750
 
1597
- ## Pick the right send (decision table)
1751
+ ## Approval has two paths know which one you're in
1598
1752
 
1599
- ${table.map((r) => `- ${r}`).join('\n')}
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.
1600
1754
 
1601
- ## How it intrudes (profiles — the human owns them)
1755
+ **Path B — your 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.
1602
1756
 
1603
- ${profileTable.map((r) => `- ${r}`).join('\n')}
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.
1604
1758
 
1605
- ## The contract
1759
+ ## The response axis (composes on ANY type)
1606
1760
 
1607
- ${notes.map((n) => `- ${n}`).join('\n')}
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.
1765
+ - **send-and-go vs wait** — the choice that decides how YOU work:
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.
1608
1769
 
1609
- ## Getting answers
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.
1610
1771
 
1611
- - \`pidge ask …\` blocks and prints chosen_action JSON; \`pidge wait <cid>\` blocks on an existing send.
1612
- - \`pidge listen\` blocks until the human MESSAGES you from the app (composer) — run it when idle.
1613
- - ${exits}
1772
+ ## Anti-slop rules (judgment a recipe can't teach)
1614
1773
 
1615
- ## Stay "always-on" while you're turn-based (#244)
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.
1616
1782
 
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:
1783
+ ## Gold examples (full commands)
1618
1784
 
1619
- ### Path 1 an interactive listening window (active session)
1785
+ Pendency with a real table \`important\`:
1620
1786
  \`\`\`bash
1621
- pidge listen --follow --timeout 300 # hold for 5 min (--timeout is SECONDS), printing messages as they arrive
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
1622
1790
  \`\`\`
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
1791
 
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:
1792
+ Blocking decision ask→wait loop (handle exit 3):
1627
1793
  \`\`\`bash
1628
- pidge listen --timeout 50 # block up to 50s for a message, print it, exit 0 (exit 3 = nothing this tick)
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
1629
1798
  \`\`\`
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
1799
 
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).
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.
1826
+
1827
+ ## How it intrudes (profiles — the human owns them)
1828
+
1829
+ ${profileTable.map((r) => `- ${r}`).join('\n')}
1830
+
1831
+ ## The contract
1832
+
1833
+ ${notes.map((n) => `- ${n}`).join('\n')}
1834
+
1835
+ ## Getting answers
1836
+
1837
+ - \`pidge ask …\` blocks and prints \`chosen_action\` JSON; \`pidge wait <cid>\` blocks on an existing send.
1838
+ - \`pidge listen\` blocks until the human MESSAGES you from the app (composer) — run it when idle.
1839
+ - ${exits}
1840
+
1841
+ ## Stay "always-on" while you're turn-based
1842
+
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 \`&\`.
1633
1846
 
1634
1847
  ## Full spec
1635
1848
 
1636
- \`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).
1637
1850
  `;
1638
1851
  const dir = path.join(process.cwd(), '.claude', 'skills', 'pidge');
1639
1852
  fs.mkdirSync(dir, { recursive: true });
1640
1853
  const file = path.join(dir, 'SKILL.md');
1641
1854
  fs.writeFileSync(file, skill);
1642
- console.error(`pidge: skill written to ${file} (manifest v${m.manifest_version}) — your future sessions in this project know Pidge now`);
1643
- console.log(JSON.stringify({ ok: true, file, manifest_version: m.manifest_version }));
1644
- process.exit(0);
1855
+ return { file, manifest_version: m.manifest_version };
1645
1856
  }
1646
1857
 
1647
1858
  (async () => {
@@ -1669,15 +1880,27 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
1669
1880
  }
1670
1881
  case 'skill': {
1671
1882
  if (parsed.positionals[1] !== 'install') die('pidge: usage: pidge skill install', 1);
1672
- await runSkillInstall();
1673
- 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);
1674
1888
  }
1675
- // #246: typed sendsfyi/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);
1889
+ // === AXIS 1the married catalog of 5 (perfis-S1/S2). Each stamps the
1890
+ // canonical template_kind. AXIS 2 (response) is orthogonal: --actions/
1891
+ // --custom-action add buttons, --wait blocks on the answer (else fire-and-
1892
+ // forget). notify/send = the deprecated typeless path; ask/approval = the
1893
+ // two shortcuts that bundle a type + response. ===
1894
+ case 'message':
1895
+ await doTypedSend('message', { wait: !!v.wait });
1896
+ break;
1897
+ case 'important':
1898
+ await doTypedSend('important', { wait: !!v.wait });
1899
+ break;
1900
+ case 'urgent':
1901
+ // --escalate ⇒ escalate:true (ask the Urgente profile for an AlarmKit alarm
1902
+ // that breaks through silent/Focus; the human's profile still decides).
1903
+ await doTypedSend('urgent', { wait: !!v.wait, extra: v.escalate ? { escalate: true } : {} });
1681
1904
  break;
1682
1905
  case 'event': {
1683
1906
  // event needs a TIME — validate locally (ISO8601) so the agent fails fast
@@ -1686,17 +1909,37 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
1686
1909
  die('pidge: --event-at required for event. Use ISO8601: --event-at 2026-06-26T14:00-03:00', 1);
1687
1910
  if (Number.isNaN(Date.parse(v['event-at'])))
1688
1911
  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');
1912
+ await doTypedSend('event', { wait: !!v.wait });
1690
1913
  break;
1691
1914
  }
1915
+ case 'live':
1916
+ // status-only — pass --wait through so doTypedSend REFUSES it loudly (it
1917
+ // never produces an answer); without --wait it's fire-and-forget.
1918
+ await doTypedSend('live', { wait: !!v.wait });
1919
+ break;
1920
+ // --- compat aliases (perfis-S1): old type names → the new canonical 5. They
1921
+ // map to the new template_kind and still honor --wait/--actions, so scripts
1922
+ // and muscle-memory keep working; a one-line note points at the new name.
1923
+ case 'fyi':
1924
+ warnRenamed('fyi', 'message');
1925
+ await doTypedSend('message', { wait: !!v.wait, label: 'fyi' });
1926
+ break;
1927
+ case 'report':
1928
+ warnRenamed('report', 'important');
1929
+ await doTypedSend('important', { wait: !!v.wait, label: 'report' });
1930
+ break;
1692
1931
  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 } : {});
1932
+ warnRenamed('alert', 'urgent');
1933
+ await doTypedSend('urgent', { wait: !!v.wait, extra: v.escalate ? { escalate: true } : {}, label: 'alert' });
1696
1934
  break;
1697
- case 'live':
1698
- await doTypedNotify('live');
1935
+ // `approval` = the RECIPE (perfis-S2 follow-up): important + Approve/Reject
1936
+ // (Face ID on Approve) + --wait. A shortcut for an explicit go/no-go; the human
1937
+ // can override the pair with their own --actions/--custom-action.
1938
+ case 'approval': {
1939
+ const extra = hasAnswerAffordance() ? {} : { custom_actions: APPROVAL_ACTIONS };
1940
+ await doTypedSend('important', { wait: true, extra, label: 'approval' });
1699
1941
  break;
1942
+ }
1700
1943
  case 'notify':
1701
1944
  case 'send': {
1702
1945
  warnDeprecatedSend(command);
@@ -1717,8 +1960,8 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
1717
1960
  if (v.profile === 'tracking')
1718
1961
  die('pidge: `hello --profile tracking` makes no sense — the handshake waits for a confirmation, which tracking (Live-Activity-only) never produces', 1);
1719
1962
  v.template = 'onboarding';
1720
- if (v.title === undefined) v.title = 'Seu agente está pronto 🐦';
1721
- 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.';
1722
1965
  const cid = v['correlation-id'] || crypto.randomUUID();
1723
1966
  v['correlation-id'] = cid;
1724
1967
  console.error(`pidge: correlation_id=${cid}`);
@@ -1733,40 +1976,11 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
1733
1976
  break;
1734
1977
  }
1735
1978
  case 'ask': {
1736
- // Send, then block on the answer in one shot. stdout = ONLY chosen_action JSON.
1737
- // tracking is Live-Activity-only: it NEVER produces a chosen_action, so an ask
1738
- // would block the full timeout believing the human is deciding.
1739
- if (v.profile === 'tracking')
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);
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);
1747
- // The cid is minted CLIENT-side when not given, and printed as the FIRST
1748
- // stderr line (greppable) — a killed/crashed ask always leaves the handle
1749
- // behind, so the agent can `pidge wait <cid>` instead of re-sending.
1750
- const cid = v['correlation-id'] || crypto.randomUUID();
1751
- v['correlation-id'] = cid;
1752
- console.error(`pidge: correlation_id=${cid}`);
1753
- const { ok, info } = await doNotify({ template_kind: 'ask' });
1754
- if (!ok) process.exit(2);
1755
- console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
1756
- // #132: no --timeout ⇒ obey the template's suggestion from the 201 echo
1757
- // (human decisions take 30-40 min in the wild; a 600 s default misread
1758
- // them as silence). Explicit --timeout always wins.
1759
- let timeout = num(v.timeout, NaN);
1760
- if (!Number.isFinite(timeout)) {
1761
- if (info.suggested_ask_timeout) {
1762
- timeout = info.suggested_ask_timeout;
1763
- const mins = Math.round(timeout / 60);
1764
- console.error(`pidge: timeout ${mins} min — suggested by template ${info.template || v.template} (override with --timeout)`);
1765
- } else {
1766
- timeout = 600;
1767
- }
1768
- }
1769
- await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
1979
+ // `ask` = the preserved shortcut: important + --wait + REQUIRES a way to
1980
+ // answer. There is no `ask` TYPE in the married catalog (manifest v40+) —
1981
+ // asking is "a type + buttons + wait". The legacy alias keeps working because
1982
+ // it always ships with buttons. `live`/tracking is refused (it never answers).
1983
+ await doTypedSend('important', { wait: true, requireAnswerable: true, label: 'ask' });
1770
1984
  break;
1771
1985
  }
1772
1986
  case 'wait': {
@@ -2052,8 +2266,8 @@ Each poll is one of your turns: pick up the message, do the work, \`pidge ack --
2052
2266
  break;
2053
2267
  }
2054
2268
  default:
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);
2269
+ // Name the bad command and point at the married catalog + the two response
2270
+ // shortcuts (a friendlier landing than dumping the whole USAGE on a typo).
2271
+ die(`pidge: unknown subcommand '${command}'. Types: message · important · urgent · event · live (response: --actions/--wait, or the ask/approval shortcuts). pidge --help`, 1);
2058
2272
  }
2059
2273
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.13.1",
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",