pidge-cli 0.15.2 → 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.
- package/CHANGELOG.md +49 -0
- package/bin/pidge.js +198 -37
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
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
|
+
|
|
34
|
+
## 0.15.3 — #33 fix: the self-heal marker no longer corrupts the skill
|
|
35
|
+
|
|
36
|
+
The 0.15.2 marker was written as the FIRST line of `SKILL.md`, ABOVE the opening `---`. A
|
|
37
|
+
SKILL.md whose first line isn't `---` fails Claude Code's YAML frontmatter parse: the skill
|
|
38
|
+
still appears, but with a GARBAGE description (the HTML comment leaks in as the description and
|
|
39
|
+
the real `name`/`description` are lost) — so the agent's Claude Code never learns WHEN to use
|
|
40
|
+
Pidge. Verified on a live headless `claude` run: a marker-first probe skill loaded with its
|
|
41
|
+
description showing `<!-- pidge-skill … -->` instead of its real text, while an identical
|
|
42
|
+
`---`-first control loaded correctly.
|
|
43
|
+
|
|
44
|
+
- **fix:** the marker now rides a `# pidge-skill rev=R manifest=N` YAML COMMENT INSIDE the
|
|
45
|
+
frontmatter (a `#` comment is valid YAML and invisible to `name`/`description`), so the
|
|
46
|
+
frontmatter opens on line 1 and parses cleanly while the marker still travels with the file.
|
|
47
|
+
- **fix:** `ensureSkillFresh()` reads the marker from its new in-frontmatter position and still
|
|
48
|
+
tolerates the old line-1 `<!-- … -->` marker, so a 0.15.2 install is detected as stale.
|
|
49
|
+
- **fix:** `SKILL_REVISION` bumped 1 → 2, so every 0.15.2 install (all rev=1, all broken) is
|
|
50
|
+
seen as stale and self-heals into the corrected format on the next command — zero human action.
|
|
51
|
+
|
|
3
52
|
## 0.15.2 — #280 the skill self-heals
|
|
4
53
|
|
|
5
54
|
#280 — the local skill self-heals: any pidge command silently refreshes
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
|
@@ -566,7 +608,12 @@ const KNOWN_MANIFEST_VERSION = 46;
|
|
|
566
608
|
// (the non-generated prose in installSkill) changes — an existing install whose
|
|
567
609
|
// baked marker is older than this self-heals on its next pidge command, so an
|
|
568
610
|
// onboarded agent always runs the latest skill without any human action. Start at 1.
|
|
569
|
-
|
|
611
|
+
// Bumped to 2 in 0.15.3 so every 0.15.2 install (which baked the marker ABOVE the `---`,
|
|
612
|
+
// corrupting the skill's description) is detected as stale and self-heals into the fixed
|
|
613
|
+
// in-frontmatter format on the next command. Bump this whenever the hand-authored spine moves.
|
|
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;
|
|
570
617
|
const NAG_TTL_MS = 24 * 60 * 60 * 1000; // #241: at most one nag per 24 h
|
|
571
618
|
let newsWarned = false;
|
|
572
619
|
// #280: the self-heal runs at most ONCE per process (one regeneration, even when
|
|
@@ -638,9 +685,15 @@ async function ensureSkillFresh(serverManifestVersion) {
|
|
|
638
685
|
// Resolve the path the SAME way installSkill does (cwd-relative).
|
|
639
686
|
const file = path.join(process.cwd(), '.claude', 'skills', 'pidge', 'SKILL.md');
|
|
640
687
|
if (!fs.existsSync(file)) return; // don't auto-create — only refresh an existing skill
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
688
|
+
// #33 fix: the marker now rides a `# pidge-skill rev=N manifest=M` YAML comment INSIDE
|
|
689
|
+
// the frontmatter (0.15.3+); pre-0.15.3 installs put `<!-- pidge-skill … -->` as line 1.
|
|
690
|
+
// Scan for the marker line either way — the token `pidge-skill` appears ONLY there in a
|
|
691
|
+
// generated skill — so a stale OLD-format install is still detected and healed into the
|
|
692
|
+
// corrected format (its garbage-description bug repaired) on the next command.
|
|
693
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
694
|
+
const markerLine = content.split('\n').find((l) => l.includes('pidge-skill')) || '';
|
|
695
|
+
const revM = markerLine.match(/rev=(\d+)/);
|
|
696
|
+
const manM = markerLine.match(/manifest=(\d+)/);
|
|
644
697
|
const installedRev = revM ? parseInt(revM[1], 10) : 0;
|
|
645
698
|
const installedManifest = manM ? parseInt(manM[1], 10) : 0;
|
|
646
699
|
const stale = SKILL_REVISION > installedRev || (serverManifestVersion || 0) > installedManifest;
|
|
@@ -917,7 +970,7 @@ function buildBody(extra = {}) {
|
|
|
917
970
|
if (trimmed.startsWith('[')) {
|
|
918
971
|
let arr;
|
|
919
972
|
try { arr = JSON.parse(trimmed); }
|
|
920
|
-
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
|
|
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); }
|
|
921
974
|
if (!Array.isArray(arr)) die('pidge: --actions JSON must be an ARRAY of {"id","label"} objects', 1);
|
|
922
975
|
arr.forEach((item, i) => customActions.push(customActionFromJson(item, i)));
|
|
923
976
|
} else {
|
|
@@ -927,6 +980,18 @@ function buildBody(extra = {}) {
|
|
|
927
980
|
for (const spec of v['custom-action'] || []) customActions.push(customActionFromSpec(spec));
|
|
928
981
|
if (customActions.length) body.custom_actions = customActions;
|
|
929
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
|
+
|
|
930
995
|
// #274: --gated synthesizes ONE Face-ID confirm on the consequential action
|
|
931
996
|
// (money/deletion) — the replacement for the retired content_template:sensitive.
|
|
932
997
|
// Skip if the agent already supplied a biometric action (don't double-gate).
|
|
@@ -1117,6 +1182,57 @@ async function doTypedSend(kind, { wait = false, extra = {}, requireAnswerable =
|
|
|
1117
1182
|
await waitForAnswer(cid, { timeout, interval: num(v.interval, 30) });
|
|
1118
1183
|
}
|
|
1119
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
|
+
|
|
1120
1236
|
// A compat alias (perfis-S1): the OLD type name still works, mapped to the new
|
|
1121
1237
|
// canonical one — a one-line note points at the rename so muscle-memory migrates.
|
|
1122
1238
|
function warnRenamed(oldName, newName) {
|
|
@@ -1135,7 +1251,10 @@ function warnDeprecatedSend(name) {
|
|
|
1135
1251
|
// Long-poll (#45): each GET carries ?wait=N (≤55 s) and the SERVER holds it until
|
|
1136
1252
|
// the user acts — answer latency ~instant, ~1 request/min. --interval is only the
|
|
1137
1253
|
// fallback pace against an old server that ignores `wait` (returns immediately).
|
|
1138
|
-
|
|
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 } = {}) {
|
|
1139
1258
|
const deadline = Date.now() + timeout * 1000;
|
|
1140
1259
|
let firedNotice = false;
|
|
1141
1260
|
for (;;) {
|
|
@@ -1154,6 +1273,8 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
1154
1273
|
const chosen = data.chosen_action || {};
|
|
1155
1274
|
if (chosen.kind === 'snoozed') {
|
|
1156
1275
|
console.error(`pidge: snoozed until ${chosen.snooze_until || chosen.at} — re-fires then, still waiting`);
|
|
1276
|
+
} else if (onAnswer) {
|
|
1277
|
+
return onAnswer(chosen);
|
|
1157
1278
|
} else {
|
|
1158
1279
|
console.log(JSON.stringify(chosen, null, 2));
|
|
1159
1280
|
process.exit(0);
|
|
@@ -1179,6 +1300,7 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
1179
1300
|
}
|
|
1180
1301
|
|
|
1181
1302
|
if (Date.now() >= deadline) {
|
|
1303
|
+
if (onTimeout) return onTimeout();
|
|
1182
1304
|
health.exitTimeout(`no answer on ${cid}`);
|
|
1183
1305
|
}
|
|
1184
1306
|
// A server WITH long-poll just held us for waitS — loop right back. One that
|
|
@@ -1194,7 +1316,7 @@ async function doWait(cid, { timeout, interval }) {
|
|
|
1194
1316
|
// for OUR cid as a wake-up; the durable answer is always re-read over HTTP
|
|
1195
1317
|
// (doWait prints + exits). A safety re-check every 60 s covers a frame lost in
|
|
1196
1318
|
// a reconnect gap. Returns only when WS can't carry us — caller falls back.
|
|
1197
|
-
async function realtimeWait(cid, { timeout, interval }) {
|
|
1319
|
+
async function realtimeWait(cid, { timeout, interval, onAnswer, onTimeout } = {}) {
|
|
1198
1320
|
const deadline = Date.now() + timeout * 1000;
|
|
1199
1321
|
const answered = async () => {
|
|
1200
1322
|
try {
|
|
@@ -1225,13 +1347,15 @@ async function realtimeWait(cid, { timeout, interval }) {
|
|
|
1225
1347
|
});
|
|
1226
1348
|
clearInterval(safety);
|
|
1227
1349
|
if (outcome === 'answered') {
|
|
1228
|
-
// fetch + print
|
|
1229
|
-
|
|
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 });
|
|
1230
1353
|
}
|
|
1231
1354
|
// Only exit-as-timeout if the REAL deadline genuinely passed. An EARLY
|
|
1232
1355
|
// 'deadline' (a spurious guard, a WS oddity) must degrade to polling for the
|
|
1233
1356
|
// remaining budget, NOT exit lying that the full timeout elapsed (#119).
|
|
1234
1357
|
if (outcome === 'deadline' && Date.now() >= deadline - 1500) {
|
|
1358
|
+
if (onTimeout) return onTimeout();
|
|
1235
1359
|
health.exitTimeout(`no answer on ${cid}`);
|
|
1236
1360
|
}
|
|
1237
1361
|
console.error('pidge: realtime unavailable — falling back to HTTP polling (same contract, less instant)');
|
|
@@ -1239,10 +1363,12 @@ async function realtimeWait(cid, { timeout, interval }) {
|
|
|
1239
1363
|
}
|
|
1240
1364
|
|
|
1241
1365
|
// wait/ask entry: WS when we can, polling as the universal fallback (#118/#119).
|
|
1242
|
-
|
|
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 } = {}) {
|
|
1243
1369
|
let budget = timeout;
|
|
1244
|
-
if (wantRealtime()) budget = await realtimeWait(cid, { timeout, interval });
|
|
1245
|
-
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 });
|
|
1246
1372
|
}
|
|
1247
1373
|
|
|
1248
1374
|
const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
|
|
@@ -1573,8 +1699,8 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
|
1573
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)');
|
|
1574
1700
|
process.exit(2);
|
|
1575
1701
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1702
|
+
note(`pidge doctor: token found (${source || 'passed in'}) — never displayed`);
|
|
1703
|
+
note(`pidge doctor: server ${base}`);
|
|
1578
1704
|
let out;
|
|
1579
1705
|
try {
|
|
1580
1706
|
out = await fetchWhoami(base, token);
|
|
@@ -1604,7 +1730,7 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
|
1604
1730
|
process.exit(2);
|
|
1605
1731
|
}
|
|
1606
1732
|
const devices = data.devices ?? 0;
|
|
1607
|
-
|
|
1733
|
+
note(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
|
|
1608
1734
|
if (devices === 0)
|
|
1609
1735
|
console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
|
|
1610
1736
|
// #182 device-reach honesty (gotcha #9) + #181 ownership — shared with whoami.
|
|
@@ -1626,18 +1752,22 @@ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
|
|
|
1626
1752
|
let realtime;
|
|
1627
1753
|
if (rt.skipped) {
|
|
1628
1754
|
realtime = 'skipped';
|
|
1629
|
-
|
|
1755
|
+
note('pidge doctor: realtime: skipped — this Node lacks a native WebSocket (need Node ≥22); `listen` will poll. Upgrade Node for instant delivery.');
|
|
1630
1756
|
} else if (rt.ok) {
|
|
1631
1757
|
realtime = 'ok';
|
|
1632
|
-
|
|
1758
|
+
note(`pidge doctor: realtime: ok (ws connect + subscribe em ${rt.ms}ms)`);
|
|
1633
1759
|
} else {
|
|
1634
1760
|
realtime = 'unavailable';
|
|
1635
|
-
|
|
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.`);
|
|
1636
1762
|
}
|
|
1637
1763
|
// #229: lead with `pidge hello` — the first-contact WOW (send + wait in one),
|
|
1638
1764
|
// the same debut the /agent-setup guide leads with. (#274: no --template hint —
|
|
1639
1765
|
// `pidge hello` IS the entry point; the content_template surface is off the menu.)
|
|
1640
|
-
|
|
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)');
|
|
1641
1771
|
console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version, realtime }));
|
|
1642
1772
|
process.exit(0);
|
|
1643
1773
|
}
|
|
@@ -1714,17 +1844,17 @@ async function runSetup() {
|
|
|
1714
1844
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
1715
1845
|
fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
|
|
1716
1846
|
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
|
|
1717
|
-
|
|
1847
|
+
note(`pidge: canal "${channelName}" configurado — chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
|
|
1718
1848
|
// #181: claim ownership of the channel for THIS install and record the
|
|
1719
1849
|
// generation locally, so a later `pidge doctor` can DETECT a silent key swap
|
|
1720
1850
|
// by a different agent (the v25 incident, now caught in code). Best-effort.
|
|
1721
1851
|
const claim = await claimOwnership(finalBase, data.key);
|
|
1722
1852
|
if (claim) {
|
|
1723
1853
|
fs.appendFileSync(CONFIG_FILE, `PIDGE_CLAIM_GENERATION=${claim.claim_generation}\nPIDGE_FINGERPRINT=${agentFingerprint()}\n`, { mode: 0o600 });
|
|
1724
|
-
|
|
1854
|
+
note(`pidge: ownership claimed as "${agentLabel()}" (generation ${claim.claim_generation}) — doctor WARNS if another agent takes this channel.`);
|
|
1725
1855
|
}
|
|
1726
1856
|
if (!AGENT_ID)
|
|
1727
|
-
|
|
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.');
|
|
1728
1858
|
await fuseSkillAndHello(finalBase, data.key);
|
|
1729
1859
|
await runDoctor(finalBase, data.key, CONFIG_FILE);
|
|
1730
1860
|
}
|
|
@@ -1738,11 +1868,11 @@ async function runSetup() {
|
|
|
1738
1868
|
async function fuseSkillAndHello(base, token) {
|
|
1739
1869
|
try {
|
|
1740
1870
|
const r = await installSkill(base, token);
|
|
1741
|
-
|
|
1871
|
+
note(`pidge: skill written to ${r.file} (manifest v${r.manifest_version}) — your future sessions in this project know Pidge now`);
|
|
1742
1872
|
} catch (e) {
|
|
1743
1873
|
console.error(`pidge: skill install skipped (${e.message}) — run \`pidge skill install\` later.`);
|
|
1744
1874
|
}
|
|
1745
|
-
|
|
1875
|
+
note('pidge: next → `pidge hello` to send your first handshake and watch it confirm on the lock screen.');
|
|
1746
1876
|
}
|
|
1747
1877
|
|
|
1748
1878
|
// skill install (#110e; rewritten #274 F3): persistent Pidge knowledge for AI
|
|
@@ -1765,10 +1895,17 @@ async function installSkill(base = BASE, token = TOKEN) {
|
|
|
1765
1895
|
const profileTable = (m.profiles && m.profiles.decision_table) || [];
|
|
1766
1896
|
const notes = m.notes || [];
|
|
1767
1897
|
const exits = (m.cli && m.cli.output) || '';
|
|
1768
|
-
|
|
1769
|
-
|
|
1898
|
+
// #33 fix (0.15.3): the self-heal marker rides a `# pidge-skill …` YAML COMMENT INSIDE
|
|
1899
|
+
// the frontmatter — it MUST NOT precede the opening `---`. A SKILL.md whose first line
|
|
1900
|
+
// isn't `---` fails the YAML frontmatter parse, so Claude Code loads the skill with a
|
|
1901
|
+
// GARBAGE description (the HTML comment leaked in as the description, the real one lost)
|
|
1902
|
+
// — proven on a live headless run. A `#` comment line is valid YAML and invisible to
|
|
1903
|
+
// name/description, so the marker survives without corrupting the load. ensureSkillFresh
|
|
1904
|
+
// reads it from this position (and still tolerates the old line-1 marker to heal it).
|
|
1905
|
+
const skill = `---
|
|
1770
1906
|
name: pidge
|
|
1771
1907
|
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.
|
|
1908
|
+
# pidge-skill rev=${SKILL_REVISION} manifest=${m.manifest_version}
|
|
1772
1909
|
---
|
|
1773
1910
|
|
|
1774
1911
|
# Pidge — notify your human, get answers back
|
|
@@ -1789,6 +1926,7 @@ Every send is **a TYPE + a markdown body + an OPTIONAL response**. The TYPE (one
|
|
|
1789
1926
|
| A pendency they should act on (can wait) ⭐ DEFAULT | \`pidge important\` |
|
|
1790
1927
|
| You need a decision and CAN'T proceed without it | \`pidge important --actions yes,no --wait\` |
|
|
1791
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) |
|
|
1792
1930
|
| A thing with a known TIME | \`pidge event --event-at <ISO8601>\` |
|
|
1793
1931
|
| A live status you'll keep updating | \`pidge live\` |
|
|
1794
1932
|
| WAKE them now — rare, real, <1/day | \`pidge urgent\` |
|
|
@@ -1810,6 +1948,8 @@ The banner shows your **\`--title\`** and **\`--body\`** (plain text). **\`--bod
|
|
|
1810
1948
|
|
|
1811
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.
|
|
1812
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
|
+
|
|
1813
1953
|
## The response axis (composes on ANY type)
|
|
1814
1954
|
|
|
1815
1955
|
Asking for a reply is orthogonal to the type — you don't need \`approval\` to get a button.
|
|
@@ -1821,14 +1961,14 @@ Asking for a reply is orthogonal to the type — you don't need \`approval\` to
|
|
|
1821
1961
|
- *wait*: \`--wait\` (or \`pidge ask\`) **blocks** until they tap. Use it when you can't proceed.
|
|
1822
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.
|
|
1823
1963
|
|
|
1824
|
-
Need a TYPED reply (a time/value/name)? \`--actions reply\` ALONE — never \`
|
|
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.
|
|
1825
1965
|
|
|
1826
1966
|
## Anti-slop rules (judgment a recipe can't teach)
|
|
1827
1967
|
|
|
1828
1968
|
1. **One send = one fact = one ask.** Never two questions in a notification.
|
|
1829
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.
|
|
1830
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).
|
|
1831
|
-
4. **Typed answer? \`--actions reply\` ALONE** — never \`
|
|
1971
|
+
4. **Typed answer? \`--actions reply\` ALONE** — never a decision + \`reply\` together (the CLI refuses it, exit 1).
|
|
1832
1972
|
5. **Trust the 201 echo over your intent** — \`degraded\`/\`render_mode\`/\`registered_devices\`. \`registered_devices:0\` ⇒ it went nowhere; don't wait.
|
|
1833
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.
|
|
1834
1974
|
7. **Be listening when the answer lands, or you lose it.** Ack only AFTER the work is durably done.
|
|
@@ -1880,7 +2020,7 @@ generate_report | pidge important --title "Report ready" \\
|
|
|
1880
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\`.
|
|
1881
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.
|
|
1882
2022
|
- **A 201 ≠ "seen."** \`registered_devices:0\` goes nowhere; \`delivered\` is APNs dispatch, not eyes; only \`seen_at\`/an answer is the human.
|
|
1883
|
-
- **The ask reply-vs-yes/no trap.** \`--actions yes,no,reply\`
|
|
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.
|
|
1884
2024
|
- **\`event\` is quiet today** — \`event --event-at\` schedules; the countdown LA-as-primitive is still being built.
|
|
1885
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.
|
|
1886
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\`.
|
|
@@ -2001,6 +2141,12 @@ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE w
|
|
|
2001
2141
|
await doTypedSend('important', { wait: true, extra, label: 'approval' });
|
|
2002
2142
|
break;
|
|
2003
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
|
+
}
|
|
2004
2150
|
case 'notify':
|
|
2005
2151
|
case 'send': {
|
|
2006
2152
|
warnDeprecatedSend(command);
|
|
@@ -2162,8 +2308,15 @@ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE w
|
|
|
2162
2308
|
// composer-only contract stands (no double-consumption for ask/wait users).
|
|
2163
2309
|
installOrphanWatchdog(); // §3c: a killed-parent orphan exits instead of eating the queue
|
|
2164
2310
|
const timeout = num(v.timeout, 600);
|
|
2311
|
+
const listenStartedAt = Date.now();
|
|
2165
2312
|
let deadline = Date.now() + timeout * 1000;
|
|
2166
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;
|
|
2167
2320
|
// §2.6: --follow is SUPERVISOR-ONLY — warn LOUDLY at startup. A turn-based
|
|
2168
2321
|
// agent that uses it traps its turn (the process keeps listening); the
|
|
2169
2322
|
// default one-shot, looped from the supervisor, is what almost everyone wants.
|
|
@@ -2194,6 +2347,14 @@ A turn-based agent (Claude Code, anything invoked on demand) stays COMMANDABLE w
|
|
|
2194
2347
|
// Print + (conditionally) ack — shared by the WS and polling paths.
|
|
2195
2348
|
const printAndAck = async (msgs) => {
|
|
2196
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;
|
|
2197
2358
|
// #131: narrate answers so the agent knows WHICH notification spoke back.
|
|
2198
2359
|
for (const m of msgs) {
|
|
2199
2360
|
if (m.kind === 'notification_reply' && m.ref) {
|