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