pendingyou 0.32.3 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +36 -0
- package/dist/apps/claude-code.js +20 -2
- package/dist/apps/codex.js +63 -4
- package/dist/args.js +27 -0
- package/dist/channel.js +276 -0
- package/dist/claude.js +117 -1
- package/dist/codex-answers.js +15 -0
- package/dist/io.js +26 -0
- package/dist/main.js +18 -1
- package/dist/permission.js +505 -14
- package/dist/redact.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +10 -2
package/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,
|
|
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]);
|