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
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// Claude Code's permission prompts, on a card (0.13.0; Darin, 2026-10-04: "Tell me: a card and a push, cleared once I
|
|
2
|
+
// answer in the terminal"). A Claude Code agent kept asking to remove files and sat waiting until Darin noticed. Now,
|
|
3
|
+
// when Claude Code stops to ask for the person's OK and nobody answers within a few seconds, a card says so ("Claude Code
|
|
4
|
+
// is waiting for your OK: rm -rf dist/"), asked in both places (askedFirst), so it stays quiet for their hand-off
|
|
5
|
+
// minutes and pushes only once it has waited that long; and it goes away once they answer in Claude Code.
|
|
6
|
+
//
|
|
7
|
+
// - `pendingyou permission` is Claude Code's PermissionRequest hook (https://code.claude.com/docs/en/hooks: it runs
|
|
8
|
+
// "when Claude Code is about to ask you for permission to use a tool", with `tool_name` and `tool_input`; since
|
|
9
|
+
// 2.0.45). It never decides: it prints nothing, so the prompt shows exactly as before, and it's done in milliseconds.
|
|
10
|
+
// It writes the prompt down (a redacted title, never the tool's input: redact.ts) in the session's own file,
|
|
11
|
+
// ~/.config/pendingyou/permission-cards/<session>.json, and starts the session's worker unless one is running.
|
|
12
|
+
// - `pendingyou permission-done` is its PostToolUse, PostToolUseFailure and SessionEnd hook: the call it asked about ran
|
|
13
|
+
// (it was allowed), so that prompt is settled, or the session ended, so all of them are. The next message (handoff) and the end of a
|
|
14
|
+
// turn (stopcheck) settle the session's prompts too, since a denial runs no PostToolUse. The shim skips Node
|
|
15
|
+
// altogether while no session has a file here (shim.ts), so a tool call costs a few milliseconds.
|
|
16
|
+
// - `pendingyou permission-card --session <id> --worker <id>`, detached, is the session's one worker: it waits until a
|
|
17
|
+
// prompt has waited GRACE_MS (one answered sooner gets no card), posts the card through Pending You's MCP with Claude
|
|
18
|
+
// Code's own connection (whoami, match_area, post_request; create_area when nothing fits, as the guide says), changes
|
|
19
|
+
// it to the newest prompt (update_request with a changeNote: one card per session, never a second), withdraws it
|
|
20
|
+
// once nothing waits (cancel_request, answeredHere; without, when the session ended), and stops.
|
|
21
|
+
//
|
|
22
|
+
// No card for a session that can't show a prompt: `claude -p` and the Agent SDK (CLAUDE_CODE_ENTRYPOINT `sdk-…`, as
|
|
23
|
+
// Claude Code 2.1.289 sets it), or a session in dontAsk or bypassPermissions mode; nor without this computer's own
|
|
24
|
+
// connection for Claude Code (a sign-in that only hears can't post).
|
|
25
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
26
|
+
import { open, readdir, rm, stat } from 'node:fs/promises';
|
|
27
|
+
import { basename, join } from 'node:path';
|
|
28
|
+
import { DEFAULT_ORIGIN } from "./args.js";
|
|
29
|
+
import { McpFailure, mcpClient, SIGN_IN_ERROR, ToolRefused } from "./bridge.js";
|
|
30
|
+
import { sessionLink } from "./confirm.js";
|
|
31
|
+
import { connectionMachine, readCredential } from "./credentials.js";
|
|
32
|
+
import { configDir, PERMISSION_FOLDER, readJson, withLock, writeWhole } from "./files.js";
|
|
33
|
+
import { cut, redactCommand, redactText, redactUrl, shortPath } from "./redact.js";
|
|
34
|
+
import { machineOf } from "./remote.js";
|
|
35
|
+
/** How long a prompt waits before its card goes up: one answered sooner was answered by someone at the terminal. */
|
|
36
|
+
export const GRACE_MS = 10_000;
|
|
37
|
+
/** The longest a card's title is (the guide's "under 90 characters"). */
|
|
38
|
+
export const TITLE_MAX = 90;
|
|
39
|
+
/** What every card's title starts with. */
|
|
40
|
+
export const WAITING = 'Claude Code is waiting for your OK';
|
|
41
|
+
/**
|
|
42
|
+
* Whether a card is one of these (by its title): the hooks' own, which closes by itself, so the hand-off neither names
|
|
43
|
+
* it among the agent's open cards nor hands an agent a Done the person pressed on it.
|
|
44
|
+
*/
|
|
45
|
+
export const isPermissionCard = (title) => typeof title === 'string' &&
|
|
46
|
+
(title.startsWith(WAITING) || title.startsWith('Claude Code is waiting for your answer'));
|
|
47
|
+
/** The name a card is asked under when the session never told Pending You its own. */
|
|
48
|
+
export const DEFAULT_NAME = 'Claude Code';
|
|
49
|
+
/** A worker whose lease is older than this has died; the next event starts another. */
|
|
50
|
+
const LEASE_STALE_MS = 120_000;
|
|
51
|
+
/** How long a worker keeps going at most; the next event starts another. */
|
|
52
|
+
const WORKER_MS = 60 * 60_000;
|
|
53
|
+
/** The longest one sleep of the worker, so its lease stays fresh. */
|
|
54
|
+
const NAP_MS = 20_000;
|
|
55
|
+
/** How long one call to Pending You may take. */
|
|
56
|
+
const CALL_MS = 15_000;
|
|
57
|
+
/** Pauses after a failed call, then it gives that step up. */
|
|
58
|
+
const BACKOFF_MS = [2000, 5000, 10_000, 20_000];
|
|
59
|
+
/** The most prompts a session's file keeps (queued subagents' prompts): the newest. */
|
|
60
|
+
const MAX_PROMPTS = 20;
|
|
61
|
+
/** A session's file untouched this long is left from a session that never ended cleanly. */
|
|
62
|
+
const KEEP_MS = 7 * 24 * 60 * 60_000;
|
|
63
|
+
/** The most of a hook's stdin read: a Write's whole file can be in it. */
|
|
64
|
+
const STDIN_MAX = 8_000_000;
|
|
65
|
+
/** The most of a transcript read, from its end, for the session's name. */
|
|
66
|
+
const TRANSCRIPT_CAP = 4 * 1024 * 1024;
|
|
67
|
+
/** A session's id, as Claude Code gives it (a UUID): also its file's name. */
|
|
68
|
+
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
69
|
+
/** Pending You's tools that carry the asking session's name, under any server name (the plugin's too). */
|
|
70
|
+
const NAMED_TOOL = /^mcp__.*pendingyou.*__(whoami|post_request|update_request|cancel_request|reply_in_thread|get_request|ack_answer|list_pending|get_attachment)$/i;
|
|
71
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
72
|
+
const folderOf = (io) => join(configDir(io), PERMISSION_FOLDER);
|
|
73
|
+
const pathOf = (io, session) => join(folderOf(io), `${session}.json`);
|
|
74
|
+
/** JSON with its keys in order, so the same input always hashes the same. */
|
|
75
|
+
function canonical(value) {
|
|
76
|
+
if (Array.isArray(value))
|
|
77
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
78
|
+
if (isObject(value))
|
|
79
|
+
return `{${Object.keys(value)
|
|
80
|
+
.sort()
|
|
81
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
82
|
+
.join(',')}}`;
|
|
83
|
+
return JSON.stringify(value) ?? 'null';
|
|
84
|
+
}
|
|
85
|
+
/** Tools whose input comes back with the person's answer in it: their prompt is the tool's, whatever the input. */
|
|
86
|
+
const ANSWERED_IN_INPUT = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
|
87
|
+
/** Which call a prompt is about: PermissionRequest's tool and input, which PostToolUse repeats for the same call. */
|
|
88
|
+
export function promptKey(tool, input) {
|
|
89
|
+
const what = ANSWERED_IN_INPUT.has(tool) ? '' : canonical(input ?? {});
|
|
90
|
+
return createHash('sha256').update(`${tool}\n${what}`).digest('hex').slice(0, 32);
|
|
91
|
+
}
|
|
92
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
93
|
+
/** A tool's name as a card may say it: letters, digits and a few marks. */
|
|
94
|
+
const toolName = (tool) => cut(tool.replace(/[^\w.:-]/g, ''), 40) || 'a tool';
|
|
95
|
+
/**
|
|
96
|
+
* What a card says about a prompt: its title (WAITING and what it's for, at most TITLE_MAX characters), what it asks
|
|
97
|
+
* in short, and what Claude Code waits for. Every command, path, address and question goes through redact.ts.
|
|
98
|
+
*/
|
|
99
|
+
export function promptWords(tool, input, home) {
|
|
100
|
+
const path = (value) => shortPath(text(value), home, 50);
|
|
101
|
+
const said = (what, doing) => ({
|
|
102
|
+
title: cut(`${WAITING}: ${what}`, TITLE_MAX),
|
|
103
|
+
what,
|
|
104
|
+
doing,
|
|
105
|
+
});
|
|
106
|
+
switch (tool) {
|
|
107
|
+
case 'Bash':
|
|
108
|
+
case 'PowerShell':
|
|
109
|
+
return said(redactCommand(text(input.command), tool).text, 'to run a command');
|
|
110
|
+
case 'Edit':
|
|
111
|
+
case 'MultiEdit':
|
|
112
|
+
return said(`edit ${path(input.file_path)}`, 'to edit a file');
|
|
113
|
+
case 'Write':
|
|
114
|
+
return said(`write ${path(input.file_path)}`, 'to write a file');
|
|
115
|
+
case 'NotebookEdit':
|
|
116
|
+
return said(`edit ${path(input.notebook_path)}`, 'to edit a notebook');
|
|
117
|
+
case 'Read':
|
|
118
|
+
return said(`read ${path(input.file_path)}`, 'to read a file');
|
|
119
|
+
case 'Glob':
|
|
120
|
+
case 'Grep':
|
|
121
|
+
case 'LS':
|
|
122
|
+
return said(input.path ? `search ${path(input.path)}` : 'search files', 'to search files');
|
|
123
|
+
case 'WebFetch': {
|
|
124
|
+
const address = redactUrl(text(input.url).trim()).replace(/^https?:\/\//i, '');
|
|
125
|
+
return said(`fetch ${cut(redactText(address, 60), 60)}`, 'to fetch a page');
|
|
126
|
+
}
|
|
127
|
+
case 'WebSearch':
|
|
128
|
+
return said(`search the web for “${redactText(text(input.query), 40)}”`, 'to search the web');
|
|
129
|
+
case 'ExitPlanMode':
|
|
130
|
+
return { title: `${WAITING} on its plan`, what: 'its plan', doing: 'on its plan' };
|
|
131
|
+
case 'AskUserQuestion': {
|
|
132
|
+
const first = Array.isArray(input.questions) ? input.questions[0] : undefined;
|
|
133
|
+
const question = redactText(text(isObject(first) ? first.question : ''), 80) || 'a question';
|
|
134
|
+
return {
|
|
135
|
+
title: cut(`Claude Code is waiting for your answer: ${question}`, TITLE_MAX),
|
|
136
|
+
what: question,
|
|
137
|
+
doing: 'to answer its question',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
case 'Skill':
|
|
141
|
+
return said(`use the ${toolName(text(input.skill))} skill`, 'to use a skill');
|
|
142
|
+
case 'Task':
|
|
143
|
+
case 'Agent':
|
|
144
|
+
return said('start an agent', 'to start an agent');
|
|
145
|
+
}
|
|
146
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
147
|
+
if (mcp) {
|
|
148
|
+
const [, server = '', name = ''] = mcp;
|
|
149
|
+
return said(`${toolName(name)} on ${toolName(server)}`, `to use ${toolName(server)}’s ${toolName(name)}`);
|
|
150
|
+
}
|
|
151
|
+
return said(toolName(tool), `to use ${toolName(tool)}`);
|
|
152
|
+
}
|
|
153
|
+
async function load(io, session) {
|
|
154
|
+
const file = await readJson(pathOf(io, session)).catch(() => null);
|
|
155
|
+
if (!isObject(file) || !Array.isArray(file.prompts) || typeof file.origin !== 'string')
|
|
156
|
+
return null;
|
|
157
|
+
return file;
|
|
158
|
+
}
|
|
159
|
+
/** Changes a session's file under its lock: `edit` returns the new state, or null to remove the file. */
|
|
160
|
+
async function change(io, session, edit) {
|
|
161
|
+
const path = pathOf(io, session);
|
|
162
|
+
return withLock(path, io, async () => {
|
|
163
|
+
const next = edit(await load(io, session));
|
|
164
|
+
if (next)
|
|
165
|
+
await writeWhole(path, `${JSON.stringify(next, null, 2)}\n`, { secret: true });
|
|
166
|
+
else
|
|
167
|
+
await rm(path, { force: true });
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const alive = (state, now) => Boolean(state.worker && now - state.worker.at < LEASE_STALE_MS);
|
|
172
|
+
/** Hands the session to a new worker unless one is alive: its id, to start it with, or null. */
|
|
173
|
+
function claim(state, now) {
|
|
174
|
+
if (alive(state, now))
|
|
175
|
+
return null;
|
|
176
|
+
const id = randomBytes(8).toString('hex');
|
|
177
|
+
state.worker = { id, at: now };
|
|
178
|
+
return id;
|
|
179
|
+
}
|
|
180
|
+
/** Starts the session's worker, detached: it outlives the hook, prints nowhere, and nobody waits for it. */
|
|
181
|
+
function startWorker(io, origin, session, worker) {
|
|
182
|
+
io.background([
|
|
183
|
+
'permission-card',
|
|
184
|
+
'--session',
|
|
185
|
+
session,
|
|
186
|
+
'--worker',
|
|
187
|
+
worker,
|
|
188
|
+
...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
const sessionOf = (input) => {
|
|
192
|
+
const id = text(input.session_id);
|
|
193
|
+
return SESSION.test(id) ? id : null;
|
|
194
|
+
};
|
|
195
|
+
/** PermissionRequest: writes the prompt down and makes sure the session's worker is going. */
|
|
196
|
+
async function asked(io, origin, input) {
|
|
197
|
+
if (io.platform === 'win32')
|
|
198
|
+
return;
|
|
199
|
+
// `claude -p` and the Agent SDK: nobody sees a prompt, and Claude Code denies the call itself.
|
|
200
|
+
if (/^sdk-/.test(io.env.CLAUDE_CODE_ENTRYPOINT ?? ''))
|
|
201
|
+
return;
|
|
202
|
+
if (input.permission_mode === 'dontAsk' || input.permission_mode === 'bypassPermissions')
|
|
203
|
+
return;
|
|
204
|
+
const session = sessionOf(input);
|
|
205
|
+
const tool = text(input.tool_name);
|
|
206
|
+
if (!session || !tool)
|
|
207
|
+
return;
|
|
208
|
+
// Only this computer's own connection for Claude Code can post a card; a sign-in that only hears can't.
|
|
209
|
+
if ((await readCredential(io, origin))?.kind !== 'connection')
|
|
210
|
+
return;
|
|
211
|
+
const now = io.now();
|
|
212
|
+
const agent = text(input.agent_id);
|
|
213
|
+
const prompt = {
|
|
214
|
+
key: promptKey(tool, input.tool_input),
|
|
215
|
+
...promptWords(tool, isObject(input.tool_input) ? input.tool_input : {}, io.home),
|
|
216
|
+
...(agent ? { agent: agent.slice(0, 100) } : {}),
|
|
217
|
+
at: now,
|
|
218
|
+
};
|
|
219
|
+
const cwd = text(input.cwd) || io.cwd;
|
|
220
|
+
const transcript = text(input.transcript_path);
|
|
221
|
+
const link = sessionLink(io.env);
|
|
222
|
+
let worker = null;
|
|
223
|
+
await change(io, session, (state) => {
|
|
224
|
+
const next = state ?? { version: 1, origin, session, cwd, prompts: [], at: now };
|
|
225
|
+
next.origin = origin;
|
|
226
|
+
next.cwd = cwd;
|
|
227
|
+
if (transcript)
|
|
228
|
+
next.transcript = transcript;
|
|
229
|
+
if (link)
|
|
230
|
+
next.link = link;
|
|
231
|
+
// The same call asked again (the hook run twice) is still the one prompt.
|
|
232
|
+
if (!next.prompts.some((each) => each.key === prompt.key))
|
|
233
|
+
next.prompts.push(prompt);
|
|
234
|
+
next.prompts = next.prompts.slice(-MAX_PROMPTS);
|
|
235
|
+
delete next.settled;
|
|
236
|
+
next.at = now;
|
|
237
|
+
worker = claim(next, now);
|
|
238
|
+
return next;
|
|
239
|
+
});
|
|
240
|
+
if (worker)
|
|
241
|
+
startWorker(io, origin, session, worker);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Settles the session's prompts that `which` picks: answered in Claude Code, or the session ended. Starts the worker
|
|
245
|
+
* when there's a card to change or withdraw; a file with nothing left in it goes (unless a worker is busy with it).
|
|
246
|
+
*/
|
|
247
|
+
async function settle(io, session, which, how) {
|
|
248
|
+
const before = await load(io, session);
|
|
249
|
+
if (!before?.prompts.some(which))
|
|
250
|
+
return;
|
|
251
|
+
const now = io.now();
|
|
252
|
+
let worker = null;
|
|
253
|
+
let origin = before.origin;
|
|
254
|
+
await change(io, session, (state) => {
|
|
255
|
+
if (!state)
|
|
256
|
+
return null;
|
|
257
|
+
origin = state.origin;
|
|
258
|
+
const left = state.prompts.filter((prompt) => !which(prompt));
|
|
259
|
+
if (left.length === state.prompts.length)
|
|
260
|
+
return state;
|
|
261
|
+
state.prompts = left;
|
|
262
|
+
state.settled = how;
|
|
263
|
+
state.at = now;
|
|
264
|
+
if (!state.card && left.length === 0)
|
|
265
|
+
return alive(state, now) ? state : null;
|
|
266
|
+
worker = claim(state, now);
|
|
267
|
+
return state;
|
|
268
|
+
});
|
|
269
|
+
if (worker)
|
|
270
|
+
startWorker(io, origin, session, worker);
|
|
271
|
+
}
|
|
272
|
+
/** PostToolUse (and PostToolUseFailure): the call ran, so its prompt was answered. Quick when there's none. */
|
|
273
|
+
async function ran(io, input) {
|
|
274
|
+
const session = sessionOf(input);
|
|
275
|
+
const tool = text(input.tool_name);
|
|
276
|
+
if (!session || !tool)
|
|
277
|
+
return;
|
|
278
|
+
const key = promptKey(tool, input.tool_input);
|
|
279
|
+
await settle(io, session, (prompt) => prompt.key === key, 'answered');
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The person's next message (handoff) or the end of a turn (stopcheck): the session's own prompts are settled (a
|
|
283
|
+
* denied call runs no PostToolUse). A subagent's are too at the end of a turn, unless a subagent is still running in
|
|
284
|
+
* the background (the Stop hook's `background_tasks`), which may still be waiting on one. Quick when there's none.
|
|
285
|
+
*/
|
|
286
|
+
export async function settlePrompts(io, input) {
|
|
287
|
+
try {
|
|
288
|
+
if (!input.session || !SESSION.test(input.session))
|
|
289
|
+
return;
|
|
290
|
+
const subagentsDone = input.event === 'stop' && !input.subagents;
|
|
291
|
+
await settle(io, input.session, (prompt) => !prompt.agent || subagentsDone, 'answered');
|
|
292
|
+
}
|
|
293
|
+
catch { }
|
|
294
|
+
}
|
|
295
|
+
/** Whether the Stop hook's input says a subagent is still running in the background (its `background_tasks`). */
|
|
296
|
+
export function subagentsRunning(input) {
|
|
297
|
+
if (!isObject(input) || !Array.isArray(input.background_tasks))
|
|
298
|
+
return false;
|
|
299
|
+
return input.background_tasks.some((task) => isObject(task) &&
|
|
300
|
+
task.type === 'subagent' &&
|
|
301
|
+
!['completed', 'failed', 'killed', 'cancelled', 'stopped'].includes(text(task.status)));
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* `pendingyou permission` and `permission-done`: Claude Code's PermissionRequest, PostToolUse and SessionEnd hooks.
|
|
305
|
+
* Prints nothing, ever (a PermissionRequest hook's output could decide the prompt), and exits 0.
|
|
306
|
+
*/
|
|
307
|
+
export async function permissionHook(io, options) {
|
|
308
|
+
try {
|
|
309
|
+
const input = JSON.parse(await io.readStdin(1000, STDIN_MAX));
|
|
310
|
+
if (!isObject(input))
|
|
311
|
+
return 0;
|
|
312
|
+
switch (input.hook_event_name) {
|
|
313
|
+
case 'PermissionRequest':
|
|
314
|
+
await asked(io, options.origin, input);
|
|
315
|
+
break;
|
|
316
|
+
case 'PostToolUse':
|
|
317
|
+
case 'PostToolUseFailure':
|
|
318
|
+
await ran(io, input);
|
|
319
|
+
break;
|
|
320
|
+
case 'SessionEnd': {
|
|
321
|
+
const session = sessionOf(input);
|
|
322
|
+
if (session)
|
|
323
|
+
await settle(io, session, () => true, 'ended');
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch { }
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The next step: withdraw the card once nothing waits; post one once the oldest prompt has waited `grace`, showing the
|
|
333
|
+
* newest; change it to the newest once that one has waited `grace` too; otherwise wait, or nothing (in step).
|
|
334
|
+
*/
|
|
335
|
+
export function planFor(state, now, grace) {
|
|
336
|
+
if (state.prompts.length === 0)
|
|
337
|
+
return state.card ? { kind: 'cancel' } : { kind: 'done' };
|
|
338
|
+
const newest = state.prompts.at(-1);
|
|
339
|
+
if (state.card) {
|
|
340
|
+
if (state.card.key === newest.key)
|
|
341
|
+
return { kind: 'done' };
|
|
342
|
+
const due = newest.at + grace - now;
|
|
343
|
+
return due > 0 ? { kind: 'wait', ms: due } : { kind: 'update', prompt: newest };
|
|
344
|
+
}
|
|
345
|
+
const live = state.prompts.filter((prompt) => !prompt.failed);
|
|
346
|
+
const oldest = live[0];
|
|
347
|
+
if (!oldest)
|
|
348
|
+
return { kind: 'done' };
|
|
349
|
+
const due = oldest.at + grace - now;
|
|
350
|
+
return due > 0 ? { kind: 'wait', ms: due } : { kind: 'post', prompt: live.at(-1) };
|
|
351
|
+
}
|
|
352
|
+
/** How long a prompt waits for its card: GRACE_MS, or PENDINGYOU_PERMISSION_GRACE_MS (for tests). */
|
|
353
|
+
function graceOf(env) {
|
|
354
|
+
const given = Number(env.PENDINGYOU_PERMISSION_GRACE_MS);
|
|
355
|
+
return Number.isInteger(given) && given >= 0 ? given : GRACE_MS;
|
|
356
|
+
}
|
|
357
|
+
/** The computer as Pending You names it ("Claude Code on Darin’s MacBook Pro"). */
|
|
358
|
+
async function computerOf(io, origin) {
|
|
359
|
+
const named = await connectionMachine(io, origin).catch(() => null);
|
|
360
|
+
return cut(named ?? (machineOf(io.host) || 'this computer'), 60);
|
|
361
|
+
}
|
|
362
|
+
/** The folder as the person reads it: under ~. */
|
|
363
|
+
const tilde = (io, path) => shortPath(path, io.home, 200);
|
|
364
|
+
/**
|
|
365
|
+
* The name the session gave Pending You, as its transcript says: the latest of its Pending You calls with a `name`
|
|
366
|
+
* (whoami's, a card tool's) or post_request's `session.label`. Null when it never gave one (or it can't be read).
|
|
367
|
+
*/
|
|
368
|
+
export async function sessionName(path) {
|
|
369
|
+
if (!path)
|
|
370
|
+
return null;
|
|
371
|
+
let file;
|
|
372
|
+
try {
|
|
373
|
+
file = await open(path, 'r');
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const size = (await file.stat()).size;
|
|
380
|
+
let position = size;
|
|
381
|
+
let carry = Buffer.alloc(0);
|
|
382
|
+
const nameIn = (line) => {
|
|
383
|
+
if (!line.includes('pendingyou'))
|
|
384
|
+
return null;
|
|
385
|
+
let entry;
|
|
386
|
+
try {
|
|
387
|
+
entry = JSON.parse(line);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
if (!isObject(entry) || entry.type !== 'assistant' || entry.isSidechain === true)
|
|
393
|
+
return null;
|
|
394
|
+
const message = isObject(entry.message) ? entry.message : {};
|
|
395
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
396
|
+
for (const block of [...blocks].reverse()) {
|
|
397
|
+
if (!isObject(block) || block.type !== 'tool_use' || !NAMED_TOOL.test(text(block.name)))
|
|
398
|
+
continue;
|
|
399
|
+
const args = isObject(block.input) ? block.input : {};
|
|
400
|
+
const session = isObject(args.session) ? args.session : {};
|
|
401
|
+
const name = (text(args.name) || text(session.label)).trim();
|
|
402
|
+
if (name && [...name].length <= 40)
|
|
403
|
+
return name;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
};
|
|
407
|
+
while (position > 0 && size - position < TRANSCRIPT_CAP) {
|
|
408
|
+
const length = Math.min(64 * 1024, position);
|
|
409
|
+
position -= length;
|
|
410
|
+
const chunk = Buffer.alloc(length);
|
|
411
|
+
await file.read(chunk, 0, length, position);
|
|
412
|
+
let buffer = Buffer.concat([chunk, carry]);
|
|
413
|
+
let newline = buffer.lastIndexOf(10);
|
|
414
|
+
while (newline !== -1) {
|
|
415
|
+
const found = nameIn(buffer.subarray(newline + 1).toString('utf8'));
|
|
416
|
+
if (found)
|
|
417
|
+
return found;
|
|
418
|
+
buffer = buffer.subarray(0, newline);
|
|
419
|
+
newline = buffer.lastIndexOf(10);
|
|
420
|
+
}
|
|
421
|
+
carry = buffer;
|
|
422
|
+
}
|
|
423
|
+
return position === 0 ? nameIn(carry.toString('utf8')) : null;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
await file.close().catch(() => { });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** The folder's git remote as match_area takes it (github.com/org/repo): never a user name, password or port. */
|
|
433
|
+
export function remoteOf(url) {
|
|
434
|
+
const plain = url
|
|
435
|
+
.trim()
|
|
436
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
437
|
+
.replace(/^[^@/]+@/, '')
|
|
438
|
+
.replace(/^([^/:]+):\d+\//, '$1/')
|
|
439
|
+
.replace(/^([^/:]+):/, '$1/')
|
|
440
|
+
.replace(/\/{2,}/g, '/')
|
|
441
|
+
.replace(/\/+$/, '')
|
|
442
|
+
.replace(/\.git$/, '');
|
|
443
|
+
return /^[\w.-]+\/[\w./-]+$/.test(plain) ? plain : null;
|
|
444
|
+
}
|
|
445
|
+
async function gitRemote(io, cwd) {
|
|
446
|
+
const result = await io.run('git', ['-C', cwd, 'remote', 'get-url', 'origin'], 3000);
|
|
447
|
+
return result.code === 0 ? remoteOf(result.stdout.split('\n')[0] ?? '') : null;
|
|
448
|
+
}
|
|
449
|
+
const idOf = (result, field, prefix) => {
|
|
450
|
+
const id = result[field];
|
|
451
|
+
return typeof id === 'string' && id.startsWith(prefix) ? id : null;
|
|
452
|
+
};
|
|
453
|
+
/** The area the session's cards go in: match_area's pick, or one made for the work when nothing fits (the guide). */
|
|
454
|
+
async function areaFor(io, client, state, name) {
|
|
455
|
+
const remote = await gitRemote(io, state.cwd);
|
|
456
|
+
const match = await client.call('match_area', {
|
|
457
|
+
cwd: cut(state.cwd, 300),
|
|
458
|
+
...(remote ? { remote: cut(remote, 300) } : {}),
|
|
459
|
+
...(name !== DEFAULT_NAME ? { task: cut(name, 60) } : {}),
|
|
460
|
+
});
|
|
461
|
+
const suggestions = Array.isArray(match.suggestions) ? match.suggestions.filter(isObject) : [];
|
|
462
|
+
const top = suggestions[0] ? idOf(suggestions[0], 'areaId', 'prj_') : null;
|
|
463
|
+
if (match.decision !== 'create' && top)
|
|
464
|
+
return top;
|
|
465
|
+
const group = isObject(match.proposedGroup) ? idOf(match.proposedGroup, 'id', 'agr_') : null;
|
|
466
|
+
const proposed = text(match.proposedName).trim();
|
|
467
|
+
const created = await client.call('create_area', {
|
|
468
|
+
idempotencyKey: `claude-code-area:${createHash('sha256').update(state.cwd).digest('hex').slice(0, 32)}`,
|
|
469
|
+
name: cut(proposed || basename(state.cwd) || 'Inbox', 40),
|
|
470
|
+
description: cut(`Work in ${remote ?? tilde(io, state.cwd)}`, 160),
|
|
471
|
+
tint: text(match.proposedTint) || 'sky',
|
|
472
|
+
...(group ? { group } : {}),
|
|
473
|
+
repo: remote ? { remote } : { path: state.cwd },
|
|
474
|
+
});
|
|
475
|
+
const areaId = idOf(created, 'areaId', 'prj_');
|
|
476
|
+
if (!areaId)
|
|
477
|
+
throw new McpFailure(-32000, 'Pending You made no area.');
|
|
478
|
+
return areaId;
|
|
479
|
+
}
|
|
480
|
+
/** The card's summary and its one step: the folder and the computer, and where to answer. */
|
|
481
|
+
function cardWords(io, state, prompt, name, machine) {
|
|
482
|
+
const who = name === DEFAULT_NAME ? 'Claude Code' : `Claude Code (${name})`;
|
|
483
|
+
const waiting = prompt.doing === 'to answer its question'
|
|
484
|
+
? 'is waiting for your answer to its question'
|
|
485
|
+
: `is waiting for your OK ${prompt.doing}`;
|
|
486
|
+
const summary = cut(`${who} in ${tilde(io, state.cwd)} on ${machine} ${waiting}. It goes on once you answer it there.`, 400);
|
|
487
|
+
const place = `Claude Code on ${machine}`;
|
|
488
|
+
const linked = state.link ? `${place}, ${state.link}` : place;
|
|
489
|
+
return {
|
|
490
|
+
summary,
|
|
491
|
+
action: {
|
|
492
|
+
where: linked.length <= 120 ? linked : cut(place, 120),
|
|
493
|
+
steps: [
|
|
494
|
+
{
|
|
495
|
+
text: state.link
|
|
496
|
+
? `Answer it in ${place}, or open the session’s link and answer it there.`
|
|
497
|
+
: `Answer it in ${place}.`,
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
done: 'This card closes by itself once you answer there.',
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/** Records what the worker did, while the session is still its own; false when it isn't any more. */
|
|
505
|
+
async function record(io, session, worker, edit) {
|
|
506
|
+
let ours = false;
|
|
507
|
+
await change(io, session, (state) => {
|
|
508
|
+
if (!state || state.worker?.id !== worker)
|
|
509
|
+
return state;
|
|
510
|
+
ours = true;
|
|
511
|
+
edit(state);
|
|
512
|
+
state.worker = { id: worker, at: io.now() };
|
|
513
|
+
return state;
|
|
514
|
+
});
|
|
515
|
+
return ours;
|
|
516
|
+
}
|
|
517
|
+
/** Posts the session's card for its newest prompt. A card it can't record (the session moved on) is withdrawn. */
|
|
518
|
+
async function post(io, client, state, prompt, worker) {
|
|
519
|
+
const name = (await sessionName(state.transcript)) ?? DEFAULT_NAME;
|
|
520
|
+
await client.call('whoami', { name });
|
|
521
|
+
const areaId = state.areaId ?? (await areaFor(io, client, state, name));
|
|
522
|
+
const machine = await computerOf(io, state.origin);
|
|
523
|
+
const result = await client.call('post_request', {
|
|
524
|
+
idempotencyKey: `claude-code-permission:${state.session}:${prompt.key}:${prompt.at}`,
|
|
525
|
+
areaId,
|
|
526
|
+
session: { label: name, machine, cwd: cut(tilde(io, state.cwd), 300) },
|
|
527
|
+
kind: 'action',
|
|
528
|
+
title: prompt.title,
|
|
529
|
+
...cardWords(io, state, prompt, name, machine),
|
|
530
|
+
blocking: false,
|
|
531
|
+
urgency: 'now',
|
|
532
|
+
askedFirst: { where: 'terminal', askedAt: new Date(prompt.at).toISOString() },
|
|
533
|
+
});
|
|
534
|
+
const requestId = idOf(result, 'requestId', 'req_');
|
|
535
|
+
const version = typeof result.version === 'number' ? result.version : 1;
|
|
536
|
+
if (!requestId)
|
|
537
|
+
throw new McpFailure(-32000, 'Pending You posted no card.');
|
|
538
|
+
const card = { requestId, version, key: prompt.key, name };
|
|
539
|
+
const kept = await record(io, state.session, worker, (state) => {
|
|
540
|
+
state.card = card;
|
|
541
|
+
state.areaId = areaId;
|
|
542
|
+
});
|
|
543
|
+
if (!kept)
|
|
544
|
+
await withdraw(io, client, card, state, 'answered').catch(() => { });
|
|
545
|
+
}
|
|
546
|
+
/** Whether the card is still in front of the person (get_request), with its version; null when it's closed. */
|
|
547
|
+
async function stillOpen(client, card) {
|
|
548
|
+
const now = await client.call('get_request', { requestId: card.requestId, name: card.name });
|
|
549
|
+
const open = ['pending', 'snoozed', 'delegated'].includes(text(now.status)) && now.turn !== 'agent';
|
|
550
|
+
return open && typeof now.version === 'number' ? now.version : null;
|
|
551
|
+
}
|
|
552
|
+
/** Changes the card to the newest prompt; a card that closed meanwhile is let go, and the next step posts anew. */
|
|
553
|
+
async function update(io, client, state, prompt, worker) {
|
|
554
|
+
const card = state.card;
|
|
555
|
+
const machine = await computerOf(io, state.origin);
|
|
556
|
+
const words = cardWords(io, state, prompt, card.name, machine);
|
|
557
|
+
const change = (expectedVersion) => client.call('update_request', {
|
|
558
|
+
requestId: card.requestId,
|
|
559
|
+
name: card.name,
|
|
560
|
+
expectedVersion,
|
|
561
|
+
changeNote: cut(prompt.doing === 'to answer its question'
|
|
562
|
+
? `Now waiting for your answer: ${prompt.what}`
|
|
563
|
+
: `Now waiting for your OK: ${prompt.what}`, 120),
|
|
564
|
+
title: prompt.title,
|
|
565
|
+
summary: words.summary,
|
|
566
|
+
action: words.action,
|
|
567
|
+
});
|
|
568
|
+
let result;
|
|
569
|
+
try {
|
|
570
|
+
result = await change(card.version);
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
if (!(error instanceof ToolRefused))
|
|
574
|
+
throw error;
|
|
575
|
+
const version = await stillOpen(client, card);
|
|
576
|
+
if (version === null) {
|
|
577
|
+
await record(io, state.session, worker, (state) => {
|
|
578
|
+
if (state.card?.requestId === card.requestId)
|
|
579
|
+
delete state.card;
|
|
580
|
+
});
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
result = await change(version);
|
|
584
|
+
}
|
|
585
|
+
const version = typeof result.version === 'number' ? result.version : card.version + 1;
|
|
586
|
+
await record(io, state.session, worker, (state) => {
|
|
587
|
+
if (state.card?.requestId === card.requestId)
|
|
588
|
+
state.card = { ...card, version, key: prompt.key };
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/** Withdraws a card: answered in Claude Code (answeredHere), or the session ended before anyone answered. */
|
|
592
|
+
async function withdraw(io, client, card, state, how) {
|
|
593
|
+
const machine = await computerOf(io, state.origin);
|
|
594
|
+
try {
|
|
595
|
+
await client.call('cancel_request', {
|
|
596
|
+
requestId: card.requestId,
|
|
597
|
+
name: card.name,
|
|
598
|
+
reason: how === 'answered'
|
|
599
|
+
? cut(`Answered in Claude Code on ${machine}.`, 200)
|
|
600
|
+
: cut(`That Claude Code session on ${machine} ended, so it isn’t waiting any more.`, 200),
|
|
601
|
+
...(how === 'answered' ? { answeredHere: true } : {}),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (!(error instanceof ToolRefused))
|
|
606
|
+
throw error;
|
|
607
|
+
// Closed already. One they pressed Done on waits for its agent to acknowledge it: done here, so nobody is told.
|
|
608
|
+
const now = await client
|
|
609
|
+
.call('get_request', { requestId: card.requestId, name: card.name })
|
|
610
|
+
.catch(() => null);
|
|
611
|
+
if (now?.status === 'answered' && typeof now.version === 'number')
|
|
612
|
+
await client
|
|
613
|
+
.call('ack_answer', {
|
|
614
|
+
requestId: card.requestId,
|
|
615
|
+
name: card.name,
|
|
616
|
+
expectedVersion: now.version,
|
|
617
|
+
outcome: cut(`Answered in Claude Code on ${machine}.`, 200),
|
|
618
|
+
})
|
|
619
|
+
.catch(() => { });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** Renews the worker's lease; false once the session isn't its own. */
|
|
623
|
+
const renew = (io, session, worker) => record(io, session, worker, () => { });
|
|
624
|
+
/**
|
|
625
|
+
* Lets the session go once nothing is left to do, under its lock, so an event written meanwhile is never missed: true
|
|
626
|
+
* when it's let go (and the file removed when nothing is in it), false when there's more to do.
|
|
627
|
+
*/
|
|
628
|
+
async function letGo(io, session, worker, grace) {
|
|
629
|
+
let done = true;
|
|
630
|
+
await change(io, session, (state) => {
|
|
631
|
+
if (!state || state.worker?.id !== worker)
|
|
632
|
+
return state;
|
|
633
|
+
if (planFor(state, io.now(), grace).kind !== 'done') {
|
|
634
|
+
done = false;
|
|
635
|
+
return state;
|
|
636
|
+
}
|
|
637
|
+
delete state.worker;
|
|
638
|
+
return state.prompts.length === 0 && !state.card ? null : state;
|
|
639
|
+
});
|
|
640
|
+
return done;
|
|
641
|
+
}
|
|
642
|
+
/** Removes files left by sessions that never ended cleanly. */
|
|
643
|
+
async function prune(io) {
|
|
644
|
+
const folder = folderOf(io);
|
|
645
|
+
const names = await readdir(folder).catch(() => []);
|
|
646
|
+
for (const name of names) {
|
|
647
|
+
if (!name.endsWith('.json'))
|
|
648
|
+
continue;
|
|
649
|
+
const path = join(folder, name);
|
|
650
|
+
const info = await stat(path).catch(() => null);
|
|
651
|
+
if (info && Date.now() - info.mtimeMs > KEEP_MS)
|
|
652
|
+
await rm(path, { force: true });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* `pendingyou permission-card --session <id> --worker <id>`: the session's worker, detached and quiet. Posts, changes
|
|
657
|
+
* and withdraws the session's one card until nothing is left to do, then lets the session go. A step that keeps
|
|
658
|
+
* failing is given up (a post: its prompts get no card; a withdrawal: the card is forgotten); a sign-in that has ended
|
|
659
|
+
* gives everything up.
|
|
660
|
+
*/
|
|
661
|
+
export async function permissionWorker(io, options) {
|
|
662
|
+
const { session, worker } = options;
|
|
663
|
+
if (!SESSION.test(session))
|
|
664
|
+
return 0;
|
|
665
|
+
const started = io.now();
|
|
666
|
+
const grace = graceOf(io.env);
|
|
667
|
+
let client = null;
|
|
668
|
+
let failures = 0;
|
|
669
|
+
try {
|
|
670
|
+
while (!io.signal.aborted && io.now() - started < WORKER_MS) {
|
|
671
|
+
const state = await load(io, session);
|
|
672
|
+
if (!state || state.worker?.id !== worker)
|
|
673
|
+
return 0;
|
|
674
|
+
const plan = planFor(state, io.now(), grace);
|
|
675
|
+
if (plan.kind === 'done') {
|
|
676
|
+
if (await letGo(io, session, worker, grace)) {
|
|
677
|
+
await prune(io).catch(() => { });
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (!(await renew(io, session, worker)))
|
|
683
|
+
return 0;
|
|
684
|
+
if (plan.kind === 'wait') {
|
|
685
|
+
await io.sleep(Math.min(plan.ms, NAP_MS), io.signal);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
client ??= await mcpClient(io, state.origin, 'claude-code', CALL_MS);
|
|
690
|
+
if (plan.kind === 'post')
|
|
691
|
+
await post(io, client, state, plan.prompt, worker);
|
|
692
|
+
else if (plan.kind === 'update')
|
|
693
|
+
await update(io, client, state, plan.prompt, worker);
|
|
694
|
+
else {
|
|
695
|
+
const card = state.card;
|
|
696
|
+
await withdraw(io, client, card, state, state.settled ?? 'answered');
|
|
697
|
+
await record(io, session, worker, (state) => {
|
|
698
|
+
if (state.card?.requestId === card.requestId)
|
|
699
|
+
delete state.card;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
failures = 0;
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
client = null;
|
|
706
|
+
// The sign-in has ended: nothing here can reach Pending You until init runs again.
|
|
707
|
+
if (error instanceof McpFailure && error.code === SIGN_IN_ERROR) {
|
|
708
|
+
await change(io, session, (state) => (state?.worker?.id === worker ? null : state));
|
|
709
|
+
return 0;
|
|
710
|
+
}
|
|
711
|
+
if (failures >= BACKOFF_MS.length) {
|
|
712
|
+
failures = 0;
|
|
713
|
+
await record(io, session, worker, (state) => {
|
|
714
|
+
if (plan.kind === 'post')
|
|
715
|
+
state.prompts = state.prompts.map((prompt) => ({ ...prompt, failed: true }));
|
|
716
|
+
else if (plan.kind === 'update' && state.card)
|
|
717
|
+
state.card = { ...state.card, key: plan.prompt.key };
|
|
718
|
+
else
|
|
719
|
+
delete state.card;
|
|
720
|
+
});
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
await io.sleep(BACKOFF_MS[failures++], io.signal);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
await client?.close().catch(() => { });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** What uninstall says it took away, when there was anything. */
|
|
733
|
+
export async function removePermissionFiles(io) {
|
|
734
|
+
const folder = folderOf(io);
|
|
735
|
+
const there = await stat(folder).then(() => true, () => false);
|
|
736
|
+
await rm(folder, { recursive: true, force: true });
|
|
737
|
+
return there;
|
|
738
|
+
}
|