pendingyou 0.32.3 → 0.34.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 +26 -0
- package/README.md +36 -0
- package/dist/apps/claude-code.js +20 -2
- package/dist/apps/codex.js +63 -4
- package/dist/args.js +27 -0
- package/dist/channel.js +276 -0
- package/dist/claude.js +117 -1
- package/dist/codex-answers.js +15 -0
- package/dist/io.js +26 -0
- package/dist/main.js +18 -1
- package/dist/permission.js +505 -14
- package/dist/redact.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.0 (2026-10-09)
|
|
4
|
+
|
|
5
|
+
- Codex can ask on your phone first. `npx pendingyou codex-answers --wait <minutes>` (0 to 10; 0, the default, is off)
|
|
6
|
+
has Codex's permission prompts go to your Pending You card at once, with **Allow** and **Deny** and the whole command
|
|
7
|
+
or patch, its secrets masked. Codex shows nothing while its hook waits, so tap one and Codex goes on, or is refused
|
|
8
|
+
(“Denied in Pending You.”); with no answer within the wait, the card is withdrawn and Codex asks in its terminal as
|
|
9
|
+
usual. It sets the PermissionRequest hook's timeout in Codex's hooks.json to the wait plus 30 seconds (Codex sets no
|
|
10
|
+
upper limit for that hook), which changes the hook: trust it again in Codex's `/hooks`, as the command says. `init`
|
|
11
|
+
names the setting when it sets Codex up and keeps it, `status` shows it, and `uninstall` clears it. Needs Pending You
|
|
12
|
+
with `permissionPrompt` (0.33.0's server).
|
|
13
|
+
|
|
14
|
+
## 0.33.0 (2026-10-09)
|
|
15
|
+
|
|
16
|
+
- Answer Claude Code's permission prompts on their Pending You card. Start Claude Code with `npx pendingyou claude
|
|
17
|
+
[args…]` (it runs `claude --dangerously-load-development-channels server:pendingyou-permissions [args…]`) and a
|
|
18
|
+
prompt left waiting gets a card with **Allow** and **Deny** and the whole input, its secrets masked, instead of a
|
|
19
|
+
card that only says where to answer. The terminal dialog stays open: whichever answer comes first wins, and Allow is
|
|
20
|
+
for that one call only. `init` registers the channel (`pendingyou channel --app claude-code`, a stdio MCP server that
|
|
21
|
+
sends nothing to the model) at user scope through the hooks' shim, beside the permission-prompt hooks, on Claude
|
|
22
|
+
Code 2.1.234 or later; `uninstall` and `--no-permission-cards` take it out; `status` says whether it's there. The card
|
|
23
|
+
still waits the same 10 seconds, so a prompt answered at the terminal gets none; answered there later, the card goes
|
|
24
|
+
as before. A session started with plain `claude` keeps today's card, which now says it's a permission
|
|
25
|
+
prompt's (`permissionPrompt` with `relay: false`). Needs Pending You with `post_request`'s `permissionPrompt`
|
|
26
|
+
(deploy the server first). Claude Code warns about the development channel at each start until
|
|
27
|
+
the channel is on its allowlist.
|
|
28
|
+
|
|
3
29
|
## 0.32.3 (2026-10-09)
|
|
4
30
|
|
|
5
31
|
- Claude Code's and Codex's permission prompts get their cards. The worker posted each card without `context`, which
|
package/README.md
CHANGED
|
@@ -274,6 +274,40 @@ PermissionRequest hook prints nothing, so Codex asks exactly as before; the card
|
|
|
274
274
|
Codex, the turn ends or the thread closes. It needs Codex signing in through this computer's sign-in, and trusting the
|
|
275
275
|
new hooks once. The Codex app's browser-use prompts don't run that hook, so they get no card.
|
|
276
276
|
|
|
277
|
+
### Allow or Deny on the card (0.33.0)
|
|
278
|
+
|
|
279
|
+
Start Claude Code with `npx pendingyou claude` (any of `claude`'s own arguments after it) and a prompt left waiting gets
|
|
280
|
+
a card with **Allow** and **Deny**, and the whole command or input it asks about, secrets masked: “Allow Claude Code
|
|
281
|
+
(billing-webhooks) to run a command?”. Tap one and the terminal's dialog closes and the call runs, or is refused. The
|
|
282
|
+
dialog stays open meanwhile, so you can still answer at the terminal: the first answer wins, and the card goes. Allow
|
|
283
|
+
is for that one call only; nothing is allowed after it.
|
|
284
|
+
|
|
285
|
+
It works through Claude Code's channels (a research preview): `init` registers a small local MCP server,
|
|
286
|
+
`pendingyou-permissions`, that Claude Code relays its permission prompts to in a session started this way, and that
|
|
287
|
+
sends Claude nothing. Until it's on Claude Code's allowlist, Claude Code shows a warning about development channels at
|
|
288
|
+
each start (`pendingyou claude` runs `claude --dangerously-load-development-channels server:pendingyou-permissions`).
|
|
289
|
+
It needs Claude Code 2.1.234 or later and the permission cards on; a session started with plain `claude` gets the card
|
|
290
|
+
without buttons, as above.
|
|
291
|
+
|
|
292
|
+
### Codex: ask on my phone first (0.34.0)
|
|
293
|
+
|
|
294
|
+
Codex shows nothing while its PermissionRequest hook runs, so its prompt can't be on the card and in the terminal at
|
|
295
|
+
once. Instead you can have it ask on your phone first:
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
npx pendingyou codex-answers --wait 2
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Now when Codex needs your OK, a card goes up at once with **Allow** and **Deny** and the whole command or patch, secrets
|
|
302
|
+
masked: “Allow Codex (infra) to run a command?”. Tap one and Codex goes on (or is refused, told “Denied in Pending
|
|
303
|
+
You.”). With no answer within the wait, the card goes and Codex asks in its terminal as usual. The wait is 0 to 10
|
|
304
|
+
minutes, per computer; 0, the default, turns it off (the card without buttons, as above). `init` names it when it sets
|
|
305
|
+
Codex up, `status` shows it, and `uninstall` clears it.
|
|
306
|
+
|
|
307
|
+
It gives Codex's PermissionRequest hook the wait plus 30 seconds (630 seconds for 10 minutes; Codex sets no upper limit
|
|
308
|
+
for that hook). Codex trusts a hook by its whole definition, timeout included, so after changing the wait trust the hook
|
|
309
|
+
again: open Codex, type `/hooks`, and trust it. Allow is for that one call only; nothing is allowed after it.
|
|
310
|
+
|
|
277
311
|
## Which sessions are open
|
|
278
312
|
|
|
279
313
|
Since 0.15.0 Pending You hears which of your agents' sessions are open right now, so it can say so and offer an open
|
|
@@ -501,6 +535,8 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
|
|
|
501
535
|
| `hold <requestId>` | Wait for the answer, print it, exit (`--timeout 4h`); the wake mod answers it itself |
|
|
502
536
|
| `status` | What's set up for each agent; exits 0 when each hears answers right away (`--app`) |
|
|
503
537
|
| `login` / `logout` | Sign an agent in again, or out (`--app`, `--device`, `--browser`, `--force`, `--name`, `--all`) |
|
|
538
|
+
| `claude [args…]` | Claude Code with the permission channel: a prompt's card has Allow and Deny; `claude`'s own arguments after it |
|
|
539
|
+
| `codex-answers --wait <minutes>` | Codex's permission prompts go to your card first, with Allow and Deny, for up to that many minutes (0 to 10; 0, the default, is off) before Codex asks in its terminal |
|
|
504
540
|
| `watch -- <command>` | Run a command each time an answer is ready (`--once`) |
|
|
505
541
|
| `pickup` / `handoff` | The session-start and next-message hooks (`--app`) |
|
|
506
542
|
| `stopcheck` | The Stop hook: items for you left only in chat (`--app`) |
|
package/dist/apps/claude-code.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { CHANNEL_CLAUDE } from "../channel.js";
|
|
1
2
|
import { addCommand, claudeState, helperServer, installClaude, mcpFromClaude, mcpFromFile, PLUGIN_ORIGINS, permissionBlocker, pluginFor, readManifest, removeCommand, sameUrl, uninstallClaude, } from "../claude.js";
|
|
2
3
|
import { connectionMachine, readCredential } from "../credentials.js";
|
|
3
4
|
import { differs, serverForFolder, siteOf } from "../environment.js";
|
|
4
5
|
import { originArgs } from "../hooks.js";
|
|
5
6
|
import { modConfigured, pruneLoadedNotes, unwokenLines, unwokenSessions, } from "../loaded.js";
|
|
6
|
-
import { claudeVersion, isOurModDir, loadsMods, MOD_CLAUDE } from "../mod.js";
|
|
7
|
+
import { atLeast, claudeVersion, isOurModDir, loadsMods, MOD_CLAUDE } from "../mod.js";
|
|
7
8
|
import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, machineOf, usesHelper, } from "../remote.js";
|
|
8
9
|
import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
|
|
9
10
|
import { shimPath } from "../shim.js";
|
|
@@ -223,6 +224,23 @@ function permissionLine(io, claude, manifest, helper, flag) {
|
|
|
223
224
|
? ` ${label} no card when Claude Code waits for your OK: ${blocker}`
|
|
224
225
|
: ` missing ${label} no card when Claude Code waits for your OK; run npx pendingyou@latest init${flag}`;
|
|
225
226
|
}
|
|
227
|
+
function channelLine(claude, flag) {
|
|
228
|
+
if (!claude.permission || claude.channel === 'unknown')
|
|
229
|
+
return [];
|
|
230
|
+
const label = 'Permission answers:';
|
|
231
|
+
if (claude.channel)
|
|
232
|
+
return [
|
|
233
|
+
` ok ${label} start Claude Code with npx pendingyou claude, and a prompt’s card has Allow and Deny`,
|
|
234
|
+
];
|
|
235
|
+
const version = claude.claude ? claudeVersion(claude.claude) : null;
|
|
236
|
+
if (!version || !atLeast(version, CHANNEL_CLAUDE))
|
|
237
|
+
return [
|
|
238
|
+
` ${label} Allow and Deny on a prompt’s card need Claude Code ${CHANNEL_CLAUDE} or later${version ? ` (this is ${version})` : ''}`,
|
|
239
|
+
];
|
|
240
|
+
return [
|
|
241
|
+
` missing ${label} no Allow or Deny on a prompt’s card; run npx pendingyou@latest init${flag}`,
|
|
242
|
+
];
|
|
243
|
+
}
|
|
226
244
|
function presenceLine(claude, connected, mod, flag) {
|
|
227
245
|
const label = 'Presence:';
|
|
228
246
|
if (!connected)
|
|
@@ -338,7 +356,7 @@ async function claudeStatus(io, ctx) {
|
|
|
338
356
|
? [
|
|
339
357
|
` ok Plugin: ${plugin} is enabled${pluginServer ? '' : mcp !== false && mcp !== 'unknown' ? '; Claude Code uses the server added by hand instead of its own' : ''}`,
|
|
340
358
|
]
|
|
341
|
-
: []), ` ${mark(skillOk)} Skill: ${skillText}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(modOk)} Wake mod: ${modText}`, permissionLine(io, claude, manifest, helper, flag), presenceLine(claude, helper && connected, modOk, flag));
|
|
359
|
+
: []), ` ${mark(skillOk)} Skill: ${skillText}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(modOk)} Wake mod: ${modText}`, permissionLine(io, claude, manifest, helper, flag), ...channelLine(claude, flag), presenceLine(claude, helper && connected, modOk, flag));
|
|
342
360
|
const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
|
|
343
361
|
if (setup)
|
|
344
362
|
lines.push(` ${setup === 'finished' ? 'ok ' : ' '} Setup: ${setup}`);
|
package/dist/apps/codex.js
CHANGED
|
@@ -2,9 +2,10 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { realpath, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { removeSkill, saveSkill } from "../claude.js";
|
|
5
|
+
import { codexManifestPath, isAnswerWait, minutes, permissionTimeout } from "../codex-answers.js";
|
|
5
6
|
import { readCredential } from "../credentials.js";
|
|
6
7
|
import { configDir, readJson, readText, writeWhole } from "../files.js";
|
|
7
|
-
import { isOurHook, lineKey, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
|
|
8
|
+
import { findHook, isOurHook, lineKey, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
|
|
8
9
|
import { atLeast } from "../mod.js";
|
|
9
10
|
import { removePresenceFiles } from "../presence.js";
|
|
10
11
|
import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, } from "../remote.js";
|
|
@@ -47,6 +48,7 @@ export const CODEX_PERMISSION_HOOKS = [
|
|
|
47
48
|
{ event: 'PermissionRequest', sub: 'permission' },
|
|
48
49
|
{ event: 'PostToolUse', sub: 'permission-done' },
|
|
49
50
|
];
|
|
51
|
+
export const codexPermissionHooks = (wait) => CODEX_PERMISSION_HOOKS.map((spec) => spec.event === 'PermissionRequest' ? { ...spec, timeout: permissionTimeout(wait) } : spec);
|
|
50
52
|
export const CODEX_PRESENCE_HOOKS = [
|
|
51
53
|
{ event: 'SessionEnd', sub: 'presence', timeout: 3 },
|
|
52
54
|
];
|
|
@@ -82,7 +84,7 @@ async function codexHome(io) {
|
|
|
82
84
|
const configPath = async (io) => join(await codexHome(io), 'config.toml');
|
|
83
85
|
const hooksPath = async (io) => join(await codexHome(io), 'hooks.json');
|
|
84
86
|
export const codexSkillPath = (io) => join(io.home, '.agents', 'skills', 'pendingyou', 'SKILL.md');
|
|
85
|
-
const manifestPath =
|
|
87
|
+
const manifestPath = codexManifestPath;
|
|
86
88
|
export const readCodexManifest = (io) => readJson(manifestPath(io)).catch(() => null);
|
|
87
89
|
export async function codexServer(io) {
|
|
88
90
|
const doc = await readText(await configPath(io)).catch(() => null);
|
|
@@ -264,6 +266,51 @@ function keepTheirTrust(config, file, before) {
|
|
|
264
266
|
}
|
|
265
267
|
return next;
|
|
266
268
|
}
|
|
269
|
+
export function answerWaitWords(wait) {
|
|
270
|
+
return wait > 0
|
|
271
|
+
? `Ask on your phone first: Codex’s permission prompts wait up to ${minutes(wait)} for Allow or Deny on their Pending You card, then Codex asks in its terminal (npx pendingyou codex-answers --wait 0 turns it off).`
|
|
272
|
+
: 'Ask on your phone first is off: Codex asks for your OK in its terminal at once. To have it put each permission prompt on your Pending You card first, with Allow and Deny, and wait up to 2 minutes for you there: npx pendingyou codex-answers --wait 2';
|
|
273
|
+
}
|
|
274
|
+
export async function setAnswerWait(io, options) {
|
|
275
|
+
const { wait } = options;
|
|
276
|
+
const manifest = await readCodexManifest(io);
|
|
277
|
+
if (!manifest) {
|
|
278
|
+
io.err('Codex isn’t set up on this computer yet: run npx pendingyou init --app codex, then this again.\n');
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
281
|
+
const file = await readHooksFile(io);
|
|
282
|
+
if ('invalid' in file) {
|
|
283
|
+
io.err(`${file.invalid}\n`);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
const hooks = file.json.hooks && typeof file.json.hooks === 'object' && !Array.isArray(file.json.hooks)
|
|
287
|
+
? file.json.hooks
|
|
288
|
+
: {};
|
|
289
|
+
const hook = findHook(hooks, 'PermissionRequest', 'permission');
|
|
290
|
+
if (manifest.permissionCards === false || typeof hook?.command !== 'string') {
|
|
291
|
+
io.err('Codex’s permission-prompt hooks aren’t installed here, so nothing can ask you first: run npx pendingyou init --app codex --permission-cards, then this again.\n');
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
const spec = codexPermissionHooks(wait).find((each) => each.event === 'PermissionRequest');
|
|
295
|
+
const merged = mergeHooks(hooks, [spec], { [lineKey(spec)]: hook.command }, file.path);
|
|
296
|
+
if (merged.changed)
|
|
297
|
+
await writeWhole(file.path, `${JSON.stringify({ ...file.json, hooks: merged.hooks }, null, 2)}\n`);
|
|
298
|
+
const next = { ...manifest };
|
|
299
|
+
if (wait > 0)
|
|
300
|
+
next.answerWait = wait;
|
|
301
|
+
else
|
|
302
|
+
delete next.answerWait;
|
|
303
|
+
await writeWhole(manifestPath(io), `${JSON.stringify(next, null, 2)}\n`, { secret: true });
|
|
304
|
+
io.out(wait > 0
|
|
305
|
+
? `Codex’s permission prompts now go to your Pending You card first, with Allow and Deny, and wait up to ${minutes(wait)} for you there; with no answer by then, Codex asks in its terminal as usual. Codex shows nothing while it waits.\n`
|
|
306
|
+
: 'Turned off: Codex asks for your OK in its terminal at once again, and its card only says so.\n');
|
|
307
|
+
if (!merged.changed) {
|
|
308
|
+
io.out('That was already the setting.\n');
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
io.out(`\nGave Codex’s PermissionRequest hook ${spec.timeout} seconds in ${file.path}: a changed hook, which Codex skips until you trust it again.\n${trustSteps(await findCodex(io)).join('\n')}\n`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
267
314
|
export const codex = {
|
|
268
315
|
id: 'codex',
|
|
269
316
|
name: NAME,
|
|
@@ -418,12 +465,14 @@ export const codex = {
|
|
|
418
465
|
report(skill.step);
|
|
419
466
|
const cards = ctx.permissionCards ?? before?.permissionCards !== false;
|
|
420
467
|
const extra = mode === 'helper' && ictx.helper !== null;
|
|
468
|
+
const wait = isAnswerWait(before?.answerWait) ? before.answerWait : 0;
|
|
421
469
|
const wanted = [
|
|
422
470
|
...CODEX_HOOKS,
|
|
423
|
-
...(extra && cards ?
|
|
471
|
+
...(extra && cards ? codexPermissionHooks(wait) : []),
|
|
424
472
|
...(extra ? CODEX_PRESENCE_HOOKS : []),
|
|
425
473
|
];
|
|
426
|
-
const
|
|
474
|
+
const wantedKeys = new Set(wanted.map(lineKey));
|
|
475
|
+
const unwanted = ALL_CODEX_HOOKS.filter((spec) => !wantedKeys.has(lineKey(spec)));
|
|
427
476
|
const lines = Object.fromEntries(wanted.map((spec) => [lineKey(spec), ictx.hookLine(spec.sub)]));
|
|
428
477
|
const file = await readHooksFile(io);
|
|
429
478
|
if ('invalid' in file)
|
|
@@ -453,6 +502,8 @@ export const codex = {
|
|
|
453
502
|
? `Took out Codex’s permission-prompt hooks, as you asked: no card when Codex waits for your OK. To turn them on: npx pendingyou init --app codex --permission-cards${originArgs(ictx.origin)}`
|
|
454
503
|
: 'Codex’s hooks were already installed.',
|
|
455
504
|
});
|
|
505
|
+
if (extra && cards)
|
|
506
|
+
report({ ok: true, text: answerWaitWords(wait) });
|
|
456
507
|
}
|
|
457
508
|
catch (error) {
|
|
458
509
|
report({
|
|
@@ -478,6 +529,7 @@ export const codex = {
|
|
|
478
529
|
helper: mode === 'helper' ? ictx.helper : null,
|
|
479
530
|
setup: { since: new Date(io.now()).toISOString() },
|
|
480
531
|
permissionCards: cards,
|
|
532
|
+
...(wait > 0 ? { answerWait: wait } : {}),
|
|
481
533
|
};
|
|
482
534
|
await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
483
535
|
secret: true,
|
|
@@ -630,6 +682,12 @@ export const codex = {
|
|
|
630
682
|
: manifest?.permissionCards === false
|
|
631
683
|
? `off, as you chose; for a card when Codex waits for your OK, run npx pendingyou init --app codex --permission-cards${flag}`
|
|
632
684
|
: null);
|
|
685
|
+
const wait = isAnswerWait(manifest?.answerWait) ? manifest.answerWait : 0;
|
|
686
|
+
const askFirst = ownConnection && manifest?.permissionCards !== false
|
|
687
|
+
? ` Ask on your phone first: ${wait > 0
|
|
688
|
+
? `${minutes(wait)}: a permission prompt waits that long for Allow or Deny on its card, then Codex asks in its terminal (npx pendingyou codex-answers --wait 0 turns it off)`
|
|
689
|
+
: 'off: Codex asks in its terminal at once (npx pendingyou codex-answers --wait 2 sends its prompts to your card first, for up to 2 minutes)'}`
|
|
690
|
+
: null;
|
|
633
691
|
const presenceText = extraLine('Presence:', 'tells Pending You when this session is open', CODEX_PRESENCE_HOOKS.map((spec) => spec.sub), ownConnection ? null : notOwn);
|
|
634
692
|
const wakes = detected
|
|
635
693
|
? atLeast(detected.version, detected.command ? CODEX_APP_WAKE : CODEX_WAKE)
|
|
@@ -649,6 +707,7 @@ export const codex = {
|
|
|
649
707
|
` ${mark(skill !== null)} Skill: ${skill !== null ? `saved (${codexSkillPath(io)})` : `not saved; run npx pendingyou init --app codex${flag}`}`,
|
|
650
708
|
` ${mark(hooksOk)} Hooks: ${hooksText}`,
|
|
651
709
|
permissionText,
|
|
710
|
+
...(askFirst ? [askFirst] : []),
|
|
652
711
|
presenceText,
|
|
653
712
|
` ${mark(wakes)} Wake: ${wakes
|
|
654
713
|
? detected?.command
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { APP_IDS, APP_NAMES, DEFAULT_APP, isAppId } from "./apps/ids.js";
|
|
2
|
+
import { ANSWER_WAIT_MAX } from "./codex-answers.js";
|
|
2
3
|
export const DEFAULT_ORIGIN = 'https://www.pendingyou.com';
|
|
3
4
|
export const DEFAULT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
4
5
|
const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
@@ -84,6 +85,9 @@ const COMMANDS = new Set([
|
|
|
84
85
|
'herdr',
|
|
85
86
|
'machine',
|
|
86
87
|
'app',
|
|
88
|
+
'channel',
|
|
89
|
+
'claude',
|
|
90
|
+
'codex-answers',
|
|
87
91
|
]);
|
|
88
92
|
const FLAGS = {
|
|
89
93
|
help: {},
|
|
@@ -139,6 +143,9 @@ const FLAGS = {
|
|
|
139
143
|
refresh: { origin: true, force: false, app: true },
|
|
140
144
|
'mcp-headers': { origin: true, check: false, app: true },
|
|
141
145
|
mcp: { origin: true, app: true, check: false },
|
|
146
|
+
channel: { origin: true, app: true },
|
|
147
|
+
claude: {},
|
|
148
|
+
'codex-answers': { origin: true, wait: true },
|
|
142
149
|
herdr: {
|
|
143
150
|
origin: true,
|
|
144
151
|
app: true,
|
|
@@ -233,6 +240,8 @@ export function parseArgs(argv, env = {}) {
|
|
|
233
240
|
return { name: 'version' };
|
|
234
241
|
if (!COMMANDS.has(first))
|
|
235
242
|
throw new UsageError(`There’s no “${first}” command.`);
|
|
243
|
+
if (first === 'claude')
|
|
244
|
+
return { name: 'claude', args: [...after] };
|
|
236
245
|
const rest = [...leading, ...after];
|
|
237
246
|
const allowed = FLAGS[first] ?? {};
|
|
238
247
|
const flags = new Map();
|
|
@@ -425,6 +434,9 @@ export function parseArgs(argv, env = {}) {
|
|
|
425
434
|
case 'mcp':
|
|
426
435
|
none();
|
|
427
436
|
return { name: 'mcp', origin, ...app(), check: flags.has('check') };
|
|
437
|
+
case 'channel':
|
|
438
|
+
none();
|
|
439
|
+
return { name: 'channel', origin, ...app() };
|
|
428
440
|
case 'herdr':
|
|
429
441
|
return herdrCommand(origin, positional, flags, app);
|
|
430
442
|
case 'machine':
|
|
@@ -445,6 +457,13 @@ export function parseArgs(argv, env = {}) {
|
|
|
445
457
|
...apps(),
|
|
446
458
|
...permissionCards(),
|
|
447
459
|
};
|
|
460
|
+
case 'codex-answers': {
|
|
461
|
+
none();
|
|
462
|
+
const given = text('wait');
|
|
463
|
+
if (given === undefined || !/^\d{1,2}$/.test(given) || Number(given) > ANSWER_WAIT_MAX)
|
|
464
|
+
throw new UsageError(`codex-answers needs --wait and a whole number of minutes, 0 (off) to ${ANSWER_WAIT_MAX}, like: pendingyou codex-answers --wait 2`);
|
|
465
|
+
return { name: 'codex-answers', wait: Number(given) };
|
|
466
|
+
}
|
|
448
467
|
case 'watch':
|
|
449
468
|
none();
|
|
450
469
|
if (!command?.length || !command[0])
|
|
@@ -662,6 +681,14 @@ export const USAGE = `pendingyou: Pending You for your coding agents (Claude Cod
|
|
|
662
681
|
npx pendingyou status What's set up for each agent, and whether it hears answers right away
|
|
663
682
|
npx pendingyou login [--app <app>] | logout
|
|
664
683
|
Sign an agent in again (Claude Code's when none is named), or out
|
|
684
|
+
npx pendingyou claude [args…]
|
|
685
|
+
Start Claude Code so its permission prompts can be answered on their
|
|
686
|
+
Pending You cards: Allow or Deny there, or in the terminal; the first
|
|
687
|
+
answer wins. Claude Code warns about the development channel each start
|
|
688
|
+
npx pendingyou codex-answers --wait <minutes>
|
|
689
|
+
Codex asks on your phone first: its permission prompts go to your
|
|
690
|
+
Pending You card, with Allow and Deny, and wait up to that many minutes
|
|
691
|
+
(0 to ${ANSWER_WAIT_MAX}; 0, the default, is off) before Codex asks in its terminal
|
|
665
692
|
npx pendingyou watch -- <command> [args…]
|
|
666
693
|
Run a command each time an answer is ready (always-on scripts)
|
|
667
694
|
npx pendingyou uninstall Remove exactly what init added
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { DEFAULT_APP } from "./apps/ids.js";
|
|
2
|
+
import { McpFailure, mcpClient, SIGN_IN_ERROR } from "./bridge.js";
|
|
3
|
+
import { ALLOW, answeredOnCard, computerOf, DENY, promptWords, RELAY_MATCH_MS, recordRelay, relayedPrompt, } from "./permission.js";
|
|
4
|
+
import { cut, maskSecrets, redactText } from "./redact.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
export const CHANNEL = 'pendingyou-permissions';
|
|
7
|
+
export const CHANNEL_CLAUDE = '2.1.234';
|
|
8
|
+
export const READ_MS = 2000;
|
|
9
|
+
export const READ_FOR_MS = 30 * 60_000;
|
|
10
|
+
export const SLOW_READ_MS = 30_000;
|
|
11
|
+
const MATCH_STEP_MS = 500;
|
|
12
|
+
const CALL_MS = 15_000;
|
|
13
|
+
const INPUT_MAX = 40_000;
|
|
14
|
+
const LINES_MAX = 400;
|
|
15
|
+
const REQUEST_ID = /^[a-km-z]{5}$/;
|
|
16
|
+
const UNSERIALIZABLE = '(value unserializable)';
|
|
17
|
+
const NO_DESCRIPTION = 'Run shell command';
|
|
18
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
|
+
const INSTRUCTIONS = 'Pending You’s permission channel: it puts Claude Code’s permission prompts on the person’s Pending You card, with Allow and Deny. It sends you no messages and has no tools; ignore it.';
|
|
20
|
+
export function inputLines(tool, preview) {
|
|
21
|
+
let parsed = null;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(preview);
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
const input = isObject(parsed) ? parsed : {};
|
|
27
|
+
let lines;
|
|
28
|
+
let lang = 'text';
|
|
29
|
+
if (isObject(parsed) &&
|
|
30
|
+
(tool === 'Bash' || tool === 'PowerShell') &&
|
|
31
|
+
typeof parsed.command === 'string') {
|
|
32
|
+
lines = maskSecrets(parsed.command).split('\n');
|
|
33
|
+
lang = 'shell';
|
|
34
|
+
}
|
|
35
|
+
else if (isObject(parsed))
|
|
36
|
+
lines = Object.entries(parsed).flatMap(([key, value]) => maskSecrets(`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`).split('\n'));
|
|
37
|
+
else
|
|
38
|
+
lines = maskSecrets(preview).split('\n');
|
|
39
|
+
const kept = [];
|
|
40
|
+
let size = 0;
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
if (kept.length >= LINES_MAX || size + line.length > INPUT_MAX) {
|
|
43
|
+
kept.push('… (cut here: the rest is in Claude Code’s dialog)');
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
kept.push(line);
|
|
47
|
+
size += line.length;
|
|
48
|
+
}
|
|
49
|
+
return { lines: kept.length ? kept : [''], lang, input };
|
|
50
|
+
}
|
|
51
|
+
export function heardOf(io, params) {
|
|
52
|
+
if (!isObject(params))
|
|
53
|
+
return null;
|
|
54
|
+
const { request_id: id, tool_name: tool, description, input_preview: preview } = params;
|
|
55
|
+
if (typeof id !== 'string' || !REQUEST_ID.test(id))
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof tool !== 'string' || !tool || tool.length > 200)
|
|
58
|
+
return null;
|
|
59
|
+
const text = typeof preview === 'string' ? preview : '';
|
|
60
|
+
const said = typeof description === 'string' ? description.trim() : '';
|
|
61
|
+
const shown = inputLines(tool, text);
|
|
62
|
+
return {
|
|
63
|
+
tool,
|
|
64
|
+
words: promptWords(tool, shown.input, io.home),
|
|
65
|
+
relay: {
|
|
66
|
+
id,
|
|
67
|
+
at: io.now(),
|
|
68
|
+
description: said && said !== NO_DESCRIPTION ? redactText(said, 160) : '',
|
|
69
|
+
lines: shown.lines,
|
|
70
|
+
lang: shown.lang,
|
|
71
|
+
...(text.includes(UNSERIALIZABLE) ? { unserializable: true } : {}),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function verdictOf(card, relay) {
|
|
76
|
+
if (!['answered', 'resolved'].includes(String(card.status)))
|
|
77
|
+
return null;
|
|
78
|
+
const answer = isObject(card.answer) ? card.answer : null;
|
|
79
|
+
const picked = Array.isArray(answer?.choiceIds) ? answer.choiceIds : [];
|
|
80
|
+
if (picked.length !== 1)
|
|
81
|
+
return null;
|
|
82
|
+
if (picked[0] === DENY)
|
|
83
|
+
return 'deny';
|
|
84
|
+
if (picked[0] === ALLOW && !relay.unserializable)
|
|
85
|
+
return 'allow';
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export async function channel(io, options) {
|
|
89
|
+
const app = options.app ?? DEFAULT_APP;
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const stop = () => controller.abort();
|
|
92
|
+
if (io.signal.aborted)
|
|
93
|
+
stop();
|
|
94
|
+
io.signal.addEventListener('abort', stop, { once: true });
|
|
95
|
+
const write = (message) => io.out(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
|
|
96
|
+
const session = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(io.env.CLAUDE_CODE_SESSION_ID ?? '')
|
|
97
|
+
? io.env.CLAUDE_CODE_SESSION_ID
|
|
98
|
+
: null;
|
|
99
|
+
const cwd = io.env.CLAUDE_PROJECT_DIR || io.cwd;
|
|
100
|
+
const following = new Set();
|
|
101
|
+
const lines = io.lines()[Symbol.asyncIterator]();
|
|
102
|
+
const stopped = new Promise((resolve) => {
|
|
103
|
+
const done = () => resolve({ done: true, value: undefined });
|
|
104
|
+
if (controller.signal.aborted)
|
|
105
|
+
done();
|
|
106
|
+
else
|
|
107
|
+
controller.signal.addEventListener('abort', done, { once: true });
|
|
108
|
+
});
|
|
109
|
+
let client = null;
|
|
110
|
+
const pending = {
|
|
111
|
+
async call(name, args) {
|
|
112
|
+
try {
|
|
113
|
+
client ??= await mcpClient(io, options.origin, 'claude-code', CALL_MS);
|
|
114
|
+
return await client.call(name, args);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof McpFailure)
|
|
118
|
+
client = null;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
for (;;) {
|
|
125
|
+
const next = await Promise.race([lines.next(), stopped]);
|
|
126
|
+
if (next.done)
|
|
127
|
+
break;
|
|
128
|
+
let message;
|
|
129
|
+
try {
|
|
130
|
+
message = JSON.parse(next.value);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!isObject(message) || typeof message.method !== 'string')
|
|
136
|
+
continue;
|
|
137
|
+
const id = message.id;
|
|
138
|
+
const asked = typeof id === 'string' || typeof id === 'number';
|
|
139
|
+
switch (message.method) {
|
|
140
|
+
case 'initialize': {
|
|
141
|
+
const params = isObject(message.params) ? message.params : {};
|
|
142
|
+
if (asked)
|
|
143
|
+
write({
|
|
144
|
+
id,
|
|
145
|
+
result: {
|
|
146
|
+
protocolVersion: typeof params.protocolVersion === 'string'
|
|
147
|
+
? params.protocolVersion
|
|
148
|
+
: '2025-06-18',
|
|
149
|
+
capabilities: {
|
|
150
|
+
experimental: { 'claude/channel': {}, 'claude/channel/permission': {} },
|
|
151
|
+
},
|
|
152
|
+
serverInfo: { name: CHANNEL, version: VERSION },
|
|
153
|
+
instructions: INSTRUCTIONS,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case 'ping':
|
|
159
|
+
if (asked)
|
|
160
|
+
write({ id, result: {} });
|
|
161
|
+
break;
|
|
162
|
+
case 'tools/list':
|
|
163
|
+
if (asked)
|
|
164
|
+
write({ id, result: { tools: [] } });
|
|
165
|
+
break;
|
|
166
|
+
case 'notifications/claude/channel/permission_request': {
|
|
167
|
+
if (app !== 'claude-code')
|
|
168
|
+
break;
|
|
169
|
+
const heard = heardOf(io, message.params);
|
|
170
|
+
if (!heard)
|
|
171
|
+
break;
|
|
172
|
+
const task = follow(io, options.origin, heard, {
|
|
173
|
+
session,
|
|
174
|
+
cwd,
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
pending,
|
|
177
|
+
verdict: (behavior) => write({
|
|
178
|
+
method: 'notifications/claude/channel/permission',
|
|
179
|
+
params: { request_id: heard.relay.id, behavior },
|
|
180
|
+
}),
|
|
181
|
+
}).catch(() => { });
|
|
182
|
+
following.add(task);
|
|
183
|
+
task.then(() => following.delete(task));
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
default:
|
|
187
|
+
if (asked)
|
|
188
|
+
write({ id, error: { code: -32601, message: 'Method not found' } });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
controller.abort();
|
|
194
|
+
io.signal.removeEventListener('abort', stop);
|
|
195
|
+
lines.return?.().catch(() => undefined);
|
|
196
|
+
await Promise.allSettled([...following]);
|
|
197
|
+
await client?.close().catch(() => { });
|
|
198
|
+
}
|
|
199
|
+
await io.flush();
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
async function follow(io, origin, heard, options) {
|
|
203
|
+
const { signal } = options;
|
|
204
|
+
const id = heard.relay.id;
|
|
205
|
+
let where = null;
|
|
206
|
+
const waitFrom = io.now();
|
|
207
|
+
for (;;) {
|
|
208
|
+
const last = io.now() - waitFrom >= RELAY_MATCH_MS;
|
|
209
|
+
where = await recordRelay(io, origin, heard, {
|
|
210
|
+
session: options.session,
|
|
211
|
+
cwd: options.cwd,
|
|
212
|
+
last,
|
|
213
|
+
});
|
|
214
|
+
if (where || last || signal.aborted)
|
|
215
|
+
break;
|
|
216
|
+
await io.sleep(MATCH_STEP_MS, signal);
|
|
217
|
+
}
|
|
218
|
+
if (!where)
|
|
219
|
+
return;
|
|
220
|
+
const started = io.now();
|
|
221
|
+
while (!signal.aborted) {
|
|
222
|
+
await io.sleep(io.now() - started < READ_FOR_MS ? READ_MS : SLOW_READ_MS, signal);
|
|
223
|
+
if (signal.aborted)
|
|
224
|
+
return;
|
|
225
|
+
const now = await relayedPrompt(io, where, id);
|
|
226
|
+
if (!now)
|
|
227
|
+
return;
|
|
228
|
+
const card = now.prompt.card;
|
|
229
|
+
if (!card)
|
|
230
|
+
continue;
|
|
231
|
+
let read;
|
|
232
|
+
try {
|
|
233
|
+
read = await options.pending.call('get_request', {
|
|
234
|
+
requestId: card.requestId,
|
|
235
|
+
name: card.name,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error instanceof McpFailure && error.code === SIGN_IN_ERROR)
|
|
240
|
+
return;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const status = String(read.status);
|
|
244
|
+
if (['pending', 'snoozed', 'delegated'].includes(status))
|
|
245
|
+
continue;
|
|
246
|
+
const verdict = verdictOf(read, heard.relay);
|
|
247
|
+
if (!verdict) {
|
|
248
|
+
if (status === 'answered' && typeof read.version === 'number') {
|
|
249
|
+
const machine = await computerOf(io, origin, 'claude-code');
|
|
250
|
+
await options.pending
|
|
251
|
+
.call('ack_answer', {
|
|
252
|
+
requestId: card.requestId,
|
|
253
|
+
name: card.name,
|
|
254
|
+
expectedVersion: read.version,
|
|
255
|
+
outcome: cut(`Not allowed or denied: it waits for your answer in Claude Code on ${machine}.`, 200),
|
|
256
|
+
})
|
|
257
|
+
.catch(() => { });
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
options.verdict(verdict);
|
|
262
|
+
await answeredOnCard(io, where, id);
|
|
263
|
+
if (status === 'answered' && typeof read.version === 'number') {
|
|
264
|
+
const machine = await computerOf(io, origin, 'claude-code');
|
|
265
|
+
await options.pending
|
|
266
|
+
.call('ack_answer', {
|
|
267
|
+
requestId: card.requestId,
|
|
268
|
+
name: card.name,
|
|
269
|
+
expectedVersion: read.version,
|
|
270
|
+
outcome: cut(`${verdict === 'allow' ? 'Allowed' : 'Denied'} in Claude Code on ${machine}.`, 200),
|
|
271
|
+
})
|
|
272
|
+
.catch(() => { });
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|