pidge-cli 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/pidge.js +177 -32
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 — #34 `pidge approve` (hook-shaped gate) + lote-5 polish
4
+
5
+ **`pidge approve "<question>"`** — a new, hook-shaped permission gate for wrapping an agent's
6
+ OWN risky actions behind a human Face-ID tap. It sends an important/sensitive notification with
7
+ two gated custom actions (`allow` = Face-ID confirm, `deny` = destructive), blocks on the same
8
+ long-poll as `pidge ask`, and maps the answer to an **exit code, DENY-DEFAULT**: only an explicit
9
+ `allow` is exit 0; deny, timeout, a dead channel, or any ambiguity is non-zero — so a Claude Code
10
+ `PreToolUse` hook fails CLOSED. `chosen_action` JSON is printed to stdout. Zero server change (a
11
+ thin wrapper over the existing send + wait). `--help` documents a runnable `PreToolUse` hook.
12
+
13
+ - **feat (#34):** `pidge approve` verb + `--allow-label`/`--deny-label`. `doWait`/`realtimeWait`/
14
+ `waitForAnswer` gained optional `onAnswer`/`onTimeout` callbacks so a caller can map the
15
+ outcome to an exit code instead of the default print-and-exit-0.
16
+ - **feat (lote-5 #2):** the CLI now REFUSES a decision button + `reply` in one send (e.g.
17
+ `--actions yes,no,reply`) — exit 1, no round-trip. The human would tap the easy Yes/No and you'd
18
+ get a useless "Yes" instead of the typed text (the skill's anti-slop rule #4, now enforced).
19
+ `reply` alongside a non-decision (e.g. `done,reply`) stays allowed.
20
+ - **fix (lote-5 #3):** `pidge <type> --help` no longer prints a bare, description-less `template`
21
+ line (`template` is intentionally off the menu; it stays a silent back-compat input).
22
+ - **feat (lote-5 #4):** `setup --quiet` collapses onboarding to a single status line (the full
23
+ doctor stays the default; `--quiet` is opt-in and never hides a broken setup — warnings/errors
24
+ still print).
25
+ - **feat (lote-5 #5):** `listen --all` now WARNS when its first quick batch is old backlog
26
+ ("N message(s) were ALREADY queued when this listen started … NOT fresh arrivals"), so a
27
+ resurfaced notification answer isn't mistaken for a new event. Within-channel — NOT the
28
+ cross-channel leak (#289).
29
+ - **note (lote-5 #1):** the `ask`/`wait` fallback poll cadence is already 30 s (aligned to the
30
+ server's suggestion); `--interval` still overrides. No change needed.
31
+ - **chore:** `SKILL_REVISION` 2 → 3 — the installed skill spine now teaches `pidge approve` and
32
+ the decision-vs-reply refusal, so onboarded agents self-heal to it on their next command.
33
+
3
34
  ## 0.15.3 — #33 fix: the self-heal marker no longer corrupts the skill
4
35
 
5
36
  The 0.15.2 marker was written as the FIRST line of `SKILL.md`, ABOVE the opening `---`. A
package/bin/pidge.js CHANGED
@@ -20,7 +20,7 @@
20
20
  // pidge message --title "Build green" --body "2m12s"
21
21
  //
22
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
23
+ // pidge important --title "Approve deploy?" --actions yes,no --wait
24
24
  //
25
25
  // # a go/no-go decision with Face ID — the approval RECIPE (= important + wait + gate)
26
26
  // pidge approval --title "Deploy to production?"
@@ -157,6 +157,12 @@ const OPTIONS = {
157
157
  renew: { type: 'boolean' }, // ack: heartbeat the visibility-timeout lease (state=delivered)
158
158
  'ack-on-read': { type: 'boolean' }, // listen: restore the pre-0.9 immediate-consume
159
159
  window: { type: 'string' }, // selftest: reachability window in seconds (default 30)
160
+ // #34 approve: the two gated-action labels (default Allow / Deny)
161
+ 'allow-label': { type: 'string' },
162
+ 'deny-label': { type: 'string' },
163
+ // lote-5 #4: collapse `setup` onboarding to a single status line (the full
164
+ // doctor stays the default; --quiet is opt-in, never the default).
165
+ quiet: { type: 'boolean' },
160
166
  };
161
167
 
162
168
  const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
@@ -327,7 +333,7 @@ const OPTION_DOCS = {
327
333
  file: '--file PATH a real artifact (xlsx/pdf/csv…) uploaded for the human (≤25 MB)',
328
334
  url: '--url URL deep link the app opens on tap (PR, dashboard, log)',
329
335
  copy: '--copy TEXT tap-to-copy value on the detail screen',
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',
336
+ actions: '--actions LIST|JSON RESPONSE axis: comma list from the catalog (e.g. yes,no · or reply ALONE — never mix a decision with reply) OR a JSON array of {"id","label"} custom actions — composes on ANY type',
331
337
  'custom-action': '--custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]" (repeatable)',
332
338
  wait: '--wait RESPONSE axis: block until the human answers (any type), then print chosen_action JSON (ask/approval imply it)',
333
339
  'deliver-at': '--deliver-at ISO8601 schedule the send for later',
@@ -358,9 +364,15 @@ const OPTION_DOCS = {
358
364
  renew: '--renew heartbeat the visibility-timeout lease instead of processing',
359
365
  window: '--window N reachability window in seconds (default 30)',
360
366
  'quiet-nag': '--quiet-nag silence the "server has new capabilities" nag for this run',
367
+ 'allow-label': '--allow-label TEXT approve: label on the Face-ID allow button (default "Allow")',
368
+ 'deny-label': '--deny-label TEXT approve: label on the deny button (default "Deny")',
369
+ quiet: '--quiet setup: collapse onboarding to one status line (the full doctor stays the default)',
361
370
  };
362
371
  // Content flags shared by every send.
363
- const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'body-markdown-file', 'subtitle', 'template', 'profile',
372
+ // lote-5 #3: `template` is intentionally OFF the menu (#274 — content_template is
373
+ // undocumented back-compat). It stays a parseable OPTION but is NOT listed here,
374
+ // so `pidge <type> --help` no longer prints a bare, description-less `template` line.
375
+ const CONTENT_OPTS = ['title', 'body', 'body-markdown', 'body-markdown-file', 'subtitle', 'profile',
364
376
  'event-at', 'lead-minutes', 'urgency', 'image', 'file', 'url', 'copy', 'actions',
365
377
  'custom-action', 'deliver-at', 'reply-to', 'correlation-id', 'thread', 'after',
366
378
  'collapse-key', 'param'];
@@ -372,8 +384,8 @@ const HELP = {
372
384
  setup: {
373
385
  summary: 'one-shot onboarding (#110): exchange a single-use claim code for the channel key, store it, run doctor.',
374
386
  usage: 'pidge setup --claim CODE [--url BASE] [--print] [--force] [--listen-mode MODE]',
375
- body: 'The CLI writes the key itself (chmod 600) — it never appears on screen or in the agent\'s chat. MULTI-AGENT: set PIDGE_AGENT=<id> at each agent\'s launch for an isolated config.',
376
- opts: ['claim', 'url-base', 'print', 'force', 'listen-mode'],
387
+ body: 'The CLI writes the key itself (chmod 600) — it never appears on screen or in the agent\'s chat. MULTI-AGENT: set PIDGE_AGENT=<id> at each agent\'s launch for an isolated config. --quiet collapses the onboarding to one status line.',
388
+ opts: ['claim', 'url-base', 'print', 'force', 'listen-mode', 'quiet'],
377
389
  },
378
390
  doctor: {
379
391
  summary: 'validate the setup WITHOUT exposing secrets (env source, server, key, device reach, realtime probe).',
@@ -401,7 +413,7 @@ const HELP = {
401
413
  },
402
414
  important: {
403
415
  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]',
416
+ usage: 'pidge important --title TEXT [--actions yes,no] [--wait] [--body-markdown MD]',
405
417
  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
418
  opts: [...SEND_OPTS],
407
419
  },
@@ -426,8 +438,8 @@ const HELP = {
426
438
  // AXIS 2 — the two response shortcuts (bundle a type + buttons + --wait).
427
439
  ask: {
428
440
  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.',
441
+ usage: 'pidge ask --title TEXT --actions yes,no [--reply-to URL] [options]',
442
+ body: 'Shorthand for important --wait that REQUIRES a way to answer — --actions (catalog or JSON) or --custom-action. For a typed answer use --actions reply ALONE (never a decision + reply together). Holds a WebSocket (or polls) until a TERMINAL answer; a snooze/reschedule re-fires.',
431
443
  opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
432
444
  },
433
445
  approval: {
@@ -436,6 +448,31 @@ const HELP = {
436
448
  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
449
  opts: [...CONTENT_OPTS, 'timeout', 'interval', 'realtime', 'no-realtime'],
438
450
  },
451
+ // #34 — the HOOK-shaped gate. DENY-DEFAULT: exit 0 ONLY on an explicit allow;
452
+ // deny, timeout, a dead channel or any ambiguity is non-zero, so a permission
453
+ // hook fails CLOSED. Built for PreToolUse (see the runnable example below).
454
+ approve: {
455
+ summary: 'ask the human to authorize a risky action (Face ID) and BLOCK — deny-default: exit 0 ONLY on explicit allow.',
456
+ usage: 'pidge approve "<question>" [--body TEXT] [--timeout N] [--allow-label L] [--deny-label L]',
457
+ body: [
458
+ 'Sends an important/sensitive notification with two gated custom actions — allow (Face-ID confirm) and deny — then blocks on the answer (the same long-poll as `pidge ask`).',
459
+ 'DENY-DEFAULT (the security rule): only an explicit allow is exit 0. deny → exit 1; timeout / no answer / a broken channel → exit 1. A send that never left the ground → exit 2. NON-ZERO ALWAYS MEANS "not approved" — treat it as a deny.',
460
+ 'chosen_action JSON is printed to stdout; human notices go to stderr.',
461
+ '',
462
+ 'PreToolUse hook (Claude Code) — gate a risky tool behind a human Face-ID tap, fail-closed:',
463
+ ' #!/usr/bin/env bash',
464
+ ' input=$(cat) # the hook JSON on stdin',
465
+ ' tool=$(printf %s "$input" | jq -r .tool_name)',
466
+ ' cmd=$(printf %s "$input" | jq -r ".tool_input.command // (.tool_input|tostring)")',
467
+ ' if pidge approve "Allow $tool?" --body "$cmd" --timeout 300 >/dev/null 2>&1; then',
468
+ ' exit 0 # human approved (Face ID) → let the tool run',
469
+ ' else',
470
+ ' echo "Blocked: no human approval for $tool" >&2',
471
+ ' exit 2 # exit 2 = PreToolUse BLOCK; fail-closed on deny/timeout/error',
472
+ ' fi',
473
+ ].join('\n'),
474
+ opts: [...CONTENT_OPTS, 'allow-label', 'deny-label', 'timeout', 'interval', 'realtime', 'no-realtime'],
475
+ },
439
476
  // COMPAT aliases — old names map to the new type (kept so scripts don't break).
440
477
  fyi: {
441
478
  summary: 'COMPAT alias of `pidge message` (renamed in 0.14 — the married catalog). Still works; prefer `message`.',
@@ -529,6 +566,11 @@ const command = parsed.positionals[0];
529
566
  // #241: silence the manifest-version nag entirely (per run via --quiet-nag, or
530
567
  // per environment via PIDGE_QUIET_NAG=1) — for scripts and CI where the nudge is noise.
531
568
  const QUIET_NAG = !!v['quiet-nag'] || process.env.PIDGE_QUIET_NAG === '1';
569
+ // lote-5 #4: `--quiet` collapses setup/doctor NARRATION to a single status line.
570
+ // `note()` prints an informational line only when NOT quiet; WARNINGS and ERRORS
571
+ // keep using console.error directly, so --quiet never hides a broken setup.
572
+ const QUIET = !!v.quiet;
573
+ const note = (msg) => { if (!QUIET) console.error(msg); };
532
574
 
533
575
  // Help on stdout, exit 0. #240: `pidge <cmd> --help` / `pidge help <cmd>` show the
534
576
  // FOCUSED help for that command (its synopsis + own flags); `pidge --help` / `help`
@@ -569,7 +611,9 @@ const KNOWN_MANIFEST_VERSION = 46;
569
611
  // Bumped to 2 in 0.15.3 so every 0.15.2 install (which baked the marker ABOVE the `---`,
570
612
  // corrupting the skill's description) is detected as stale and self-heals into the fixed
571
613
  // in-frontmatter format on the next command. Bump this whenever the hand-authored spine moves.
572
- const SKILL_REVISION = 2;
614
+ // Bumped to 3 in 0.16.0: the spine now teaches `pidge approve` (the hook-shaped gate)
615
+ // and notes the CLI now REFUSES a decision + reply in one send (lote-5 #2).
616
+ const SKILL_REVISION = 3;
573
617
  const NAG_TTL_MS = 24 * 60 * 60 * 1000; // #241: at most one nag per 24 h
574
618
  let newsWarned = false;
575
619
  // #280: the self-heal runs at most ONCE per process (one regeneration, even when
@@ -926,7 +970,7 @@ function buildBody(extra = {}) {
926
970
  if (trimmed.startsWith('[')) {
927
971
  let arr;
928
972
  try { arr = JSON.parse(trimmed); }
929
- catch (e) { die(`pidge: --actions looks like JSON but didn't parse (${e.message}). Use a JSON array of {"id","label"} objects, or the short form yes,no,reply`, 1); }
973
+ catch (e) { die(`pidge: --actions looks like JSON but didn't parse (${e.message}). Use a JSON array of {"id","label"} objects, or the short form yes,no (or reply alone)`, 1); }
930
974
  if (!Array.isArray(arr)) die('pidge: --actions JSON must be an ARRAY of {"id","label"} objects', 1);
931
975
  arr.forEach((item, i) => customActions.push(customActionFromJson(item, i)));
932
976
  } else {
@@ -936,6 +980,18 @@ function buildBody(extra = {}) {
936
980
  for (const spec of v['custom-action'] || []) customActions.push(customActionFromSpec(spec));
937
981
  if (customActions.length) body.custom_actions = customActions;
938
982
 
983
+ // lote-5 #2: REFUSE a decision button + `reply` in the same send (the skill's
984
+ // anti-slop rule #4). The human taps the easy Yes/No and you get a useless
985
+ // "Yes" instead of the typed text you wanted. One question per send — enforce
986
+ // it locally (exit 1, no round-trip), don't warn-and-send. (`reply` alongside a
987
+ // non-decision like done/snooze is fine — DONE_REPLY is a real category.)
988
+ if (Array.isArray(body.actions) && body.actions.includes('reply')) {
989
+ const DECISION_ACTIONS = ['yes', 'no', 'approve', 'reject', 'accept', 'decline', 'later'];
990
+ const decisions = body.actions.filter((a) => DECISION_ACTIONS.includes(a));
991
+ if (decisions.length)
992
+ die(`pidge: --actions can't combine a decision button (${decisions.join(',')}) with \`reply\` — the human taps the easy button and you get a useless "${decisions[0]}" instead of the text you wanted. Use \`--actions reply\` ALONE for a typed answer, or drop \`reply\` for a button decision. One question per send.`, 1);
993
+ }
994
+
939
995
  // #274: --gated synthesizes ONE Face-ID confirm on the consequential action
940
996
  // (money/deletion) — the replacement for the retired content_template:sensitive.
941
997
  // Skip if the agent already supplied a biometric action (don't double-gate).
@@ -1126,6 +1182,57 @@ async function doTypedSend(kind, { wait = false, extra = {}, requireAnswerable =
1126
1182
  await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
1127
1183
  }
1128
1184
 
1185
+ // `pidge approve` (#34) — a hook-shaped, DENY-DEFAULT permission gate. Sends a
1186
+ // Face-ID approval and BLOCKS, then maps the human's tap to an exit code: ONLY an
1187
+ // explicit allow is exit 0; deny, timeout, a dead channel or any ambiguity is
1188
+ // non-zero (exit 1) so a PreToolUse hook fails CLOSED. A thin wrapper over the
1189
+ // ask/wait long-poll: it fixes the two gated actions and swaps print-and-exit-0
1190
+ // for the exit-code mapping (via waitForAnswer's onAnswer/onTimeout).
1191
+ async function doApprove() {
1192
+ const question = parsed.positionals[1] || v.title;
1193
+ if (!question)
1194
+ die('pidge: usage: pidge approve "<question>" [--body TEXT] [--timeout N] [--allow-label L] [--deny-label L]', 1);
1195
+ v.title = question;
1196
+ const allowLabel = v['allow-label'] || 'Allow';
1197
+ const denyLabel = v['deny-label'] || 'Deny';
1198
+ // allow = Face-ID confirm (both confirm+biometric) · deny = destructive out.
1199
+ // Both terminal, both gated ⇒ the banner is detail-only (resolve_push_category →
1200
+ // HERALD_OPEN): approving is a deliberate in-app Face-ID tap, never a one-tap banner.
1201
+ const customActions = [
1202
+ { id: 'allow', label: allowLabel, confirm: true, biometric: true, terminal: true },
1203
+ { id: 'deny', label: denyLabel, style: 'destructive', terminal: true },
1204
+ ];
1205
+ const cid = v['correlation-id'] || crypto.randomUUID();
1206
+ v['correlation-id'] = cid;
1207
+ console.error(`pidge: correlation_id=${cid}`);
1208
+ const { ok, info } = await doNotify({ template_kind: 'important', custom_actions: customActions });
1209
+ if (!ok) {
1210
+ // Couldn't even ask the human ⇒ fail closed. (doNotify already narrated the
1211
+ // HTTP failure; a raw network error exits 2 inside doNotify — also non-zero.)
1212
+ console.error('pidge: could NOT send the approval — DENIED (deny-default; nothing was approved). exit 1');
1213
+ process.exit(1);
1214
+ }
1215
+ console.error(`pidge: approval sent (${info.registered_devices} device(s)) — waiting on ${cid} (only an explicit "${allowLabel}" is exit 0)`);
1216
+ await waitForAnswer(cid, {
1217
+ timeout: num(v.timeout, 300),
1218
+ interval: num(v.interval, 30),
1219
+ onAnswer: (chosen) => {
1220
+ console.log(JSON.stringify(chosen, null, 2)); // machine output on stdout
1221
+ if (chosen && chosen.action_id === 'allow') {
1222
+ console.error('pidge: ALLOWED — the human approved (Face ID). exit 0');
1223
+ process.exit(0);
1224
+ }
1225
+ console.error(`pidge: DENIED — the human chose "${(chosen && chosen.action_id) || '?'}" (deny-default: only an explicit allow is exit 0). exit 1`);
1226
+ process.exit(1);
1227
+ },
1228
+ onTimeout: () => {
1229
+ console.log(JSON.stringify({ decision: 'deny', reason: 'timeout', correlation_id: cid }));
1230
+ console.error('pidge: no answer before the timeout — DENIED (deny-default; a gate must fail closed). exit 1');
1231
+ process.exit(1);
1232
+ },
1233
+ });
1234
+ }
1235
+
1129
1236
  // A compat alias (perfis-S1): the OLD type name still works, mapped to the new
1130
1237
  // canonical one — a one-line note points at the rename so muscle-memory migrates.
1131
1238
  function warnRenamed(oldName, newName) {
@@ -1144,7 +1251,10 @@ function warnDeprecatedSend(name) {
1144
1251
  // Long-poll (#45): each GET carries ?wait=N (≤55 s) and the SERVER holds it until
1145
1252
  // the user acts — answer latency ~instant, ~1 request/min. --interval is only the
1146
1253
  // fallback pace against an old server that ignores `wait` (returns immediately).
1147
- async function doWait(cid, { timeout, interval }) {
1254
+ // #34: onAnswer(chosen)/onTimeout() let a caller (approve) MAP the outcome to an
1255
+ // exit code instead of the default print-chosen+exit-0 / exitTimeout. Both
1256
+ // callbacks MUST exit the process; when omitted the wait/ask behavior stands.
1257
+ async function doWait(cid, { timeout, interval, onAnswer, onTimeout } = {}) {
1148
1258
  const deadline = Date.now() + timeout * 1000;
1149
1259
  let firedNotice = false;
1150
1260
  for (;;) {
@@ -1163,6 +1273,8 @@ async function doWait(cid, { timeout, interval }) {
1163
1273
  const chosen = data.chosen_action || {};
1164
1274
  if (chosen.kind === 'snoozed') {
1165
1275
  console.error(`pidge: snoozed until ${chosen.snooze_until || chosen.at} — re-fires then, still waiting`);
1276
+ } else if (onAnswer) {
1277
+ return onAnswer(chosen);
1166
1278
  } else {
1167
1279
  console.log(JSON.stringify(chosen, null, 2));
1168
1280
  process.exit(0);
@@ -1188,6 +1300,7 @@ async function doWait(cid, { timeout, interval }) {
1188
1300
  }
1189
1301
 
1190
1302
  if (Date.now() >= deadline) {
1303
+ if (onTimeout) return onTimeout();
1191
1304
  health.exitTimeout(`no answer on ${cid}`);
1192
1305
  }
1193
1306
  // A server WITH long-poll just held us for waitS — loop right back. One that
@@ -1203,7 +1316,7 @@ async function doWait(cid, { timeout, interval }) {
1203
1316
  // for OUR cid as a wake-up; the durable answer is always re-read over HTTP
1204
1317
  // (doWait prints + exits). A safety re-check every 60 s covers a frame lost in
1205
1318
  // a reconnect gap. Returns only when WS can't carry us — caller falls back.
1206
- async function realtimeWait(cid, { timeout, interval }) {
1319
+ async function realtimeWait(cid, { timeout, interval, onAnswer, onTimeout } = {}) {
1207
1320
  const deadline = Date.now() + timeout * 1000;
1208
1321
  const answered = async () => {
1209
1322
  try {
@@ -1234,13 +1347,15 @@ async function realtimeWait(cid, { timeout, interval }) {
1234
1347
  });
1235
1348
  clearInterval(safety);
1236
1349
  if (outcome === 'answered') {
1237
- // fetch + print + exit via the poller (one quick authoritative read)
1238
- await doWait(cid, { timeout: Math.max(10, Math.ceil((deadline - Date.now()) / 1000)), interval });
1350
+ // fetch + resolve (print+exit, or the caller's onAnswer/onTimeout mapping) via
1351
+ // the poller (one quick authoritative read)
1352
+ await doWait(cid, { timeout: Math.max(10, Math.ceil((deadline - Date.now()) / 1000)), interval, onAnswer, onTimeout });
1239
1353
  }
1240
1354
  // Only exit-as-timeout if the REAL deadline genuinely passed. An EARLY
1241
1355
  // 'deadline' (a spurious guard, a WS oddity) must degrade to polling for the
1242
1356
  // remaining budget, NOT exit lying that the full timeout elapsed (#119).
1243
1357
  if (outcome === 'deadline' && Date.now() >= deadline - 1500) {
1358
+ if (onTimeout) return onTimeout();
1244
1359
  health.exitTimeout(`no answer on ${cid}`);
1245
1360
  }
1246
1361
  console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
@@ -1248,10 +1363,12 @@ async function realtimeWait(cid, { timeout, interval }) {
1248
1363
  }
1249
1364
 
1250
1365
  // wait/ask entry: WS when we can, polling as the universal fallback (#118/#119).
1251
- async function waitForAnswer(cid, { timeout, interval }) {
1366
+ // #34: onAnswer/onTimeout thread through to both paths so `approve` can map the
1367
+ // outcome to an exit code; omit them for the default print-and-exit-0 behavior.
1368
+ async function waitForAnswer(cid, { timeout, interval, onAnswer, onTimeout } = {}) {
1252
1369
  let budget = timeout;
1253
- if (wantRealtime()) budget = await realtimeWait(cid, { timeout, interval });
1254
- await doWait(cid, { timeout: budget, interval });
1370
+ if (wantRealtime()) budget = await realtimeWait(cid, { timeout, interval, onAnswer, onTimeout });
1371
+ await doWait(cid, { timeout: budget, interval, onAnswer, onTimeout });
1255
1372
  }
1256
1373
 
1257
1374
  const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
@@ -1582,8 +1699,8 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
1582
1699
  console.error('pidge doctor: NO TOKEN — set PIDGE_TOKEN, or onboard with `pidge setup --claim <code>` (the human copies the code from the Pidge app)');
1583
1700
  process.exit(2);
1584
1701
  }
1585
- console.error(`pidge doctor: token found (${source || 'passed in'}) — never displayed`);
1586
- console.error(`pidge doctor: server ${base}`);
1702
+ note(`pidge doctor: token found (${source || 'passed in'}) — never displayed`);
1703
+ note(`pidge doctor: server ${base}`);
1587
1704
  let out;
1588
1705
  try {
1589
1706
  out = await fetchWhoami(base, token);
@@ -1613,7 +1730,7 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
1613
1730
  process.exit(2);
1614
1731
  }
1615
1732
  const devices = data.devices ?? 0;
1616
- console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
1733
+ note(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
1617
1734
  if (devices === 0)
1618
1735
  console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
1619
1736
  // #182 device-reach honesty (gotcha #9) + #181 ownership — shared with whoami.
@@ -1635,18 +1752,22 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
1635
1752
  let realtime;
1636
1753
  if (rt.skipped) {
1637
1754
  realtime = 'skipped';
1638
- console.error('pidge doctor: realtime: skipped — this Node lacks a native WebSocket (need Node ≥22); `listen` will poll. Upgrade Node for instant delivery.');
1755
+ note('pidge doctor: realtime: skipped — this Node lacks a native WebSocket (need Node ≥22); `listen` will poll. Upgrade Node for instant delivery.');
1639
1756
  } else if (rt.ok) {
1640
1757
  realtime = 'ok';
1641
- console.error(`pidge doctor: realtime: ok (ws connect + subscribe em ${rt.ms}ms)`);
1758
+ note(`pidge doctor: realtime: ok (ws connect + subscribe em ${rt.ms}ms)`);
1642
1759
  } else {
1643
1760
  realtime = 'unavailable';
1644
- 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.`);
1761
+ note(`pidge doctor: realtime: INDISPONÍVEL — ${rt.reason}. O \`listen\` degrada pra polling (funciona, menos instantâneo); use --no-realtime pra fixar o piso.`);
1645
1762
  }
1646
1763
  // #229: lead with `pidge hello` — the first-contact WOW (send + wait in one),
1647
1764
  // the same debut the /agent-setup guide leads with. (#274: no --template hint —
1648
1765
  // `pidge hello` IS the entry point; the content_template surface is off the menu.)
1649
- console.error('pidge doctor: all good try: pidge hello (first-contact WOW send + wait in one)');
1766
+ // lote-5 #4: --quiet collapses ALL of the above to this single status line.
1767
+ if (QUIET)
1768
+ console.error(`pidge: ✓ setup ok — canal "${data.channel && data.channel.name}" · ${devices} device(s) · realtime ${realtime} (run \`pidge doctor\` for the full check)`);
1769
+ else
1770
+ console.error('pidge doctor: all good — try: pidge hello (first-contact WOW — send + wait in one)');
1650
1771
  console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version, realtime }));
1651
1772
  process.exit(0);
1652
1773
  }
@@ -1723,17 +1844,17 @@ async function runSetup() {
1723
1844
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
1724
1845
  fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
1725
1846
  try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
1726
- console.error(`pidge: canal "${channelName}" configurado — chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
1847
+ note(`pidge: canal "${channelName}" configurado — chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
1727
1848
  // #181: claim ownership of the channel for THIS install and record the
1728
1849
  // generation locally, so a later `pidge doctor` can DETECT a silent key swap
1729
1850
  // by a different agent (the v25 incident, now caught in code). Best-effort.
1730
1851
  const claim = await claimOwnership(finalBase, data.key);
1731
1852
  if (claim) {
1732
1853
  fs.appendFileSync(CONFIG_FILE, `PIDGE_CLAIM_GENERATION=${claim.claim_generation}\nPIDGE_FINGERPRINT=${agentFingerprint()}\n`, { mode: 0o600 });
1733
- console.error(`pidge: ownership claimed as "${agentLabel()}" (generation ${claim.claim_generation}) — doctor WARNS if another agent takes this channel.`);
1854
+ note(`pidge: ownership claimed as "${agentLabel()}" (generation ${claim.claim_generation}) — doctor WARNS if another agent takes this channel.`);
1734
1855
  }
1735
1856
  if (!AGENT_ID)
1736
- 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.');
1857
+ note('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.');
1737
1858
  await fuseSkillAndHello(finalBase, data.key);
1738
1859
  await runDoctor(finalBase, data.key, CONFIG_FILE);
1739
1860
  }
@@ -1747,11 +1868,11 @@ async function runSetup() {
1747
1868
  async function fuseSkillAndHello(base, token) {
1748
1869
  try {
1749
1870
  const r = await installSkill(base, token);
1750
- console.error(`pidge: skill written to ${r.file} (manifest v${r.manifest_version}) — your future sessions in this project know Pidge now`);
1871
+ note(`pidge: skill written to ${r.file} (manifest v${r.manifest_version}) — your future sessions in this project know Pidge now`);
1751
1872
  } catch (e) {
1752
1873
  console.error(`pidge: skill install skipped (${e.message}) — run \`pidge skill install\` later.`);
1753
1874
  }
1754
- console.error('pidge: next → `pidge hello` to send your first handshake and watch it confirm on the lock screen.');
1875
+ note('pidge: next → `pidge hello` to send your first handshake and watch it confirm on the lock screen.');
1755
1876
  }
1756
1877
 
1757
1878
  // skill install (#110e; rewritten #274 F3): persistent Pidge knowledge for AI
@@ -1805,6 +1926,7 @@ Every send is **a TYPE + a markdown body + an OPTIONAL response**. The TYPE (one
1805
1926
  | A pendency they should act on (can wait) ⭐ DEFAULT | \`pidge important\` |
1806
1927
  | You need a decision and CAN'T proceed without it | \`pidge important --actions yes,no --wait\` |
1807
1928
  | YOU are asking for a formal go/no-go (money/risk) | \`pidge approval\` |
1929
+ | Gate your OWN risky tool behind a human OK (a hook) | \`pidge approve "<question>"\` (exit 0 = allow) |
1808
1930
  | A thing with a known TIME | \`pidge event --event-at <ISO8601>\` |
1809
1931
  | A live status you'll keep updating | \`pidge live\` |
1810
1932
  | WAKE them now — rare, real, <1/day | \`pidge urgent\` |
@@ -1826,6 +1948,8 @@ The banner shows your **\`--title\`** and **\`--body\`** (plain text). **\`--bod
1826
1948
 
1827
1949
  **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.
1828
1950
 
1951
+ **\`pidge approve "<question>"\` — the hook-shaped gate (for permission hooks).** When YOU need the human to authorize one of YOUR OWN risky actions before you take it — and you want the answer as an EXIT CODE, not JSON to parse — use \`pidge approve\`. It sends a Face-ID allow / deny pair, blocks, and is **DENY-DEFAULT: exit 0 ONLY on an explicit allow; deny, timeout, or a broken channel → non-zero.** Perfect for a Claude Code \`PreToolUse\` hook that must fail CLOSED (see \`pidge approve --help\` for a runnable hook). \`pidge approval\` is the JSON-answer sibling (Path A); \`pidge approve\` is the exit-code gate.
1952
+
1829
1953
  ## The response axis (composes on ANY type)
1830
1954
 
1831
1955
  Asking for a reply is orthogonal to the type — you don't need \`approval\` to get a button.
@@ -1837,14 +1961,14 @@ Asking for a reply is orthogonal to the type — you don't need \`approval\` to
1837
1961
  - *wait*: \`--wait\` (or \`pidge ask\`) **blocks** until they tap. Use it when you can't proceed.
1838
1962
  - **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.
1839
1963
 
1840
- 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.
1964
+ Need a TYPED reply (a time/value/name)? \`--actions reply\` ALONE — never a decision + \`reply\` together (the human taps the easy button and you get a useless "Yes"). The CLI now **refuses** \`yes,no,reply\` (exit 1) so you can't ship the trap by accident. ONE question per send.
1841
1965
 
1842
1966
  ## Anti-slop rules (judgment a recipe can't teach)
1843
1967
 
1844
1968
  1. **One send = one fact = one ask.** Never two questions in a notification.
1845
1969
  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.
1846
1970
  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).
1847
- 4. **Typed answer? \`--actions reply\` ALONE** — never \`yes,no,reply\` together.
1971
+ 4. **Typed answer? \`--actions reply\` ALONE** — never a decision + \`reply\` together (the CLI refuses it, exit 1).
1848
1972
  5. **Trust the 201 echo over your intent** — \`degraded\`/\`render_mode\`/\`registered_devices\`. \`registered_devices:0\` ⇒ it went nowhere; don't wait.
1849
1973
  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.
1850
1974
  7. **Be listening when the answer lands, or you lose it.** Ack only AFTER the work is durably done.
@@ -1896,7 +2020,7 @@ generate_report | pidge important --title "Report ready" \\
1896
2020
  - **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\`.
1897
2021
  - **\`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.
1898
2022
  - **A 201 ≠ "seen."** \`registered_devices:0\` goes nowhere; \`delivered\` is APNs dispatch, not eyes; only \`seen_at\`/an answer is the human.
1899
- - **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.
2023
+ - **The ask reply-vs-yes/no trap.** \`--actions yes,no,reply\` let the human dodge a typed answer with one tap — so the CLI now REFUSES a decision + \`reply\` in one send (exit 1). Use \`--actions reply\` alone when you need text.
1900
2024
  - **\`event\` is quiet today** — \`event --event-at\` schedules; the countdown LA-as-primitive is still being built.
1901
2025
  - **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.
1902
2026
  - **The banner ≠ the detail screen.** Lock-screen banner = \`title\` + \`body\` (plain). \`body_markdown\`/images render only when the human taps in. A send with only \`--title\` can look empty on the lock screen — always include a \`--body\`.
@@ -2017,6 +2141,12 @@ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE w
2017
2141
  await doTypedSend('important', { wait: true, extra, label: 'approval' });
2018
2142
  break;
2019
2143
  }
2144
+ // #34 — the hook-shaped, deny-default permission gate (allow→0, everything
2145
+ // else→non-zero). See doApprove + `pidge approve --help` (PreToolUse example).
2146
+ case 'approve': {
2147
+ await doApprove();
2148
+ break;
2149
+ }
2020
2150
  case 'notify':
2021
2151
  case 'send': {
2022
2152
  warnDeprecatedSend(command);
@@ -2178,8 +2308,15 @@ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE w
2178
2308
  // composer-only contract stands (no double-consumption for ask/wait users).
2179
2309
  installOrphanWatchdog(); // §3c: a killed-parent orphan exits instead of eating the queue
2180
2310
  const timeout = num(v.timeout, 600);
2311
+ const listenStartedAt = Date.now();
2181
2312
  let deadline = Date.now() + timeout * 1000;
2182
2313
  const queueQs = v.all ? '?all=true' : '';
2314
+ // lote-5 #5: the FIRST batch that comes back QUICKLY was already sitting in
2315
+ // the queue when this listen started — with --all that includes answers to
2316
+ // EARLIER notifications, which read as "new" if we don't say otherwise. A
2317
+ // batch that arrives after a real hold (a long-poll that waited) is fresh.
2318
+ const BACKLOG_WINDOW_MS = 5000;
2319
+ let firstBatch = true;
2183
2320
  // §2.6: --follow is SUPERVISOR-ONLY — warn LOUDLY at startup. A turn-based
2184
2321
  // agent that uses it traps its turn (the process keeps listening); the
2185
2322
  // default one-shot, looped from the supervisor, is what almost everyone wants.
@@ -2210,6 +2347,14 @@ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE w
2210
2347
  // Print + (conditionally) ack — shared by the WS and polling paths.
2211
2348
  const printAndAck = async (msgs) => {
2212
2349
  console.log(JSON.stringify(msgs, null, 2));
2350
+ // lote-5 #5: heads-up on ORPHANED backlog served on the first quick read
2351
+ // (--all only). It's within-channel — NOT the cross-channel leak (#289).
2352
+ if (v.all && firstBatch && (Date.now() - listenStartedAt) < BACKLOG_WINDOW_MS) {
2353
+ const replies = msgs.filter((m) => m.kind === 'notification_reply').length;
2354
+ const detail = replies ? ` (${replies} of them are answers to EARLIER notifications)` : '';
2355
+ console.error(`pidge: --all — ${msgs.length} message(s) were ALREADY queued when this listen started${detail}: OLD backlog (sent while you weren't listening), NOT fresh arrivals. This is your OWN channel's backlog, not a cross-channel leak (#289).`);
2356
+ }
2357
+ firstBatch = false;
2213
2358
  // #131: narrate answers so the agent knows WHICH notification spoke back.
2214
2359
  for (const m of msgs) {
2215
2360
  if (m.kind === 'notification_reply' && m.ref) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.15.3",
3
+ "version": "0.16.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",