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 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`) |
@@ -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 = (io) => join(configDir(io), 'codex.json');
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 ? CODEX_PERMISSION_HOOKS : []),
471
+ ...(extra && cards ? codexPermissionHooks(wait) : []),
424
472
  ...(extra ? CODEX_PRESENCE_HOOKS : []),
425
473
  ];
426
- const unwanted = ALL_CODEX_HOOKS.filter((spec) => !wanted.includes(spec));
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, HOOK_DEADLINE_MS - io.uptime()));
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]);
@@ -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) => Math.min(Math.min(prompt.at, prompt.relay.at) + grace, prompt.shown ?? Number.POSITIVE_INFINITY);
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 ? { kind: 'cancel' } : { kind: 'done' };
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
- state.card = { ...card, version, key: prompt.key };
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 === 'answered'
1072
- ? cut(`Answered in ${APP_NAMES[appOf(state)]} on ${machine}.`, 200)
1073
- : cut(`That ${APP_NAMES[appOf(state)]} session on ${machine} ended, so it isn’t waiting any more.`, 200),
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.33.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.33.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",