pendingyou 0.12.0 → 0.13.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 +75 -0
- package/README.md +49 -10
- package/dist/apps/claude-code.js +20 -2
- package/dist/apps/codex-app.js +44 -0
- package/dist/apps/codex-wake.js +13 -2
- package/dist/apps/codex.js +66 -30
- package/dist/apps/pi-extension.js +7 -2
- package/dist/args.js +41 -2
- package/dist/bridge.js +82 -0
- package/dist/claude.js +78 -8
- package/dist/files.js +5 -0
- package/dist/hooks.js +8 -3
- package/dist/init.js +2 -1
- package/dist/io.js +2 -2
- package/dist/main.js +14 -0
- package/dist/permission.js +738 -0
- package/dist/pickup.js +13 -6
- package/dist/redact.js +369 -0
- package/dist/setup.js +16 -5
- package/dist/shim.js +8 -1
- package/dist/stopcheck.js +11 -1
- package/dist/version.js +1 -1
- package/mod/hooks/register.js +7 -3
- package/mod/hooks/wake.js +17 -4
- package/package.json +1 -1
package/dist/args.js
CHANGED
|
@@ -76,6 +76,9 @@ const COMMANDS = new Set([
|
|
|
76
76
|
'posted',
|
|
77
77
|
'listen',
|
|
78
78
|
'mcp',
|
|
79
|
+
'permission',
|
|
80
|
+
'permission-done',
|
|
81
|
+
'permission-card',
|
|
79
82
|
]);
|
|
80
83
|
/** Flags each command takes; `true` when the flag takes a value. */
|
|
81
84
|
const FLAGS = {
|
|
@@ -98,6 +101,9 @@ const FLAGS = {
|
|
|
98
101
|
stopcheck: { origin: true, app: true },
|
|
99
102
|
posted: { origin: true, app: true },
|
|
100
103
|
listen: { origin: true, app: true, thread: true },
|
|
104
|
+
permission: { origin: true, app: true },
|
|
105
|
+
'permission-done': { origin: true, app: true },
|
|
106
|
+
'permission-card': { origin: true, session: true, worker: true },
|
|
101
107
|
init: {
|
|
102
108
|
origin: true,
|
|
103
109
|
'no-login': false,
|
|
@@ -108,6 +114,8 @@ const FLAGS = {
|
|
|
108
114
|
name: true,
|
|
109
115
|
oauth: false,
|
|
110
116
|
app: true,
|
|
117
|
+
'permission-cards': false,
|
|
118
|
+
'no-permission-cards': false,
|
|
111
119
|
},
|
|
112
120
|
uninstall: { origin: true, app: true },
|
|
113
121
|
watch: { origin: true, once: false },
|
|
@@ -116,7 +124,7 @@ const FLAGS = {
|
|
|
116
124
|
mcp: { origin: true, app: true, check: false },
|
|
117
125
|
};
|
|
118
126
|
/** Options that take a value, whichever command they belong to: needed to find the command behind them. */
|
|
119
|
-
const VALUE_OPTIONS = new Set(['origin', 'timeout', 'name', 'app', 'thread']);
|
|
127
|
+
const VALUE_OPTIONS = new Set(['origin', 'timeout', 'name', 'app', 'thread', 'session', 'worker']);
|
|
120
128
|
/** Splits off the options given before the command: [those options, the command, everything after it]. */
|
|
121
129
|
function splitCommand(argv) {
|
|
122
130
|
let index = 0;
|
|
@@ -138,6 +146,8 @@ export const HOOK_COMMANDS = new Set([
|
|
|
138
146
|
'handoff',
|
|
139
147
|
'stopcheck',
|
|
140
148
|
'posted',
|
|
149
|
+
'permission',
|
|
150
|
+
'permission-done',
|
|
141
151
|
]);
|
|
142
152
|
/**
|
|
143
153
|
* A thread's id, as an app's hooks give it (`session_id`): Codex's (letters, digits and dashes), an OpenCode session's
|
|
@@ -244,6 +254,16 @@ export function parseArgs(argv, env = {}) {
|
|
|
244
254
|
throw new UsageError(`${first} takes one app, like: --app ${apps[0]}`);
|
|
245
255
|
return { app: apps[0] };
|
|
246
256
|
};
|
|
257
|
+
/** `--permission-cards` or `--no-permission-cards`, or neither (as before). */
|
|
258
|
+
const permissionCards = () => {
|
|
259
|
+
if (flags.has('permission-cards') && flags.has('no-permission-cards'))
|
|
260
|
+
throw new UsageError('Use --permission-cards or --no-permission-cards, not both.');
|
|
261
|
+
return flags.has('permission-cards')
|
|
262
|
+
? { permissionCards: true }
|
|
263
|
+
: flags.has('no-permission-cards')
|
|
264
|
+
? { permissionCards: false }
|
|
265
|
+
: {};
|
|
266
|
+
};
|
|
247
267
|
/** `--app`, one or several; none when it isn't given (every app). */
|
|
248
268
|
const apps = () => {
|
|
249
269
|
const given = text('app');
|
|
@@ -295,6 +315,23 @@ export function parseArgs(argv, env = {}) {
|
|
|
295
315
|
throw new UsageError('listen needs the thread it wakes, like: --thread 0199a1b2-…');
|
|
296
316
|
return { name: 'listen', origin, ...app(), thread };
|
|
297
317
|
}
|
|
318
|
+
// Not in USAGE: Claude Code's permission-prompt hooks (0.13.0, permission.ts).
|
|
319
|
+
case 'permission':
|
|
320
|
+
case 'permission-done':
|
|
321
|
+
none();
|
|
322
|
+
return { name: first, origin, ...app() };
|
|
323
|
+
// Not in USAGE: what those hooks start in the background, one per Claude Code session (permission.ts).
|
|
324
|
+
case 'permission-card': {
|
|
325
|
+
none();
|
|
326
|
+
const session = text('session');
|
|
327
|
+
const worker = text('worker');
|
|
328
|
+
if (session === undefined ||
|
|
329
|
+
!THREAD.test(session) ||
|
|
330
|
+
!worker ||
|
|
331
|
+
!/^[0-9a-f]{8,64}$/.test(worker))
|
|
332
|
+
throw new UsageError('permission-card needs --session <id> and --worker <id>.');
|
|
333
|
+
return { name: 'permission-card', origin, session, worker };
|
|
334
|
+
}
|
|
298
335
|
case 'uninstall':
|
|
299
336
|
none();
|
|
300
337
|
return { name: 'uninstall', origin, ...apps() };
|
|
@@ -321,6 +358,7 @@ export function parseArgs(argv, env = {}) {
|
|
|
321
358
|
...machine(),
|
|
322
359
|
...(flags.has('oauth') ? { oauth: true } : {}),
|
|
323
360
|
...apps(),
|
|
361
|
+
...permissionCards(),
|
|
324
362
|
};
|
|
325
363
|
case 'watch':
|
|
326
364
|
none();
|
|
@@ -375,5 +413,6 @@ Options: --origin <url> (default ${DEFAULT_ORIGIN}), --app <app> (init, status,
|
|
|
375
413
|
the default over SSH), --browser (open the browser anyway), --no-browser (only print the address), --name <name>
|
|
376
414
|
(login, init: what to call this computer; by default its own name on a Mac, its Tailscale name, else its hostname),
|
|
377
415
|
--no-login (init), --oauth (init: every agent signs in to Pending You by itself, as before 0.11.0), --yes (init: switch
|
|
378
|
-
or replace a pendingyou MCP server without asking), --
|
|
416
|
+
or replace a pendingyou MCP server without asking), --no-permission-cards (init: no card when Claude Code waits for your
|
|
417
|
+
OK; --permission-cards turns them back on), --all (logout), --force (login), --once (watch).
|
|
379
418
|
`;
|
package/dist/bridge.js
CHANGED
|
@@ -536,6 +536,88 @@ export async function bridge(io, options) {
|
|
|
536
536
|
await io.flush();
|
|
537
537
|
return 0;
|
|
538
538
|
}
|
|
539
|
+
/** A tool call Pending You refused, in its own words: a card that's closed, or changed since it was last read. */
|
|
540
|
+
export class ToolRefused extends Error {
|
|
541
|
+
name = 'ToolRefused';
|
|
542
|
+
}
|
|
543
|
+
/** Pending You couldn't be asked: the sign-in (SIGN_IN_ERROR: init has to run again) or the network (BRIDGE_ERROR). */
|
|
544
|
+
export class McpFailure extends Error {
|
|
545
|
+
name = 'McpFailure';
|
|
546
|
+
code;
|
|
547
|
+
constructor(code, message) {
|
|
548
|
+
super(message);
|
|
549
|
+
this.code = code;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/** A tool's answer as Pending You gives it (worker/mcp/server.ts): its structured content, or its text as JSON. */
|
|
553
|
+
function resultOf(result) {
|
|
554
|
+
if (isObject(result.structuredContent))
|
|
555
|
+
return result.structuredContent;
|
|
556
|
+
const blocks = Array.isArray(result.content) ? result.content : [];
|
|
557
|
+
for (const block of blocks) {
|
|
558
|
+
if (!isObject(block) || typeof block.text !== 'string')
|
|
559
|
+
continue;
|
|
560
|
+
try {
|
|
561
|
+
const parsed = JSON.parse(block.text);
|
|
562
|
+
if (isObject(parsed))
|
|
563
|
+
return parsed;
|
|
564
|
+
}
|
|
565
|
+
catch { }
|
|
566
|
+
}
|
|
567
|
+
return {};
|
|
568
|
+
}
|
|
569
|
+
/** The words of a refused tool call (its text content), cut short. */
|
|
570
|
+
function refusalOf(result) {
|
|
571
|
+
const blocks = Array.isArray(result.content) ? result.content : [];
|
|
572
|
+
const text = blocks.find((block) => isObject(block) && typeof block.text === 'string');
|
|
573
|
+
return typeof text?.text === 'string' ? text.text.slice(0, 300) : 'Pending You refused that.';
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* A small MCP client over the bridge's own relay (0.13.0): what the permission-prompt cards' worker (permission.ts)
|
|
577
|
+
* asks Pending You with. The handshake, then one tools/call at a time, each given up after `callMs`, with the app's
|
|
578
|
+
* own connection (its token refreshed in the background, never a redirect followed), exactly as the bridge sends an
|
|
579
|
+
* app's messages. Throws McpFailure when the handshake can't be made.
|
|
580
|
+
*/
|
|
581
|
+
export async function mcpClient(io, origin, app, callMs = 15_000) {
|
|
582
|
+
const replies = new Map();
|
|
583
|
+
const relay = relayFor(io, origin, app, AbortSignal.timeout(callMs), (message) => {
|
|
584
|
+
for (const each of Array.isArray(message) ? message : [message])
|
|
585
|
+
if (isObject(each) && !('method' in each) && isId(each.id))
|
|
586
|
+
replies.set(each.id, each);
|
|
587
|
+
}, () => { });
|
|
588
|
+
let next = 0;
|
|
589
|
+
const send = async (message) => {
|
|
590
|
+
// Each message gets its own time limit; the session it keeps carries over.
|
|
591
|
+
relay.signal = AbortSignal.timeout(callMs);
|
|
592
|
+
await relayOne(relay, JSON.stringify(message), message);
|
|
593
|
+
};
|
|
594
|
+
const request = async (method, params) => {
|
|
595
|
+
const id = `pendingyou-${++next}`;
|
|
596
|
+
await send({ jsonrpc: '2.0', id, method, params });
|
|
597
|
+
const reply = replies.get(id);
|
|
598
|
+
replies.delete(id);
|
|
599
|
+
if (!reply)
|
|
600
|
+
throw new McpFailure(BRIDGE_ERROR, words.noAnswer);
|
|
601
|
+
if (isObject(reply.error))
|
|
602
|
+
throw new McpFailure(typeof reply.error.code === 'number' ? reply.error.code : BRIDGE_ERROR, typeof reply.error.message === 'string' ? reply.error.message : words.noAnswer);
|
|
603
|
+
return isObject(reply.result) ? reply.result : {};
|
|
604
|
+
};
|
|
605
|
+
await request('initialize', {
|
|
606
|
+
protocolVersion: '2025-06-18',
|
|
607
|
+
capabilities: {},
|
|
608
|
+
clientInfo: { name: 'pendingyou', version: VERSION },
|
|
609
|
+
});
|
|
610
|
+
await send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
611
|
+
return {
|
|
612
|
+
async call(name, args) {
|
|
613
|
+
const result = await request('tools/call', { name, arguments: args });
|
|
614
|
+
if (result.isError === true)
|
|
615
|
+
throw new ToolRefused(refusalOf(result));
|
|
616
|
+
return resultOf(result);
|
|
617
|
+
},
|
|
618
|
+
close: () => endSession(relay),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
539
621
|
/**
|
|
540
622
|
* Whether the app's MCP server would reach Pending You through the bridge now (status): the handshake, sent as the
|
|
541
623
|
* bridge sends it, with the app's sign-in. Null when it answered; else why not, in the words the bridge would use.
|
package/dist/claude.js
CHANGED
|
@@ -23,7 +23,8 @@ import { PlainError } from "./errors.js";
|
|
|
23
23
|
import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
|
|
24
24
|
import { findHook, hooksOf, mergeHooks, originArgs, removeHooks } from "./hooks.js";
|
|
25
25
|
import { cliPackage, hookForm, hookRuns, shellQuote, } from "./install.js";
|
|
26
|
-
import { claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pluginDirs, pluginEnabled, separatorFor, withModDir, } from "./mod.js";
|
|
26
|
+
import { atLeast, claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pluginDirs, pluginEnabled, separatorFor, withModDir, } from "./mod.js";
|
|
27
|
+
import { removePermissionFiles } from "./permission.js";
|
|
27
28
|
import { helperPath } from "./remote.js";
|
|
28
29
|
import { readShim, SHIM } from "./shim.js";
|
|
29
30
|
import { VERSION } from "./version.js";
|
|
@@ -37,6 +38,23 @@ export const HOOKS = {
|
|
|
37
38
|
};
|
|
38
39
|
/** The same, as hooks.ts takes them. SessionStart has no matcher: pickup itself skips a compaction (setup.ts). */
|
|
39
40
|
export const CLAUDE_HOOKS = Object.keys(HOOKS).map((event) => ({ event, sub: HOOKS[event] }));
|
|
41
|
+
/**
|
|
42
|
+
* The permission-prompt hooks (0.13.0, permission.ts), for every tool: the prompt (PermissionRequest), the call it
|
|
43
|
+
* asked about running (PostToolUse, PostToolUseFailure) and the session ending (SessionEnd, with no timeout of ours:
|
|
44
|
+
* a longer one would make Claude Code wait longer for every SessionEnd hook). The next message's and the Stop hooks
|
|
45
|
+
* settle a session's prompts too.
|
|
46
|
+
*/
|
|
47
|
+
export const PERMISSION_HOOKS = [
|
|
48
|
+
{ event: 'PermissionRequest', sub: 'permission' },
|
|
49
|
+
{ event: 'PostToolUse', sub: 'permission-done' },
|
|
50
|
+
{ event: 'PostToolUseFailure', sub: 'permission-done' },
|
|
51
|
+
{ event: 'SessionEnd', sub: 'permission-done', timeout: null },
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* The first Claude Code with all of them (PermissionRequest came in 2.0.45, PostToolUseFailure by 2.1.119). An older
|
|
55
|
+
* one gets none: before 2.1.101, a hook event Claude Code didn't know made it ignore the whole settings file.
|
|
56
|
+
*/
|
|
57
|
+
export const PERMISSION_CLAUDE = '2.1.119';
|
|
40
58
|
/**
|
|
41
59
|
* What Claude Code may do without stopping to ask: start the background wait, and use Pending You's own tools (ask
|
|
42
60
|
* the person, read their answers). Without them, a question posted while the person is away would wait on a
|
|
@@ -100,12 +118,23 @@ const objectAt = (parent, key) => {
|
|
|
100
118
|
/**
|
|
101
119
|
* Adds our hooks and permissions, or updates our hooks in place (a new version, or a line an older version wrote, like
|
|
102
120
|
* 0.2.0's `… --origin <url> handoff`, which 0.2.0 itself can't read). Returns the permissions it added.
|
|
121
|
+
* `permission` (0.13.0): the permission-prompt hooks' lines, by event, to add or update; null takes ours out (the
|
|
122
|
+
* person turned them off, or this Claude Code can't run them); left out, they're left as they are.
|
|
103
123
|
*/
|
|
104
|
-
export function mergeSettings(settings, commands) {
|
|
124
|
+
export function mergeSettings(settings, commands, permission) {
|
|
105
125
|
const next = structuredClone(settings);
|
|
106
126
|
const merged = mergeHooks(hooksOf(next, 'Claude Code’s settings'), CLAUDE_HOOKS, commands, 'Claude Code');
|
|
107
|
-
let
|
|
108
|
-
|
|
127
|
+
let hooks = merged.hooks;
|
|
128
|
+
let permissionChanged = false;
|
|
129
|
+
if (permission !== undefined) {
|
|
130
|
+
const prompts = permission
|
|
131
|
+
? mergeHooks(hooks, PERMISSION_HOOKS, permission, 'Claude Code')
|
|
132
|
+
: removeHooks(hooks, PERMISSION_HOOKS);
|
|
133
|
+
hooks = prompts.hooks;
|
|
134
|
+
permissionChanged = prompts.changed;
|
|
135
|
+
}
|
|
136
|
+
let changed = merged.changed || permissionChanged;
|
|
137
|
+
next.hooks = hooks;
|
|
109
138
|
const permissions = objectAt(next, 'permissions') ?? {};
|
|
110
139
|
if (next.permissions !== undefined && objectAt(next, 'permissions') === null)
|
|
111
140
|
throw new PlainError('Claude Code’s “permissions” isn’t an object, so I left the settings alone.');
|
|
@@ -116,7 +145,13 @@ export function mergeSettings(settings, commands) {
|
|
|
116
145
|
next.permissions = permissions;
|
|
117
146
|
changed = true;
|
|
118
147
|
}
|
|
119
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
settings: next,
|
|
150
|
+
permissionsAdded,
|
|
151
|
+
changed,
|
|
152
|
+
hooksChanged: merged.changed,
|
|
153
|
+
permissionChanged,
|
|
154
|
+
};
|
|
120
155
|
}
|
|
121
156
|
/** Removes our hooks, and the permissions init added; leaves everything else as it was. */
|
|
122
157
|
export function unmergeSettings(settings, permissionsAdded) {
|
|
@@ -124,7 +159,7 @@ export function unmergeSettings(settings, permissionsAdded) {
|
|
|
124
159
|
let changed = false;
|
|
125
160
|
const hooks = objectAt(next, 'hooks');
|
|
126
161
|
if (hooks) {
|
|
127
|
-
const removed = removeHooks(hooks, CLAUDE_HOOKS);
|
|
162
|
+
const removed = removeHooks(hooks, [...CLAUDE_HOOKS, ...PERMISSION_HOOKS]);
|
|
128
163
|
changed = removed.changed;
|
|
129
164
|
if (Object.keys(removed.hooks).length === 0)
|
|
130
165
|
delete next.hooks;
|
|
@@ -323,6 +358,17 @@ export async function removeSkill(skill) {
|
|
|
323
358
|
}
|
|
324
359
|
return text !== null ? { ok: true, text: `Kept ${skill.path}: it was changed after init.` } : null;
|
|
325
360
|
}
|
|
361
|
+
/** Why Claude Code here can't have the permission-prompt hooks, in a line; null when it can. */
|
|
362
|
+
export function permissionBlocker(io, version, mode) {
|
|
363
|
+
if (io.platform === 'win32')
|
|
364
|
+
return 'not on Windows yet';
|
|
365
|
+
if (mode !== 'helper')
|
|
366
|
+
return 'they need Claude Code signed in through this computer’s sign-in, and here it signs in by itself';
|
|
367
|
+
const found = claudeVersion(version);
|
|
368
|
+
if (!found || !atLeast(found, PERMISSION_CLAUDE))
|
|
369
|
+
return `they need Claude Code ${PERMISSION_CLAUDE} or later${found ? ` (this is ${found})` : ''}`;
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
326
372
|
/**
|
|
327
373
|
* Everything init does in Claude Code (not the sign-in, nor the shared parts: the copy, the shim, the helper).
|
|
328
374
|
* Idempotent. Each step is reported as it's done.
|
|
@@ -359,13 +405,30 @@ export async function installClaude(io, origin, options) {
|
|
|
359
405
|
skill = await saveSkill(io, origin, skillPath(io), before?.skill ?? null);
|
|
360
406
|
report(skill.step);
|
|
361
407
|
const commands = Object.fromEntries(Object.keys(HOOKS).map((event) => [event, options.hookLine(HOOKS[event])]));
|
|
362
|
-
|
|
408
|
+
// The permission-prompt hooks (0.13.0): on unless the person turned them off, here or before.
|
|
409
|
+
const wanted = options.permissionCards ?? before?.permissionCards !== false;
|
|
410
|
+
const blocker = permissionBlocker(io, options.version, options.mode);
|
|
411
|
+
const permissionLines = wanted && !blocker
|
|
412
|
+
? Object.fromEntries(PERMISSION_HOOKS.map((spec) => [spec.event, options.hookLine(spec.sub)]))
|
|
413
|
+
: null;
|
|
414
|
+
const merged = mergeSettings(await readSettings(io), commands, permissionLines);
|
|
363
415
|
report({
|
|
364
416
|
ok: true,
|
|
365
|
-
text: merged.
|
|
417
|
+
text: merged.hooksChanged
|
|
366
418
|
? `Installed or updated the session-start pickup, next-message hand-off and Stop check hooks in ${settingsPath(io)}.`
|
|
367
419
|
: 'The hooks were already installed.',
|
|
368
420
|
});
|
|
421
|
+
const flag = originArgs(origin);
|
|
422
|
+
report({
|
|
423
|
+
ok: true,
|
|
424
|
+
text: permissionLines
|
|
425
|
+
? merged.permissionChanged
|
|
426
|
+
? 'Added the permission-prompt hooks: when Claude Code waits for your OK and you don’t answer within a few seconds, a card says so (a push only after your hand-off wait), and it goes once you answer in Claude Code.'
|
|
427
|
+
: 'The permission-prompt hooks were already installed.'
|
|
428
|
+
: !wanted
|
|
429
|
+
? `${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}`
|
|
430
|
+
: `${merged.permissionChanged ? 'Took out' : 'Left out'} the permission-prompt hooks: ${blocker}.`,
|
|
431
|
+
});
|
|
369
432
|
const mod = await addMod(io, merged.settings, options.copy, options.version);
|
|
370
433
|
if (mod.step)
|
|
371
434
|
report(mod.step);
|
|
@@ -389,6 +452,8 @@ export async function installClaude(io, origin, options) {
|
|
|
389
452
|
: null,
|
|
390
453
|
setup: { since: new Date(io.now()).toISOString() },
|
|
391
454
|
...(options.plugin ? { plugin: true } : {}),
|
|
455
|
+
permissionHooks: permissionLines,
|
|
456
|
+
permissionCards: wanted,
|
|
392
457
|
};
|
|
393
458
|
await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
|
|
394
459
|
return steps;
|
|
@@ -450,6 +515,8 @@ export async function uninstallClaude(io) {
|
|
|
450
515
|
});
|
|
451
516
|
if (modRemoved)
|
|
452
517
|
steps.push({ ok: true, text: `Removed the wake mod from ${PLUGIN_DIRS}.` });
|
|
518
|
+
// Sessions' permission prompts: nothing will settle them now.
|
|
519
|
+
await removePermissionFiles(io);
|
|
453
520
|
const skill = await removeSkill(manifest?.skill ?? null);
|
|
454
521
|
if (skill)
|
|
455
522
|
steps.push(skill);
|
|
@@ -489,6 +556,7 @@ export async function claudeState(io) {
|
|
|
489
556
|
}
|
|
490
557
|
const skill = (await readText(skillPath(io))) !== null;
|
|
491
558
|
let ours = (_event) => undefined;
|
|
559
|
+
let permission = false;
|
|
492
560
|
const mod = { loads: false, dirs: [], present: false, plugin: false };
|
|
493
561
|
const loads = claude ? claudeVersion(claude) : null;
|
|
494
562
|
mod.loads = loads !== null && loadsMods(loads);
|
|
@@ -500,6 +568,7 @@ export async function claudeState(io) {
|
|
|
500
568
|
plugins = enabledPlugins(settings);
|
|
501
569
|
const all = objectAt(settings, 'hooks') ?? {};
|
|
502
570
|
ours = (event) => findHook(all, event, HOOKS[event]);
|
|
571
|
+
permission = Boolean(findHook(all, 'PermissionRequest', 'permission'));
|
|
503
572
|
}
|
|
504
573
|
catch { }
|
|
505
574
|
const start = ours('SessionStart');
|
|
@@ -513,6 +582,7 @@ export async function claudeState(io) {
|
|
|
513
582
|
skill,
|
|
514
583
|
hooks: Boolean(start && ours('UserPromptSubmit')),
|
|
515
584
|
stopcheck: Boolean(ours('Stop')),
|
|
585
|
+
permission,
|
|
516
586
|
form: form ? { ...form, runs: await hookRuns(form), shim } : null,
|
|
517
587
|
mod,
|
|
518
588
|
plugins,
|
package/dist/files.js
CHANGED
|
@@ -11,6 +11,11 @@ export function configDir(io) {
|
|
|
11
11
|
const base = io.env.XDG_CONFIG_HOME || join(io.home, '.config');
|
|
12
12
|
return join(base, 'pendingyou');
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The folder, under configDir, of Claude Code sessions' permission prompts (0.13.0, permission.ts): one file a session
|
|
16
|
+
* while it has a prompt or a card. The hooks' shim looks for any file here before it starts Node (shim.ts).
|
|
17
|
+
*/
|
|
18
|
+
export const PERMISSION_FOLDER = 'permission-cards';
|
|
14
19
|
/** Claude Code's own folder: ~/.claude, or $CLAUDE_CONFIG_DIR. */
|
|
15
20
|
export function claudeDir(io) {
|
|
16
21
|
return io.env.CLAUDE_CONFIG_DIR || join(io.home, '.claude');
|
package/dist/hooks.js
CHANGED
|
@@ -67,19 +67,24 @@ export function mergeHooks(hooks, specs, lines, where) {
|
|
|
67
67
|
throw new PlainError(`${where}’s ${spec.event} hooks aren’t a list, so I left them alone.`);
|
|
68
68
|
const groups = Array.isArray(next[spec.event]) ? next[spec.event] : [];
|
|
69
69
|
const ours = groups.filter((group) => (group?.hooks ?? []).some((hook) => isOurHook(hook?.command, spec.sub)));
|
|
70
|
+
const timeout = spec.timeout === undefined ? HOOK_TIMEOUT : spec.timeout;
|
|
70
71
|
if (ours.length === 0) {
|
|
71
72
|
groups.push({
|
|
72
73
|
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
73
|
-
hooks: [{ type: 'command', command: wanted, timeout:
|
|
74
|
+
hooks: [{ type: 'command', command: wanted, ...(timeout === null ? {} : { timeout }) }],
|
|
74
75
|
});
|
|
75
76
|
changed = true;
|
|
76
77
|
}
|
|
77
78
|
for (const group of ours) {
|
|
78
79
|
for (const hook of group.hooks ?? [])
|
|
79
80
|
if (isOurHook(hook.command, spec.sub) &&
|
|
80
|
-
(hook.command !== wanted ||
|
|
81
|
+
(hook.command !== wanted ||
|
|
82
|
+
(timeout === null ? 'timeout' in hook : hook.timeout !== timeout))) {
|
|
81
83
|
hook.command = wanted;
|
|
82
|
-
|
|
84
|
+
if (timeout === null)
|
|
85
|
+
delete hook.timeout;
|
|
86
|
+
else
|
|
87
|
+
hook.timeout = timeout;
|
|
83
88
|
changed = true;
|
|
84
89
|
}
|
|
85
90
|
// The matcher is ours to set only on a group that holds nothing but our hook.
|
package/dist/init.js
CHANGED
|
@@ -135,7 +135,7 @@ export async function init(io, options) {
|
|
|
135
135
|
io.out(`Didn’t find ${namesOf([...APP_IDS], 'or')} on this computer’s PATH. Install one, then run: npx -y pendingyou@latest init${flag}\n`);
|
|
136
136
|
return 1;
|
|
137
137
|
}
|
|
138
|
-
const versions = found.map(({ app, detected }) => `${app.name} ${detected.version}`);
|
|
138
|
+
const versions = found.map(({ app, detected }) => `${app.name} ${detected.version}${detected.from ? ` (${detected.from})` : ''}`);
|
|
139
139
|
io.out(`Setting up Pending You (${options.origin}) for ${versions.length > 1 ? `${versions.slice(0, -1).join(', ')} and ${versions.at(-1)}` : versions[0]}.\n`);
|
|
140
140
|
for (const problem of problems)
|
|
141
141
|
io.out(`${problem}\n`);
|
|
@@ -145,6 +145,7 @@ export async function init(io, options) {
|
|
|
145
145
|
oauth: Boolean(options.oauth),
|
|
146
146
|
headless: headlessReason(io),
|
|
147
147
|
progress: (text) => io.out(text),
|
|
148
|
+
...(options.permissionCards === undefined ? {} : { permissionCards: options.permissionCards }),
|
|
148
149
|
};
|
|
149
150
|
// Everything decided before anyone signs in: Cancel then changes nothing.
|
|
150
151
|
const prepared = [];
|
package/dist/io.js
CHANGED
|
@@ -86,7 +86,7 @@ export function sleep(ms, signal) {
|
|
|
86
86
|
signal?.addEventListener('abort', done, { once: true });
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
|
-
function readStdin(timeoutMs) {
|
|
89
|
+
function readStdin(timeoutMs, maxChars = 1_000_000) {
|
|
90
90
|
if (process.stdin.isTTY)
|
|
91
91
|
return Promise.resolve('');
|
|
92
92
|
return new Promise((resolve) => {
|
|
@@ -100,7 +100,7 @@ function readStdin(timeoutMs) {
|
|
|
100
100
|
process.stdin.setEncoding('utf8');
|
|
101
101
|
process.stdin.on('data', (chunk) => {
|
|
102
102
|
text += chunk;
|
|
103
|
-
if (text.length >
|
|
103
|
+
if (text.length > maxChars)
|
|
104
104
|
finish();
|
|
105
105
|
});
|
|
106
106
|
process.stdin.on('end', finish);
|
package/dist/main.js
CHANGED
|
@@ -10,6 +10,7 @@ import { PlainError } from "./errors.js";
|
|
|
10
10
|
import { hold } from "./hold.js";
|
|
11
11
|
import { init, status, uninstall } from "./init.js";
|
|
12
12
|
import { OAuthError } from "./oauth.js";
|
|
13
|
+
import { permissionHook, permissionWorker } from "./permission.js";
|
|
13
14
|
import { pickup } from "./pickup.js";
|
|
14
15
|
import { mcpHeaders } from "./remote.js";
|
|
15
16
|
import { login, logout } from "./signin.js";
|
|
@@ -46,6 +47,19 @@ async function run(io, command, hook = { fallback: '' }) {
|
|
|
46
47
|
return stopcheck(io, { origin: command.origin, app: command.app ?? DEFAULT_APP });
|
|
47
48
|
case 'posted':
|
|
48
49
|
return posted(io, { origin: command.origin, app: command.app ?? DEFAULT_APP });
|
|
50
|
+
case 'permission':
|
|
51
|
+
case 'permission-done':
|
|
52
|
+
// Claude Code's only (0.13.0): a hook line with another app does nothing.
|
|
53
|
+
if ((command.app ?? DEFAULT_APP) !== DEFAULT_APP)
|
|
54
|
+
return 0;
|
|
55
|
+
return permissionHook(io, { origin: command.origin });
|
|
56
|
+
case 'permission-card':
|
|
57
|
+
// Detached and quiet, like refresh: nobody reads what it prints.
|
|
58
|
+
try {
|
|
59
|
+
await permissionWorker(io, command);
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
return 0;
|
|
49
63
|
case 'listen':
|
|
50
64
|
// Codex's is detached, started by `posted`: nobody reads what it prints. OpenCode's plugin and Pi's extension
|
|
51
65
|
// read their own child's.
|