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 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`) |
@@ -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}`);
@@ -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;
@@ -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
@@ -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
+ }