pendingyou 0.33.0 → 0.34.1
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 +21 -0
- package/README.md +20 -0
- package/dist/apps/codex.js +63 -4
- package/dist/args.js +14 -0
- package/dist/codex-answers.js +15 -0
- package/dist/main.js +12 -1
- package/dist/permission.js +254 -11
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.1 (2026-10-09)
|
|
4
|
+
|
|
5
|
+
- Fixed: a Claude Code permission prompt answered through the permission channel (`npx pendingyou claude`) could get
|
|
6
|
+
two cards, the relayed one with **Allow** and **Deny** and the old button-less “Claude Code is waiting for your OK”
|
|
7
|
+
one. It happened to any tool the session had used before (a second Write): Claude Code writes a prompted call to its
|
|
8
|
+
transcript only once it's answered, so the Notification hook, six seconds into the dialog, found the earlier call
|
|
9
|
+
answered, took the dialog for one nothing explained and put up a card for it. A relayed prompt now counts as on
|
|
10
|
+
screen, a Notification adds nothing while one is open, and a button-less card already up for a relayed dialog is
|
|
11
|
+
withdrawn as the relayed card goes up at once (“It has a card of its own now, with Allow and Deny.”).
|
|
12
|
+
|
|
13
|
+
## 0.34.0 (2026-10-09)
|
|
14
|
+
|
|
15
|
+
- Codex can ask on your phone first. `npx pendingyou codex-answers --wait <minutes>` (0 to 10; 0, the default, is off)
|
|
16
|
+
has Codex's permission prompts go to your Pending You card at once, with **Allow** and **Deny** and the whole command
|
|
17
|
+
or patch, its secrets masked. Codex shows nothing while its hook waits, so tap one and Codex goes on, or is refused
|
|
18
|
+
(“Denied in Pending You.”); with no answer within the wait, the card is withdrawn and Codex asks in its terminal as
|
|
19
|
+
usual. It sets the PermissionRequest hook's timeout in Codex's hooks.json to the wait plus 30 seconds (Codex sets no
|
|
20
|
+
upper limit for that hook), which changes the hook: trust it again in Codex's `/hooks`, as the command says. `init`
|
|
21
|
+
names the setting when it sets Codex up and keeps it, `status` shows it, and `uninstall` clears it. Needs Pending You
|
|
22
|
+
with `permissionPrompt` (0.33.0's server).
|
|
23
|
+
|
|
3
24
|
## 0.33.0 (2026-10-09)
|
|
4
25
|
|
|
5
26
|
- Answer Claude Code's permission prompts on their Pending You card. Start Claude Code with `npx pendingyou claude
|
package/README.md
CHANGED
|
@@ -289,6 +289,25 @@ each start (`pendingyou claude` runs `claude --dangerously-load-development-chan
|
|
|
289
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
290
|
without buttons, as above.
|
|
291
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
|
+
|
|
292
311
|
## Which sessions are open
|
|
293
312
|
|
|
294
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
|
|
@@ -517,6 +536,7 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
|
|
|
517
536
|
| `status` | What's set up for each agent; exits 0 when each hears answers right away (`--app`) |
|
|
518
537
|
| `login` / `logout` | Sign an agent in again, or out (`--app`, `--device`, `--browser`, `--force`, `--name`, `--all`) |
|
|
519
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 |
|
|
520
540
|
| `watch -- <command>` | Run a command each time an answer is ready (`--once`) |
|
|
521
541
|
| `pickup` / `handoff` | The session-start and next-message hooks (`--app`) |
|
|
522
542
|
| `stopcheck` | The Stop hook: items for you left only in chat (`--app`) |
|
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;
|
|
@@ -86,6 +87,7 @@ const COMMANDS = new Set([
|
|
|
86
87
|
'app',
|
|
87
88
|
'channel',
|
|
88
89
|
'claude',
|
|
90
|
+
'codex-answers',
|
|
89
91
|
]);
|
|
90
92
|
const FLAGS = {
|
|
91
93
|
help: {},
|
|
@@ -143,6 +145,7 @@ const FLAGS = {
|
|
|
143
145
|
mcp: { origin: true, app: true, check: false },
|
|
144
146
|
channel: { origin: true, app: true },
|
|
145
147
|
claude: {},
|
|
148
|
+
'codex-answers': { origin: true, wait: true },
|
|
146
149
|
herdr: {
|
|
147
150
|
origin: true,
|
|
148
151
|
app: true,
|
|
@@ -454,6 +457,13 @@ export function parseArgs(argv, env = {}) {
|
|
|
454
457
|
...apps(),
|
|
455
458
|
...permissionCards(),
|
|
456
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
|
+
}
|
|
457
467
|
case 'watch':
|
|
458
468
|
none();
|
|
459
469
|
if (!command?.length || !command[0])
|
|
@@ -675,6 +685,10 @@ export const USAGE = `pendingyou: Pending You for your coding agents (Claude Cod
|
|
|
675
685
|
Start Claude Code so its permission prompts can be answered on their
|
|
676
686
|
Pending You cards: Allow or Deny there, or in the terminal; the first
|
|
677
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
|
|
678
692
|
npx pendingyou watch -- <command> [args…]
|
|
679
693
|
Run a command each time an answer is ready (always-on scripts)
|
|
680
694
|
npx pendingyou uninstall Remove exactly what init added
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { configDir, readJson } from "./files.js";
|
|
3
|
+
import { HOOK_TIMEOUT } from "./hooks.js";
|
|
4
|
+
export const ANSWER_WAIT_MAX = 10;
|
|
5
|
+
export const ANSWER_MARGIN_S = 30;
|
|
6
|
+
export const ANSWER_DEADLINE_MARGIN_MS = 20_000;
|
|
7
|
+
export const codexManifestPath = (io) => join(configDir(io), 'codex.json');
|
|
8
|
+
export const isAnswerWait = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= ANSWER_WAIT_MAX;
|
|
9
|
+
export async function readAnswerWait(io) {
|
|
10
|
+
const manifest = await readJson(codexManifestPath(io)).catch(() => null);
|
|
11
|
+
const wait = manifest?.answerWait;
|
|
12
|
+
return isAnswerWait(wait) ? wait : 0;
|
|
13
|
+
}
|
|
14
|
+
export const permissionTimeout = (wait) => wait > 0 ? wait * 60 + ANSWER_MARGIN_S : HOOK_TIMEOUT;
|
|
15
|
+
export const minutes = (wait) => `${wait} minute${wait === 1 ? '' : 's'}`;
|
package/dist/main.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HOOK_DEADLINE_MS, refreshInBackground, SignInNeeded, Unavailable } from "./api.js";
|
|
2
|
+
import { setAnswerWait } from "./apps/codex.js";
|
|
2
3
|
import { listen, listenHanded, posted } from "./apps/codex-wake.js";
|
|
3
4
|
import { DEFAULT_APP } from "./apps/ids.js";
|
|
4
5
|
import { appModule } from "./apps/registry.js";
|
|
@@ -6,6 +7,7 @@ import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.j
|
|
|
6
7
|
import { bridge } from "./bridge.js";
|
|
7
8
|
import { channel } from "./channel.js";
|
|
8
9
|
import { claudeWithChannel } from "./claude.js";
|
|
10
|
+
import { ANSWER_DEADLINE_MARGIN_MS, readAnswerWait } from "./codex-answers.js";
|
|
9
11
|
import { PlainError } from "./errors.js";
|
|
10
12
|
import { hold } from "./hold.js";
|
|
11
13
|
import { init, status, uninstall } from "./init.js";
|
|
@@ -85,6 +87,8 @@ async function run(io, command, hook = { fallback: '' }) {
|
|
|
85
87
|
return uninstall(io, command);
|
|
86
88
|
case 'watch':
|
|
87
89
|
return watch(io, command);
|
|
90
|
+
case 'codex-answers':
|
|
91
|
+
return setAnswerWait(io, command);
|
|
88
92
|
case 'refresh':
|
|
89
93
|
try {
|
|
90
94
|
await refreshInBackground(io, command.origin, command.force, command.app ?? DEFAULT_APP);
|
|
@@ -108,6 +112,12 @@ async function run(io, command, hook = { fallback: '' }) {
|
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
export { HOOK_DEADLINE_MS };
|
|
115
|
+
async function deadlineOf(io, command) {
|
|
116
|
+
if (command.name !== 'permission' || command.app !== 'codex')
|
|
117
|
+
return HOOK_DEADLINE_MS;
|
|
118
|
+
const wait = await readAnswerWait(io).catch(() => 0);
|
|
119
|
+
return wait > 0 ? wait * 60_000 + ANSWER_DEADLINE_MARGIN_MS : HOOK_DEADLINE_MS;
|
|
120
|
+
}
|
|
111
121
|
async function runHook(io, command) {
|
|
112
122
|
const hook = { fallback: '' };
|
|
113
123
|
const controller = new AbortController();
|
|
@@ -127,9 +137,10 @@ async function runHook(io, command) {
|
|
|
127
137
|
},
|
|
128
138
|
signal: controller.signal,
|
|
129
139
|
};
|
|
140
|
+
const limit = await deadlineOf(io, command);
|
|
130
141
|
let timer;
|
|
131
142
|
const deadline = new Promise((resolve) => {
|
|
132
|
-
timer = setTimeout(() => resolve('deadline'), Math.max(0,
|
|
143
|
+
timer = setTimeout(() => resolve('deadline'), Math.max(0, limit - io.uptime()));
|
|
133
144
|
});
|
|
134
145
|
try {
|
|
135
146
|
const outcome = await Promise.race([run(hookIo, command, hook), deadline]);
|
package/dist/permission.js
CHANGED
|
@@ -5,10 +5,11 @@ import { threadNames } from "./apps/codex-wake.js";
|
|
|
5
5
|
import { APP_NAMES } from "./apps/ids.js";
|
|
6
6
|
import { DEFAULT_ORIGIN } from "./args.js";
|
|
7
7
|
import { McpFailure, mcpClient, SIGN_IN_ERROR, ToolRefused } from "./bridge.js";
|
|
8
|
+
import { minutes, readAnswerWait } from "./codex-answers.js";
|
|
8
9
|
import { sessionLink } from "./confirm.js";
|
|
9
10
|
import { connectionMachine, readCredential } from "./credentials.js";
|
|
10
11
|
import { claudeDir, configDir, PERMISSION_FOLDER, readJson, withLock, writeWhole } from "./files.js";
|
|
11
|
-
import { BLANK, cut, redactCommand, redactText, redactUrl, shortPath } from "./redact.js";
|
|
12
|
+
import { BLANK, cut, maskSecrets, redactCommand, redactText, redactUrl, shortPath, } from "./redact.js";
|
|
12
13
|
import { machineOf } from "./remote.js";
|
|
13
14
|
export const GRACE_MS = 10_000;
|
|
14
15
|
export const TITLE_MAX = 90;
|
|
@@ -231,6 +232,18 @@ function keepNewest(state) {
|
|
|
231
232
|
closeCards(state, state.prompts.slice(0, -MAX_PROMPTS), 'answered');
|
|
232
233
|
state.prompts = state.prompts.slice(-MAX_PROMPTS);
|
|
233
234
|
}
|
|
235
|
+
function markMoving(state) {
|
|
236
|
+
const card = state.card;
|
|
237
|
+
if (!card)
|
|
238
|
+
return;
|
|
239
|
+
const shown = state.prompts.find((prompt) => prompt.key === card.key && !prompt.notice);
|
|
240
|
+
if (shown ? shown.relay : state.prompts.some((prompt) => prompt.relay))
|
|
241
|
+
card.moving = true;
|
|
242
|
+
}
|
|
243
|
+
function relayed(state) {
|
|
244
|
+
state.prompts = state.prompts.filter((prompt) => !prompt.notice);
|
|
245
|
+
markMoving(state);
|
|
246
|
+
}
|
|
234
247
|
const ADOPT_MS = 30_000;
|
|
235
248
|
function adopt(state, prompt) {
|
|
236
249
|
const own = state.prompts.find((each) => each.relay?.own &&
|
|
@@ -406,7 +419,7 @@ async function notified(io, origin, input) {
|
|
|
406
419
|
for (const prompt of (known?.prompts ?? []).slice().reverse()) {
|
|
407
420
|
if (prompt.shown !== undefined || prompt.notice || prompt.at > before)
|
|
408
421
|
continue;
|
|
409
|
-
if (await stillAsked(known, prompt)) {
|
|
422
|
+
if (prompt.relay || (await stillAsked(known, prompt))) {
|
|
410
423
|
picked = promptId(prompt);
|
|
411
424
|
break;
|
|
412
425
|
}
|
|
@@ -435,7 +448,7 @@ async function notified(io, origin, input) {
|
|
|
435
448
|
shows.shown = now;
|
|
436
449
|
delete shows.failed;
|
|
437
450
|
}
|
|
438
|
-
else if (next.prompts.some((prompt) => prompt.shown !== undefined)) {
|
|
451
|
+
else if (next.prompts.some((prompt) => prompt.shown !== undefined || prompt.relay)) {
|
|
439
452
|
return state;
|
|
440
453
|
}
|
|
441
454
|
else
|
|
@@ -456,6 +469,11 @@ export async function permissionHook(io, options) {
|
|
|
456
469
|
return 0;
|
|
457
470
|
switch (input.hook_event_name) {
|
|
458
471
|
case 'PermissionRequest':
|
|
472
|
+
if (app === 'codex') {
|
|
473
|
+
const wait = await readAnswerWait(io);
|
|
474
|
+
if (wait > 0 && (await askFirst(io, options.origin, input, wait)))
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
459
477
|
await asked(io, options.origin, input, app);
|
|
460
478
|
break;
|
|
461
479
|
case 'PostToolUse':
|
|
@@ -500,7 +518,9 @@ function relayPlan(state, now, grace, then) {
|
|
|
500
518
|
const waiting = state.prompts.filter((prompt) => prompt.relay && !prompt.card && !prompt.failed);
|
|
501
519
|
if (waiting.length === 0)
|
|
502
520
|
return then;
|
|
503
|
-
const dueAt = (prompt) =>
|
|
521
|
+
const dueAt = (prompt) => state.card?.moving
|
|
522
|
+
? now
|
|
523
|
+
: Math.min(Math.min(prompt.at, prompt.relay.at) + grace, prompt.shown ?? Number.POSITIVE_INFINITY);
|
|
504
524
|
const first = waiting.reduce((soonest, prompt) => dueAt(prompt) < dueAt(soonest) ? prompt : soonest);
|
|
505
525
|
const due = dueAt(first) - now;
|
|
506
526
|
if (due <= 0)
|
|
@@ -514,7 +534,9 @@ function relayPlan(state, now, grace, then) {
|
|
|
514
534
|
function cardPlan(state, now, grace) {
|
|
515
535
|
const prompts = state.prompts.filter((prompt) => seen(prompt) && !prompt.relay);
|
|
516
536
|
if (prompts.length === 0)
|
|
517
|
-
return state.card
|
|
537
|
+
return state.card
|
|
538
|
+
? { kind: 'cancel', ...(state.card.moving ? { moved: true } : {}) }
|
|
539
|
+
: { kind: 'done' };
|
|
518
540
|
if (state.card) {
|
|
519
541
|
const newest = shownOf(prompts);
|
|
520
542
|
if (state.card.key === newest.key)
|
|
@@ -934,6 +956,7 @@ async function post(io, client, state, prompt, worker) {
|
|
|
934
956
|
const kept = await record(io, state.session, worker, (state) => {
|
|
935
957
|
state.card = card;
|
|
936
958
|
state.areaId = areaId;
|
|
959
|
+
markMoving(state);
|
|
937
960
|
});
|
|
938
961
|
if (!kept)
|
|
939
962
|
await withdraw(io, client, card, state, 'answered').catch(() => { });
|
|
@@ -1058,19 +1081,24 @@ async function update(io, client, state, prompt, worker) {
|
|
|
1058
1081
|
}
|
|
1059
1082
|
const version = typeof result.version === 'number' ? result.version : card.version + 1;
|
|
1060
1083
|
await record(io, state.session, worker, (state) => {
|
|
1061
|
-
if (state.card?.requestId === card.requestId)
|
|
1062
|
-
|
|
1084
|
+
if (state.card?.requestId === card.requestId) {
|
|
1085
|
+
const { moving: _, ...shown } = card;
|
|
1086
|
+
state.card = { ...shown, version, key: prompt.key };
|
|
1087
|
+
}
|
|
1063
1088
|
});
|
|
1064
1089
|
}
|
|
1090
|
+
const MOVED = 'It has a card of its own now, with Allow and Deny.';
|
|
1065
1091
|
async function withdraw(io, client, card, state, how) {
|
|
1066
1092
|
const machine = await computerOf(io, state.origin, appOf(state));
|
|
1067
1093
|
try {
|
|
1068
1094
|
await client.call('cancel_request', {
|
|
1069
1095
|
requestId: card.requestId,
|
|
1070
1096
|
name: card.name,
|
|
1071
|
-
reason: how === '
|
|
1072
|
-
?
|
|
1073
|
-
:
|
|
1097
|
+
reason: how === 'moved'
|
|
1098
|
+
? MOVED
|
|
1099
|
+
: how === 'answered'
|
|
1100
|
+
? cut(`Answered in ${APP_NAMES[appOf(state)]} on ${machine}.`, 200)
|
|
1101
|
+
: cut(`That ${APP_NAMES[appOf(state)]} session on ${machine} ended, so it isn’t waiting any more.`, 200),
|
|
1074
1102
|
...(how === 'answered' ? { answeredHere: true } : {}),
|
|
1075
1103
|
});
|
|
1076
1104
|
}
|
|
@@ -1186,7 +1214,7 @@ export async function permissionWorker(io, options) {
|
|
|
1186
1214
|
}
|
|
1187
1215
|
else {
|
|
1188
1216
|
const card = state.card;
|
|
1189
|
-
await withdraw(io, client, card, state, state.settled ?? 'answered');
|
|
1217
|
+
await withdraw(io, client, card, state, plan.moved ? 'moved' : (state.settled ?? 'answered'));
|
|
1190
1218
|
await record(io, session, worker, (state) => {
|
|
1191
1219
|
if (state.card?.requestId === card.requestId)
|
|
1192
1220
|
delete state.card;
|
|
@@ -1282,6 +1310,7 @@ export async function recordRelay(io, origin, heard, options) {
|
|
|
1282
1310
|
return state;
|
|
1283
1311
|
prompt.relay = heard.relay;
|
|
1284
1312
|
delete prompt.failed;
|
|
1313
|
+
relayed(state);
|
|
1285
1314
|
matched = true;
|
|
1286
1315
|
delete state.settled;
|
|
1287
1316
|
worker = claim(state, io.now());
|
|
@@ -1318,6 +1347,7 @@ export async function recordRelay(io, origin, heard, options) {
|
|
|
1318
1347
|
if (next.prompts.some((each) => each.relay?.id === heard.relay.id))
|
|
1319
1348
|
return next;
|
|
1320
1349
|
next.prompts.push(prompt);
|
|
1350
|
+
relayed(next);
|
|
1321
1351
|
keepNewest(next);
|
|
1322
1352
|
delete next.settled;
|
|
1323
1353
|
next.at = now;
|
|
@@ -1353,3 +1383,216 @@ export async function removePermissionFiles(io) {
|
|
|
1353
1383
|
await rm(folder, { recursive: true, force: true });
|
|
1354
1384
|
return there;
|
|
1355
1385
|
}
|
|
1386
|
+
export const ANSWER_READ_MS = 2000;
|
|
1387
|
+
const ANSWER_CALL_MS = 8000;
|
|
1388
|
+
const ANSWER_INPUT_MAX = 40_000;
|
|
1389
|
+
const ANSWER_LINES_MAX = 400;
|
|
1390
|
+
const ANSWER_LINE_MAX = 2000;
|
|
1391
|
+
export const DENIED = 'Denied in Pending You.';
|
|
1392
|
+
export function codexDecision(verdict) {
|
|
1393
|
+
const decision = verdict === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: DENIED };
|
|
1394
|
+
return JSON.stringify({
|
|
1395
|
+
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision },
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
export function codexInputLines(tool, input) {
|
|
1399
|
+
const { description: _, ...rest } = input;
|
|
1400
|
+
const command = commandText(rest.command);
|
|
1401
|
+
const patch = command || text(rest.patch) || text(rest.input);
|
|
1402
|
+
let lines;
|
|
1403
|
+
let lang = 'text';
|
|
1404
|
+
if (tool === 'apply_patch' && patch)
|
|
1405
|
+
lines = patch.split('\n');
|
|
1406
|
+
else if (command) {
|
|
1407
|
+
lines = command.split('\n');
|
|
1408
|
+
lang = 'shell';
|
|
1409
|
+
}
|
|
1410
|
+
else
|
|
1411
|
+
lines = Object.entries(rest).flatMap(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`.split('\n'));
|
|
1412
|
+
const kept = [];
|
|
1413
|
+
let size = 0;
|
|
1414
|
+
for (const line of lines) {
|
|
1415
|
+
if (kept.length >= ANSWER_LINES_MAX ||
|
|
1416
|
+
line.length > ANSWER_LINE_MAX ||
|
|
1417
|
+
size + line.length > ANSWER_INPUT_MAX) {
|
|
1418
|
+
kept.push('… (cut here: too long to show whole)');
|
|
1419
|
+
return { lines: kept, lang, cut: true };
|
|
1420
|
+
}
|
|
1421
|
+
kept.push(maskSecrets(line));
|
|
1422
|
+
size += line.length;
|
|
1423
|
+
}
|
|
1424
|
+
return { lines: kept.length ? kept : [''], lang, cut: false };
|
|
1425
|
+
}
|
|
1426
|
+
function tapOf(card, partial) {
|
|
1427
|
+
if (!['answered', 'resolved'].includes(text(card.status)))
|
|
1428
|
+
return null;
|
|
1429
|
+
const answer = isObject(card.answer) ? card.answer : null;
|
|
1430
|
+
const picked = Array.isArray(answer?.choiceIds) ? answer.choiceIds : [];
|
|
1431
|
+
if (picked.length !== 1)
|
|
1432
|
+
return 'terminal';
|
|
1433
|
+
if (picked[0] === DENY)
|
|
1434
|
+
return 'deny';
|
|
1435
|
+
if (picked[0] === ALLOW && !partial)
|
|
1436
|
+
return 'allow';
|
|
1437
|
+
return 'terminal';
|
|
1438
|
+
}
|
|
1439
|
+
async function askFirst(io, origin, input, wait) {
|
|
1440
|
+
if (io.platform === 'win32')
|
|
1441
|
+
return false;
|
|
1442
|
+
const where = whereOf(input);
|
|
1443
|
+
const tool = text(input.tool_name);
|
|
1444
|
+
if (!where || !tool)
|
|
1445
|
+
return false;
|
|
1446
|
+
if ((await readCredential(io, origin, 'codex'))?.kind !== 'connection')
|
|
1447
|
+
return false;
|
|
1448
|
+
const started = io.now();
|
|
1449
|
+
const toolInput = isObject(input.tool_input) ? input.tool_input : {};
|
|
1450
|
+
const cwd = text(input.cwd) || io.cwd;
|
|
1451
|
+
const known = await load(io, where.session).catch(() => null);
|
|
1452
|
+
const state = {
|
|
1453
|
+
version: 1,
|
|
1454
|
+
origin,
|
|
1455
|
+
app: 'codex',
|
|
1456
|
+
session: where.session,
|
|
1457
|
+
cwd,
|
|
1458
|
+
prompts: [],
|
|
1459
|
+
...(known?.areaId && known.cwd === cwd ? { areaId: known.areaId } : {}),
|
|
1460
|
+
at: started,
|
|
1461
|
+
};
|
|
1462
|
+
const prompt = {
|
|
1463
|
+
key: promptKey(tool, keyInput('codex', toolInput)),
|
|
1464
|
+
tool: tool.slice(0, 100),
|
|
1465
|
+
...promptWords(tool, toolInput, io.home, 'codex'),
|
|
1466
|
+
at: started,
|
|
1467
|
+
};
|
|
1468
|
+
const shown = codexInputLines(tool, toolInput);
|
|
1469
|
+
let client = null;
|
|
1470
|
+
let card;
|
|
1471
|
+
let machine;
|
|
1472
|
+
try {
|
|
1473
|
+
client = await mcpClient(io, origin, 'codex', ANSWER_CALL_MS);
|
|
1474
|
+
const name = await nameOf(io, state);
|
|
1475
|
+
await client.call('whoami', { name });
|
|
1476
|
+
const areaId = state.areaId ?? (await areaFor(io, client, state, name));
|
|
1477
|
+
machine = await computerOf(io, origin, 'codex');
|
|
1478
|
+
const title = relayTitle(state, prompt, name);
|
|
1479
|
+
const who = name === defaultName('codex') ? APP_NAMES.codex : `${APP_NAMES.codex} (${name})`;
|
|
1480
|
+
const wants = prompt.doing.startsWith('to ')
|
|
1481
|
+
? `wants your OK ${prompt.doing}`
|
|
1482
|
+
: `wants your OK to use ${toolName(tool)}`;
|
|
1483
|
+
const reason = reasonOf('codex', toolInput);
|
|
1484
|
+
const why = reason ? ` It says: “${reason}”.` : '';
|
|
1485
|
+
const answer = shown.cut
|
|
1486
|
+
? ' It’s too long to show whole, so only Deny is here; to allow it, choose “I’ll answer in Codex” and answer in its terminal.'
|
|
1487
|
+
: ` Allow or Deny here: Codex waits up to ${minutes(wait)} for you, showing nothing, then asks in its terminal.`;
|
|
1488
|
+
const session = createHash('sha256').update(where.session).digest('hex').slice(0, 24);
|
|
1489
|
+
const result = await client.call('post_request', {
|
|
1490
|
+
idempotencyKey: `codex-ask:${session}:${prompt.key}:${started}`,
|
|
1491
|
+
areaId,
|
|
1492
|
+
session: { label: name, machine, cwd: cut(tilde(io, cwd), 300) },
|
|
1493
|
+
kind: 'choice',
|
|
1494
|
+
intent: 'approve',
|
|
1495
|
+
title,
|
|
1496
|
+
summary: cut(`${who} in ${tilde(io, cwd)} on ${machine} ${wants}.${why}${answer}`, 400),
|
|
1497
|
+
options: shown.cut
|
|
1498
|
+
? [
|
|
1499
|
+
{ id: DENY, label: 'Deny' },
|
|
1500
|
+
{ id: IN_TERMINAL, label: `I’ll answer in ${APP_NAMES.codex}` },
|
|
1501
|
+
]
|
|
1502
|
+
: [
|
|
1503
|
+
{ id: ALLOW, label: 'Allow', detail: 'This call only. Nothing is allowed after it.' },
|
|
1504
|
+
{ id: DENY, label: 'Deny' },
|
|
1505
|
+
],
|
|
1506
|
+
artifacts: [
|
|
1507
|
+
{
|
|
1508
|
+
kind: 'code',
|
|
1509
|
+
title: shown.lang === 'shell' ? 'The command' : `${toolName(tool)}’s input`,
|
|
1510
|
+
ref: `permission-${prompt.key.slice(0, 16)}`,
|
|
1511
|
+
lang: shown.lang,
|
|
1512
|
+
lines: shown.lines,
|
|
1513
|
+
},
|
|
1514
|
+
],
|
|
1515
|
+
context: { ...contextOf(state, prompt), trace: [{ state: 'paused', text: title }] },
|
|
1516
|
+
blocking: false,
|
|
1517
|
+
urgency: 'now',
|
|
1518
|
+
permissionPrompt: { app: 'codex', relay: true },
|
|
1519
|
+
});
|
|
1520
|
+
const requestId = idOf(result, 'requestId', 'req_');
|
|
1521
|
+
if (!requestId)
|
|
1522
|
+
throw new McpFailure(-32000, 'Pending You posted no card.');
|
|
1523
|
+
card = {
|
|
1524
|
+
requestId,
|
|
1525
|
+
version: typeof result.version === 'number' ? result.version : 1,
|
|
1526
|
+
key: prompt.key,
|
|
1527
|
+
name,
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
await client?.close().catch(() => { });
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
const pending = client;
|
|
1535
|
+
const read = () => pending.call('get_request', { requestId: card.requestId, name: card.name });
|
|
1536
|
+
const say = async (tap, now) => {
|
|
1537
|
+
if (tap !== 'terminal')
|
|
1538
|
+
io.out(`${codexDecision(tap)}\n`);
|
|
1539
|
+
if (now.status !== 'answered' || typeof now.version !== 'number')
|
|
1540
|
+
return;
|
|
1541
|
+
await pending
|
|
1542
|
+
.call('ack_answer', {
|
|
1543
|
+
requestId: card.requestId,
|
|
1544
|
+
name: card.name,
|
|
1545
|
+
expectedVersion: now.version,
|
|
1546
|
+
outcome: cut(tap === 'allow'
|
|
1547
|
+
? `Allowed in Codex on ${machine}.`
|
|
1548
|
+
: tap === 'deny'
|
|
1549
|
+
? `Denied in Codex on ${machine}.`
|
|
1550
|
+
: `Not allowed or denied here: Codex asks in its terminal on ${machine} now.`, 200),
|
|
1551
|
+
})
|
|
1552
|
+
.catch(() => { });
|
|
1553
|
+
};
|
|
1554
|
+
const until = started + wait * 60_000;
|
|
1555
|
+
try {
|
|
1556
|
+
while (!io.signal.aborted && io.now() < until) {
|
|
1557
|
+
await io.sleep(Math.min(ANSWER_READ_MS, until - io.now()), io.signal);
|
|
1558
|
+
if (io.signal.aborted)
|
|
1559
|
+
return true;
|
|
1560
|
+
let now;
|
|
1561
|
+
try {
|
|
1562
|
+
now = await read();
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
if (error instanceof McpFailure && error.code === SIGN_IN_ERROR)
|
|
1566
|
+
break;
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (['pending', 'snoozed', 'delegated'].includes(text(now.status)))
|
|
1570
|
+
continue;
|
|
1571
|
+
const tap = tapOf(now, shown.cut);
|
|
1572
|
+
if (tap)
|
|
1573
|
+
await say(tap, now);
|
|
1574
|
+
return true;
|
|
1575
|
+
}
|
|
1576
|
+
if (io.signal.aborted)
|
|
1577
|
+
return true;
|
|
1578
|
+
try {
|
|
1579
|
+
await pending.call('cancel_request', {
|
|
1580
|
+
requestId: card.requestId,
|
|
1581
|
+
name: card.name,
|
|
1582
|
+
reason: cut(`No answer here in ${minutes(wait)}, so Codex is asking in its terminal on ${machine} now.`, 200),
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
catch (error) {
|
|
1586
|
+
if (!(error instanceof ToolRefused))
|
|
1587
|
+
return true;
|
|
1588
|
+
const now = await read().catch(() => null);
|
|
1589
|
+
const tap = now ? tapOf(now, shown.cut) : null;
|
|
1590
|
+
if (now && tap)
|
|
1591
|
+
await say(tap, now);
|
|
1592
|
+
}
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
finally {
|
|
1596
|
+
await pending.close().catch(() => { });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.34.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
4
4
|
"description": "Pending You for your coding agents: one command sets up Claude Code, Codex, OpenCode and Pi to ask you through Pending You and hear your answers, even while they sit idle.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://www.pendingyou.com",
|