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/dist/claude.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { rm, rmdir, stat } from 'node:fs/promises';
3
3
  import { basename, dirname, join } from 'node:path';
4
4
  import { DEFAULT_ORIGIN } from "./args.js";
5
+ import { CHANNEL, CHANNEL_CLAUDE } from "./channel.js";
5
6
  import { PlainError } from "./errors.js";
6
7
  import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
7
8
  import { findHook, hooksOf, mergeHooks, originArgs, removeHooks } from "./hooks.js";
@@ -11,7 +12,7 @@ import { atLeast, claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pl
11
12
  import { removePermissionFiles } from "./permission.js";
12
13
  import { removePresenceFiles } from "./presence.js";
13
14
  import { helperPath } from "./remote.js";
14
- import { readShim, SHIM } from "./shim.js";
15
+ import { readShim, SHIM, shimPath } from "./shim.js";
15
16
  import { VERSION } from "./version.js";
16
17
  export { isOurHook } from "./hooks.js";
17
18
  export const MCP_NAME = 'pendingyou';
@@ -192,6 +193,105 @@ export function helperServer(url, command) {
192
193
  text: `claude mcp add-json --scope user ${MCP_NAME} ${quote(json)}`,
193
194
  };
194
195
  }
196
+ export function channelEntry(io, origin) {
197
+ return {
198
+ type: 'stdio',
199
+ command: shimPath(io),
200
+ args: [
201
+ 'channel',
202
+ '--app',
203
+ 'claude-code',
204
+ ...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
205
+ ],
206
+ };
207
+ }
208
+ export async function channelFromFile(io) {
209
+ let parsed;
210
+ try {
211
+ const text = await readText(claudeJsonPath(io));
212
+ if (text === null)
213
+ return 'unknown';
214
+ parsed = JSON.parse(text);
215
+ }
216
+ catch {
217
+ return 'unknown';
218
+ }
219
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
220
+ return 'unknown';
221
+ const servers = objectAt(parsed, 'mcpServers');
222
+ return servers ? objectAt(servers, CHANNEL) : null;
223
+ }
224
+ const sameEntry = (entry, wanted) => entry.command === wanted.command && JSON.stringify(entry.args) === JSON.stringify(wanted.args);
225
+ async function applyChannel(io, origin, wanted, before, progress) {
226
+ const entry = channelEntry(io, origin);
227
+ const now = await channelFromFile(io);
228
+ const remove = () => io.run('claude', ['mcp', 'remove', '--scope', 'user', CHANNEL], MCP_TIMEOUT_MS);
229
+ if (!wanted) {
230
+ if (!before?.channel || now === null)
231
+ return { step: null, registered: false };
232
+ progress('Removing the permission channel from Claude Code (this can take a minute)…\n');
233
+ const removed = await remove();
234
+ return removed.code === 0
235
+ ? {
236
+ step: { ok: true, text: `Took out the permission channel (${CHANNEL}).` },
237
+ registered: false,
238
+ }
239
+ : {
240
+ step: {
241
+ ok: false,
242
+ text: `Couldn’t take out the permission channel. Run: claude mcp remove --scope user ${CHANNEL}`,
243
+ },
244
+ registered: true,
245
+ };
246
+ }
247
+ if (now !== null && now !== 'unknown' && sameEntry(now, entry))
248
+ return {
249
+ step: { ok: true, text: 'The permission channel was already registered.' },
250
+ registered: true,
251
+ };
252
+ const json = JSON.stringify(entry);
253
+ const add = `claude mcp add-json --scope user ${CHANNEL} ${quote(json)}`;
254
+ if (now !== null && now !== 'unknown') {
255
+ progress('Updating the permission channel in Claude Code (this can take a minute)…\n');
256
+ if ((await remove()).code !== 0)
257
+ return {
258
+ step: {
259
+ ok: false,
260
+ text: `Couldn’t update the permission channel. Run:\n claude mcp remove --scope user ${CHANNEL}\n ${add}`,
261
+ },
262
+ registered: false,
263
+ };
264
+ }
265
+ else
266
+ progress('Adding the permission channel to Claude Code (this can take a minute)…\n');
267
+ const added = await io.run('claude', ['mcp', 'add-json', '--scope', 'user', CHANNEL, json], MCP_TIMEOUT_MS);
268
+ if (added.code !== 0)
269
+ return {
270
+ step: { ok: false, text: `Claude Code didn’t add the permission channel. Run: ${add}` },
271
+ registered: false,
272
+ };
273
+ return {
274
+ step: {
275
+ ok: true,
276
+ text: `Added the permission channel (${CHANNEL}): start Claude Code with npx pendingyou claude and a prompt’s card has Allow and Deny. The first answer wins, there or in the terminal.`,
277
+ },
278
+ registered: true,
279
+ };
280
+ }
281
+ export async function claudeWithChannel(io, args) {
282
+ if (!io.handOver)
283
+ throw new PlainError('This can’t start Claude Code here.');
284
+ if ((await channelFromFile(io)) === null)
285
+ io.err('pendingyou: the permission channel isn’t set up, so Claude Code’s prompts get no Allow or Deny on their cards. Run: npx pendingyou@latest init\n');
286
+ const code = await io.handOver('claude', [
287
+ '--dangerously-load-development-channels',
288
+ `server:${CHANNEL}`,
289
+ ...args,
290
+ ]);
291
+ if (code === 127)
292
+ io.err('pendingyou: Claude Code isn’t installed here (no claude on PATH).\n');
293
+ return code;
294
+ }
195
295
  async function applyMcp(io, origin, plan, helper, progress) {
196
296
  const url = `${origin}/mcp`;
197
297
  const add = helper
@@ -363,6 +463,10 @@ export async function installClaude(io, origin, options) {
363
463
  ? `${merged.permissionChanged ? 'Took out the permission-prompt hooks' : 'Left out the permission-prompt hooks'}, as you asked: no card when Claude Code waits for your OK. To turn them on: npx pendingyou init --permission-cards${flag}`
364
464
  : `${merged.permissionChanged ? 'Took out' : 'Left out'} the permission-prompt hooks: ${blocker}.`,
365
465
  });
466
+ const found = claudeVersion(options.version);
467
+ const channel = await applyChannel(io, origin, Boolean(permissionLines) && found !== null && atLeast(found, CHANNEL_CLAUDE), before, options.progress);
468
+ if (channel.step)
469
+ report(channel.step);
366
470
  const mod = await addMod(io, merged.settings, options.copy, options.version);
367
471
  if (mod.step)
368
472
  report(mod.step);
@@ -387,6 +491,7 @@ export async function installClaude(io, origin, options) {
387
491
  ...(options.plugin ? { plugin: true } : {}),
388
492
  permissionHooks: permissionLines,
389
493
  permissionCards: wanted,
494
+ ...(channel.registered ? { channel: true } : {}),
390
495
  };
391
496
  await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
392
497
  return steps;
@@ -458,6 +563,16 @@ export async function uninstallClaude(io) {
458
563
  const skill = await removeSkill(manifest?.skill ?? null);
459
564
  if (skill)
460
565
  steps.push(skill);
566
+ if (manifest?.channel && (await channelFromFile(io)) !== null) {
567
+ io.out('Removing the permission channel from Claude Code (this can take a minute)…\n');
568
+ const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', CHANNEL], MCP_TIMEOUT_MS);
569
+ steps.push(result.code === 0
570
+ ? { ok: true, text: `Removed the permission channel (${CHANNEL}) from Claude Code.` }
571
+ : {
572
+ ok: false,
573
+ text: `Couldn’t remove the permission channel. Run: claude mcp remove --scope user ${CHANNEL}`,
574
+ });
575
+ }
461
576
  if (manifest?.mcpAdded) {
462
577
  io.out('Removing the pendingyou MCP server from Claude Code (this can take a minute)…\n');
463
578
  const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], MCP_TIMEOUT_MS);
@@ -523,6 +638,7 @@ export async function claudeState(io) {
523
638
  permission,
524
639
  notify,
525
640
  presence: Boolean(ours('SessionEnd')),
641
+ channel: await channelFromFile(io).then((entry) => entry === 'unknown' ? 'unknown' : entry !== null),
526
642
  form: form ? { ...form, runs: await hookRuns(form), shim } : null,
527
643
  mod,
528
644
  plugins,
@@ -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/io.js CHANGED
@@ -68,6 +68,29 @@ export function execProgram(command, args, options) {
68
68
  child.stdin?.end(options.stdin);
69
69
  });
70
70
  }
71
+ let handedTo = null;
72
+ function handOver(command, args) {
73
+ return new Promise((resolve) => {
74
+ let child;
75
+ try {
76
+ child = spawn(command, [...args], { stdio: 'inherit' });
77
+ }
78
+ catch {
79
+ resolve(127);
80
+ return;
81
+ }
82
+ handedTo = (signal) => {
83
+ if (signal !== 'SIGINT')
84
+ child.kill(signal);
85
+ };
86
+ const done = (code) => {
87
+ handedTo = null;
88
+ resolve(code);
89
+ };
90
+ child.on('error', (error) => done(error.code === 'ENOENT' ? 127 : 1));
91
+ child.on('exit', (code, signal) => done(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)));
92
+ });
93
+ }
71
94
  export function sleep(ms, signal) {
72
95
  return new Promise((resolve) => {
73
96
  if (signal?.aborted || ms <= 0) {
@@ -232,6 +255,8 @@ export function realIo() {
232
255
  const controller = new AbortController();
233
256
  for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP'])
234
257
  process.on(name, () => {
258
+ if (handedTo)
259
+ return handedTo(name);
235
260
  if (controller.signal.aborted)
236
261
  process.exit(130);
237
262
  controller.abort();
@@ -250,6 +275,7 @@ export function realIo() {
250
275
  ask,
251
276
  run: runProgram,
252
277
  exec: execProgram,
278
+ handOver,
253
279
  openBrowser,
254
280
  readStdin,
255
281
  lines: () => lines(controller.signal, () => controller.abort()),
package/dist/main.js CHANGED
@@ -1,9 +1,13 @@
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";
5
6
  import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
6
7
  import { bridge } from "./bridge.js";
8
+ import { channel } from "./channel.js";
9
+ import { claudeWithChannel } from "./claude.js";
10
+ import { ANSWER_DEADLINE_MARGIN_MS, readAnswerWait } from "./codex-answers.js";
7
11
  import { PlainError } from "./errors.js";
8
12
  import { hold } from "./hold.js";
9
13
  import { init, status, uninstall } from "./init.js";
@@ -83,6 +87,8 @@ async function run(io, command, hook = { fallback: '' }) {
83
87
  return uninstall(io, command);
84
88
  case 'watch':
85
89
  return watch(io, command);
90
+ case 'codex-answers':
91
+ return setAnswerWait(io, command);
86
92
  case 'refresh':
87
93
  try {
88
94
  await refreshInBackground(io, command.origin, command.force, command.app ?? DEFAULT_APP);
@@ -93,6 +99,10 @@ async function run(io, command, hook = { fallback: '' }) {
93
99
  return mcpHeaders(io, command);
94
100
  case 'mcp':
95
101
  return bridge(io, command);
102
+ case 'channel':
103
+ return channel(io, command);
104
+ case 'claude':
105
+ return claudeWithChannel(io, command.args);
96
106
  case 'herdr':
97
107
  return (await import("./herdr/command.js")).herdr(io, command);
98
108
  case 'machine':
@@ -102,6 +112,12 @@ async function run(io, command, hook = { fallback: '' }) {
102
112
  }
103
113
  }
104
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
+ }
105
121
  async function runHook(io, command) {
106
122
  const hook = { fallback: '' };
107
123
  const controller = new AbortController();
@@ -121,9 +137,10 @@ async function runHook(io, command) {
121
137
  },
122
138
  signal: controller.signal,
123
139
  };
140
+ const limit = await deadlineOf(io, command);
124
141
  let timer;
125
142
  const deadline = new Promise((resolve) => {
126
- timer = setTimeout(() => resolve('deadline'), Math.max(0, HOOK_DEADLINE_MS - io.uptime()));
143
+ timer = setTimeout(() => resolve('deadline'), Math.max(0, limit - io.uptime()));
127
144
  });
128
145
  try {
129
146
  const outcome = await Promise.race([run(hookIo, command, hook), deadline]);