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/dist/pickup.js CHANGED
@@ -31,6 +31,7 @@ import { environmentWarning } from "./environment.js";
31
31
  import { configDir, readJson } from "./files.js";
32
32
  import { clip, describe } from "./format.js";
33
33
  import { holdCommand } from "./hold.js";
34
+ import { isPermissionCard, settlePrompts } from "./permission.js";
34
35
  import { setupReminder } from "./setup.js";
35
36
  import { markHanded, momentOf, wasHanded } from "./state.js";
36
37
  /** Each request's own limit, inside the hook's 3-second deadline (main.ts). */
@@ -52,14 +53,16 @@ export function openLine(open) {
52
53
  const { requests, total } = open;
53
54
  if (!Array.isArray(requests))
54
55
  return REMINDER;
55
- const cards = requests
56
- .filter((card) => typeof card?.requestId === 'string' &&
56
+ const valid = requests.filter((card) => typeof card?.requestId === 'string' &&
57
57
  /^req_[A-Za-z0-9-]{1,40}$/.test(card.requestId) &&
58
- typeof card.title === 'string')
59
- .slice(0, OPEN_SHOWN);
58
+ typeof card.title === 'string');
59
+ // A permission prompt's card is the hooks' own, and closes by itself (0.13.0, permission.ts): not the agent's to settle.
60
+ const theirs = valid.filter((card) => !isPermissionCard(card.title));
61
+ const cards = theirs.slice(0, OPEN_SHOWN);
60
62
  if (cards.length === 0)
61
63
  return REMINDER;
62
- const more = typeof total === 'number' && total > cards.length ? total - cards.length : 0;
64
+ const waiting = typeof total === 'number' ? total - (valid.length - theirs.length) : 0;
65
+ const more = waiting > cards.length ? waiting - cards.length : 0;
63
66
  const named = cards
64
67
  .map((card) => {
65
68
  const by = typeof card.name === 'string' && card.name.trim()
@@ -111,6 +114,9 @@ export async function pickup(io, options, hook = { fallback: '' }) {
111
114
  try {
112
115
  const input = await hookInput(io);
113
116
  const { cwd } = input;
117
+ // The person wrote: whatever Claude Code asked their OK for there is answered (0.13.0, permission.ts).
118
+ if (mode === 'handoff' && app === DEFAULT_APP)
119
+ await settlePrompts(io, { session: input.session, event: 'prompt' });
114
120
  warning = await environmentWarning(io, cwd, origin, app);
115
121
  if (warning)
116
122
  hook.fallback = `${warning}\n`;
@@ -152,7 +158,8 @@ export async function pickup(io, options, hook = { fallback: '' }) {
152
158
  return 0;
153
159
  const fresh = [];
154
160
  for (const heard of body.requests)
155
- if (!(await wasHanded(io, origin, heard.requestId, momentOf(heard))))
161
+ if (!isPermissionCard(heard.title) &&
162
+ !(await wasHanded(io, origin, heard.requestId, momentOf(heard))))
156
163
  fresh.push(heard);
157
164
  if (fresh.length === 0)
158
165
  return 0;
package/dist/redact.js ADDED
@@ -0,0 +1,369 @@
1
+ // What a card may say about a command, a file or an address (0.13.0, the permission-prompt cards: permission.ts): what
2
+ // Claude Code is waiting for the person's OK on, in a line, with anything that could be a secret taken out. A card's
3
+ // words sit in notifications, on every device it's opened on and in history, so this errs on the side of saying less:
4
+ //
5
+ // - the values after anything named like a secret: a flag (`--token …`, `--password=…`, `-p…`), a header
6
+ // (`Authorization: …`, `Cookie: …`), an environment variable or a key in JSON, YAML or a form (`FOO_TOKEN=…`,
7
+ // `"password": …`), and a bearer, basic or token credential;
8
+ // - a URL's user name and password, and the value of every query and fragment parameter;
9
+ // - anything that looks like a key or token: the well-known prefixes (ghp_, sk-, AKIA, xoxb-…), a JWT, a private key,
10
+ // a long run of letters and digits that mixes upper and lower case, one long random-looking part, and long hex;
11
+ // - and when a command can't be read safely (more than one line, a heredoc, inline code like `node -e`, `eval`, or a
12
+ // command that takes a secret on stdin), only the tool and the command's first word: "Bash: node …".
13
+ //
14
+ // Everything is cut to SUMMARY_MAX characters; the card's title is cut again to fit. Pure functions: nothing here reads
15
+ // or writes anything.
16
+ /** What stands in for whatever was taken out. */
17
+ export const BLANK = '…';
18
+ /** The most of a command, a path or a question a card shows. */
19
+ export const SUMMARY_MAX = 120;
20
+ /** Parts of a flag's name that make its value secret: `--api-key`, `--auth-token`, `--session`. */
21
+ const SECRET_PARTS = new Set([
22
+ 'pass',
23
+ 'password',
24
+ 'passwd',
25
+ 'passphrase',
26
+ 'pwd',
27
+ 'pw',
28
+ 'secret',
29
+ 'secrets',
30
+ 'token',
31
+ 'tokens',
32
+ 'key',
33
+ 'keys',
34
+ 'apikey',
35
+ 'auth',
36
+ 'authorization',
37
+ 'credential',
38
+ 'credentials',
39
+ 'cred',
40
+ 'creds',
41
+ 'cookie',
42
+ 'cookies',
43
+ 'session',
44
+ 'sessionid',
45
+ 'sid',
46
+ 'signature',
47
+ 'sig',
48
+ 'otp',
49
+ 'bearer',
50
+ 'private',
51
+ 'dsn',
52
+ 'jwt',
53
+ 'pat',
54
+ ]);
55
+ /**
56
+ * Words that make a name secret wherever they are in it (in snake case): PGPASSWORD, GITHUB_TOKEN, client_secret,
57
+ * BASIC_AUTH. Not "author".
58
+ */
59
+ const SECRET_WORDS = /pass(?:word|wd|phrase)?|pwd|secret|token|api_?key|access_?key|private_?key|credential|cookie|signature|bearer|session|(?:^|_)auth(?:_|$|orization)|dsn|jwt|(?:^|_)keys?(?:_|$)|key$/i;
60
+ /** Whether a flag's name (`--api-key`, `-p`) says its value is secret. */
61
+ export function isSecretFlag(flag) {
62
+ const name = flag.replace(/^-+/, '');
63
+ if (/^[pP]$/.test(name))
64
+ return true;
65
+ // openssl's -pass, -passin and -passout; --password, --passphrase.
66
+ if (/^pass|password|passwd|secret|token|apikey|credential/i.test(name))
67
+ return true;
68
+ return name
69
+ .toLowerCase()
70
+ .split(/[-_.]/)
71
+ .some((part) => SECRET_PARTS.has(part));
72
+ }
73
+ /** Whether a variable's or a key's name (`GITHUB_TOKEN`, `"password"`, `clientSecret`) says its value is secret. */
74
+ export function isSecretName(name) {
75
+ const bare = name.replace(/^-+/, '');
76
+ if (isSecretFlag(bare))
77
+ return true;
78
+ // camelCase and SCREAMING_CASE alike: clientSecret → client_secret.
79
+ const snake = bare.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
80
+ return SECRET_WORDS.test(snake);
81
+ }
82
+ /** Letters and digits, and enough different characters that it isn't a placeholder like xxxx or 0000. */
83
+ const looksRandom = (run) => /\d/.test(run) && /[A-Za-z]/.test(run) && new Set(run).size >= 8;
84
+ /** The well-known shapes of keys and tokens, at any length that could be real. */
85
+ const KEY_SHAPES = [
86
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
87
+ /\bgh[pousr]_[A-Za-z0-9]{16,}/g,
88
+ /\bgithub_pat_[A-Za-z0-9_]{16,}/g,
89
+ /\bglpat-[A-Za-z0-9_-]{16,}/g,
90
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
91
+ /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{12,}/g,
92
+ /\bwhsec_[A-Za-z0-9+/=]{16,}/g,
93
+ /\bxox[abposr]-[A-Za-z0-9-]{8,}/g,
94
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
95
+ /\bsntry[su]_[A-Za-z0-9+/=_-]{16,}/g,
96
+ /\bnpm_[A-Za-z0-9]{16,}/g,
97
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
98
+ /\bcrsr_[A-Za-z0-9]{16,}/g,
99
+ /\bya29\.[A-Za-z0-9_-]{16,}/g,
100
+ /\beyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]*){0,2}/g,
101
+ ];
102
+ /**
103
+ * A run that looks like a key or token: long hex; or letters and digits that mix upper and lower case (a base62 or
104
+ * base64 key); or one long part, split on - _ . /, that mixes letters and digits. Not a branch name, a slug or a path,
105
+ * whose parts are short and mostly one case.
106
+ */
107
+ export function looksLikeToken(run) {
108
+ const bare = run.replace(/^[-_.:/+=]+|[-_.:/+=]+$/g, '');
109
+ if (bare.length < 20)
110
+ return false;
111
+ if (/^[0-9a-f]{32,}$/i.test(bare))
112
+ return true;
113
+ if (bare.length >= 24 && /[a-z]/.test(bare) && /[A-Z]/.test(bare) && /\d/.test(bare)) {
114
+ // A path's parts are words: a run of upper, lower and digits is a key only when it isn't split into short words.
115
+ const parts = bare.split(/[/.]/);
116
+ if (parts.some((part) => part.length >= 20 && looksRandom(part)))
117
+ return true;
118
+ }
119
+ return bare.split(/[-_./]/).some((part) => part.length >= 20 && looksRandom(part));
120
+ }
121
+ /** Takes keys and tokens out of any text: their well-known shapes, then long random-looking runs. */
122
+ export function redactTokens(text) {
123
+ let out = text;
124
+ for (const shape of KEY_SHAPES)
125
+ out = out.replace(shape, BLANK);
126
+ return out.replace(/[A-Za-z0-9+/=_.:-]{20,}/g, (run) => {
127
+ // A URL's own address isn't a token: only its parts are looked at (redactUrls blanks its query).
128
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(run))
129
+ return run;
130
+ return looksLikeToken(run) ? BLANK : run;
131
+ });
132
+ }
133
+ /**
134
+ * A URL without its user name and password, with every query and fragment parameter's value blanked, and any part of
135
+ * its path that looks like a token (a webhook's secret: hooks.slack.com/services/T…/B…/<secret>) blanked too.
136
+ */
137
+ export function redactUrl(url) {
138
+ const match = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#\s]*)([^?#\s]*)(.*)$/is.exec(url);
139
+ if (!match)
140
+ return url;
141
+ const [, scheme, authority = '', path = '', rest = ''] = match;
142
+ const host = authority.includes('@')
143
+ ? `${BLANK}@${authority.slice(authority.lastIndexOf('@') + 1)}`
144
+ : authority;
145
+ const parts = path
146
+ .split('/')
147
+ .map((part) => (part.length >= 20 && looksLikeToken(part) ? BLANK : part))
148
+ .join('/');
149
+ const tail = rest
150
+ .replace(/([?&;][^=&#;\s]*)=([^&#;\s]*)/g, (_all, name, value) => value ? `${name}=${BLANK}` : `${name}=`)
151
+ .replace(/(#|&)([^=&#\s]+)=([^&#\s]*)/g, (_all, mark, name, value) => value ? `${mark}${name}=${BLANK}` : `${mark}${name}=`);
152
+ return `${scheme}${host}${parts}${tail}`;
153
+ }
154
+ /** Every URL in the text, without its credentials and query values (redactUrl). */
155
+ export function redactUrls(text) {
156
+ return text.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>)\]}]+/gi, redactUrl);
157
+ }
158
+ /** A quoted value or a bare word, as a shell or a JSON object writes it. */
159
+ const VALUE = `("(?:[^"\\\\]|\\\\.)*"|'[^']*'|[^\\s"'&;,|}\\])]+)`;
160
+ /** A blanked value, keeping its quotes so the line still reads. */
161
+ function blankValue(value) {
162
+ if (value.startsWith('"'))
163
+ return `"${BLANK}"`;
164
+ if (value.startsWith("'"))
165
+ return `'${BLANK}'`;
166
+ return BLANK;
167
+ }
168
+ /** The values of headers that carry credentials: `Authorization: …`, `Cookie: …`, `X-Api-Key: …`. */
169
+ function redactHeaders(text) {
170
+ return text.replace(/\b((?:proxy-)?authorization|(?:set-)?cookie|x-[a-z0-9-]*(?:key|token|auth|secret|signature|session)[a-z0-9-]*|api-?key|private-token|x-amz-security-token)(\s*:\s*)([^"'\n]+)/gi, (_all, name, colon) => `${name}${colon}${BLANK}`);
171
+ }
172
+ /** `Bearer …`, `Basic …`, `Token …`: a credential after the word that says what it is. */
173
+ function redactSchemes(text) {
174
+ return text.replace(/\b(Bearer|Basic|Token|Digest)(\s+)(?!…)[A-Za-z0-9._~+/=:-]{6,}/g, (_all, scheme, space) => `${scheme}${space}${BLANK}`);
175
+ }
176
+ /**
177
+ * Values after names that say they're secret: environment variables and keys (`FOO_TOKEN=…`, `"password": …`,
178
+ * `password: …`, a form's `&pass=…`) and flags (`--token=…`).
179
+ */
180
+ function redactNamedValues(text) {
181
+ const pattern = new RegExp(`(["']?)([A-Za-z_][A-Za-z0-9_.-]*)\\1(\\s*[:=]\\s*)${VALUE}`, 'g');
182
+ return text.replace(pattern, (all, quote, name, sign, value) => {
183
+ // A URL's own `scheme:` isn't a name and its value.
184
+ if (sign.trim() === ':' && /^\/\//.test(value))
185
+ return all;
186
+ // `--user=name:password` (curl) holds a password too.
187
+ const user = name === 'user' && sign.trim() === '=' && value.includes(':');
188
+ if (!isSecretName(name) && !user)
189
+ return all;
190
+ return `${quote}${name}${quote}${sign}${blankValue(value)}`;
191
+ });
192
+ }
193
+ /** Programs whose `-p` (or `-P`) is a switch or a number, never a password: `mkdir -p`, `git add -p`, `ps -p`. */
194
+ const P_IS_PLAIN = new Set([
195
+ 'mkdir',
196
+ 'cp',
197
+ 'ls',
198
+ 'git',
199
+ 'tar',
200
+ 'rsync',
201
+ 'scp',
202
+ 'install',
203
+ 'lsof',
204
+ 'ps',
205
+ 'pgrep',
206
+ 'pkill',
207
+ 'du',
208
+ 'grep',
209
+ 'rg',
210
+ ]);
211
+ /** Flags that hold a password for one program only: `redis-cli -a`. */
212
+ const PROGRAM_SECRETS = {
213
+ 'redis-cli': ['-a', '--pass', '--user'],
214
+ };
215
+ /** Programs that take a password glued to `-p` (`mysql -pS3cret`, `7z x -pS3cret`). */
216
+ const P_ATTACHED = /^(?:mysql\w*|mariadb\w*|7z\w*|7za|7zr)$/;
217
+ /** A command line's parts between `&&`, `||`, `;` and `|`, outside quotes, each with what joined it to the next. */
218
+ function segments(text) {
219
+ const found = [];
220
+ let current = '';
221
+ let quote = null;
222
+ for (let index = 0; index < text.length; index++) {
223
+ const char = text[index];
224
+ if (quote) {
225
+ current += char;
226
+ if (char === quote)
227
+ quote = null;
228
+ continue;
229
+ }
230
+ if (char === '"' || char === "'") {
231
+ quote = char;
232
+ current += char;
233
+ continue;
234
+ }
235
+ const two = text.slice(index, index + 2);
236
+ if (two === '&&' || two === '||') {
237
+ found.push({ text: current, join: two });
238
+ current = '';
239
+ index++;
240
+ continue;
241
+ }
242
+ if (char === ';' || char === '|') {
243
+ found.push({ text: current, join: char });
244
+ current = '';
245
+ continue;
246
+ }
247
+ current += char;
248
+ }
249
+ found.push({ text: current, join: '' });
250
+ return found;
251
+ }
252
+ /**
253
+ * The value after a flag that says it's secret, in each part of the command line: `--token abc`, `-p hunter2` (not
254
+ * `mkdir -p`), `-u user:pass`, `-pS3cret`, `redis-cli -a …`; and after a variable's name that says it's secret,
255
+ * given as a word of its own (`aws configure set aws_secret_access_key …`, `netlify env:set API_KEY …`).
256
+ */
257
+ function redactFlags(text) {
258
+ const spaced = new RegExp(`(^|\\s)(--?[A-Za-z][A-Za-z0-9_.-]*)(\\s+)(?!-)${VALUE}`, 'g');
259
+ const named = new RegExp(`(^|\\s)([A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]*|[A-Z][A-Z0-9_]{2,})(\\s+)(?![-|&;])${VALUE}`, 'g');
260
+ return segments(text)
261
+ .map((part) => {
262
+ const program = firstWord(part.text) ?? '';
263
+ const plainP = P_IS_PLAIN.has(program);
264
+ const extra = PROGRAM_SECRETS[program] ?? [];
265
+ const secret = (flag) => extra.includes(flag) || (isSecretFlag(flag) && !(plainP && /^-[pP]$/.test(flag)));
266
+ let out = part.text.replace(spaced, (all, lead, flag, space, value) => {
267
+ const user = /^(-u|--user)$/.test(flag) && value.includes(':');
268
+ if (!secret(flag) && !user)
269
+ return all;
270
+ return `${lead}${flag}${space}${blankValue(value)}`;
271
+ });
272
+ // A password glued to its flag (mysql -pS3cret).
273
+ if (P_ATTACHED.test(program))
274
+ out = out.replace(/(^|\s)(-[pP])(?=[^\s-])(\S+)/g, (_all, lead, flag) => {
275
+ return `${lead}${flag}${BLANK}`;
276
+ });
277
+ out = out.replace(named, (all, lead, name, space, value) => isSecretName(name) && name !== program ? `${lead}${name}${space}${blankValue(value)}` : all);
278
+ return `${out}${part.join}`;
279
+ })
280
+ .join('');
281
+ }
282
+ /** Repeated blanks read as one: "… …" stays, "……" doesn't happen. */
283
+ const tidy = (text) => text
284
+ .replace(new RegExp(`${BLANK}{2,}`, 'g'), BLANK)
285
+ .replace(/\s+/g, ' ')
286
+ .trim();
287
+ /** Cuts text to `max` characters, saying so with a final "…". */
288
+ export function cut(text, max = SUMMARY_MAX) {
289
+ const chars = [...text];
290
+ if (chars.length <= max)
291
+ return text;
292
+ return `${chars
293
+ .slice(0, max - 1)
294
+ .join('')
295
+ .trimEnd()}${BLANK}`;
296
+ }
297
+ /** Any text a card shows (a question, a search): keys, tokens and URLs' secrets out, one line, cut. */
298
+ export function redactText(text, max = SUMMARY_MAX) {
299
+ return cut(tidy(redactTokens(redactSchemes(redactHeaders(redactUrls(text))))), max);
300
+ }
301
+ /** A shell or an interpreter given its code on the command line: `bash -c …`, `node -e …`, `python3 -c …`. */
302
+ const INLINE_CODE = /(?:^|[\s;&|(`$/])(?:(?:ba|z|da|k)?sh|fish|python[0-9.]*|node(?:js)?|deno|bun|ruby|perl|php|osascript|pwsh|powershell|lua|Rscript)\b[^;&|]*?\s(?:-c|-e|-E|--eval|-p|--print|-r|-Command|-command|-EncodedCommand)(?=\s|=|$)/i;
303
+ /** Commands that take a secret on stdin or set one: what follows them could be the secret itself. */
304
+ const TAKES_SECRET = /\bsudo\s+(?:-\w+\s+)*-S\b|--password-stdin|\b(?:ch)?passwd\b|\bhtpasswd\b|\bsecurity\s+(?:add|set|import)-|\bsecrets?\s+(?:set|put|add|create|edit)\b|\bvault\s+(?:kv\s+)?(?:put|write)\b|\bkubectl\s+create\s+secret\b|\bop\s+(?:item|read|run|inject)\b|\bssh-add\b|\bgpg\b|\bpass\s+(?:insert|edit)\b|\bbase64\s+(?:-d|-D|--decode)\b/i;
305
+ /**
306
+ * Whether a command can't be shown safely, even with secrets taken out: more than one line, a heredoc or here-string
307
+ * (its body could be anything), inline code for a shell or an interpreter, `eval`, or a command that takes a secret on
308
+ * stdin or sets one.
309
+ */
310
+ export function unsafeCommand(command) {
311
+ const text = command.trim();
312
+ if (/[\r\n]/.test(text))
313
+ return true;
314
+ if (/<<-?\s*['"]?\w|<<</.test(text))
315
+ return true;
316
+ if (/(?:^|[\s;&|(`$])eval\s/.test(text))
317
+ return true;
318
+ return INLINE_CODE.test(text) || TAKES_SECRET.test(text);
319
+ }
320
+ /** The command's own name, for "Bash: rm …": past variables, sudo and the like; null when it isn't a plain word. */
321
+ export function firstWord(command) {
322
+ const words = command.trim().split(/\s+/);
323
+ for (const word of words) {
324
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
325
+ continue;
326
+ if (/^(?:sudo|env|time|nohup|exec|command|nice|caffeinate)$/.test(word))
327
+ continue;
328
+ if (word.startsWith('-'))
329
+ continue;
330
+ const name = word
331
+ .replace(/^['"]|['"]$/g, '')
332
+ .split('/')
333
+ .at(-1) ?? '';
334
+ return /^[A-Za-z0-9][\w.+-]{0,31}$/.test(name) ? name : null;
335
+ }
336
+ return null;
337
+ }
338
+ /**
339
+ * What a card says about a shell command: the command with every secret taken out, on one line, cut to SUMMARY_MAX;
340
+ * or, when it can't be shown safely (unsafeCommand), the tool and its first word: "Bash: node …".
341
+ */
342
+ export function redactCommand(command, tool = 'Bash', max = SUMMARY_MAX) {
343
+ const word = firstWord(command);
344
+ const doubtful = { text: word ? `${tool}: ${word} ${BLANK}` : `${tool} ${BLANK}`, doubt: true };
345
+ if (!command.trim() || unsafeCommand(command))
346
+ return doubtful;
347
+ let text = redactUrls(command);
348
+ text = redactHeaders(text);
349
+ text = redactSchemes(text);
350
+ text = redactNamedValues(text);
351
+ text = redactFlags(text);
352
+ text = redactTokens(text);
353
+ text = tidy(text);
354
+ if (!text || text === BLANK)
355
+ return doubtful;
356
+ return { text: cut(text, max), doubt: false };
357
+ }
358
+ /** A path as the person reads it: under ~ when it's in their home, its secrets out, and its end kept when it's long. */
359
+ export function shortPath(path, home, max = 60) {
360
+ const base = home.replace(/\/+$/, '');
361
+ let shown = base && (path === base || path.startsWith(`${base}/`)) ? `~${path.slice(base.length)}` : path;
362
+ shown = redactTokens(redactUrls(shown.replace(/\s+/g, ' ').trim()));
363
+ const chars = [...shown];
364
+ if (chars.length <= max)
365
+ return shown;
366
+ const tail = chars.slice(chars.length - (max - 1)).join('');
367
+ const slash = tail.indexOf('/');
368
+ return `${BLANK}${slash > 0 && slash < tail.length - 1 ? tail.slice(slash) : tail}`;
369
+ }
package/dist/setup.js CHANGED
@@ -50,7 +50,17 @@ export function setupOf(heard) {
50
50
  };
51
51
  }
52
52
  /**
53
- * The session-start line for a setup that isn't finished (about 70 tokens), or null when there's nothing for the
53
+ * The three calls that finish a setup, said exactly (0.13.0): Pi's first setup on Darin's devbox spent about 20,000
54
+ * tokens on "whoami with your name and guide true" (the whole guide, which Pi cut into a file the model then parsed)
55
+ * and on hunting for testAreaId in whoami's answer (only report_setup's has it). `stopFirst` adds whoami's way out for
56
+ * a setup another session finished meanwhile; a setup that has reported already says app.setUp is true, so its line
57
+ * leaves that out. The Claude Code wake mod's setup turn says the same (packages/claude-plugin's setupPrompt).
58
+ */
59
+ export function setupCalls(app, stopFirst) {
60
+ return `Three calls, nothing to install or approve: 1. whoami with your name.${stopFirst ? ' If it says app.setUp is true, another session did this: stop.' : ''} 2. report_setup with source ${app}, your name, skillSaved true, hears instant, and skillVersion set to whoami’s skill.latest. Its answer has a testAreaId. 3. post_request a short test question in that testAreaId, tell your person it’s on its way, and end your turn: you’re woken when they answer, so don’t ask them to type anything.`;
61
+ }
62
+ /**
63
+ * The session-start line for a setup that isn't finished (about 110 tokens), or null when there's nothing for the
54
64
  * session to do: verified, or its test card waiting on the person.
55
65
  */
56
66
  export function setupLine(app, heard, setup) {
@@ -59,15 +69,16 @@ export function setupLine(app, heard, setup) {
59
69
  const title = connectionTitle(heard);
60
70
  if (setup.test?.turn === 'agent') {
61
71
  const { requestId, name } = setup.test;
72
+ const pickUp = (who) => `call get_request (requestId ${requestId}, ${who}), then ack_answer with its version and a one-line outcome: that finishes setup.`;
62
73
  return name
63
- ? `Pending You: your person answered setup’s test question (${requestId}), asked by “${name}”: if you’re ${name}, get_request it, then ack_answer.`
64
- : `Pending You: your person answered setup’s test question (${requestId}): get_request it, then ack_answer.`;
74
+ ? `Pending You: your person answered setup’s test question (${requestId}), asked by “${name}”. If you’re ${name}, ${pickUp(`name “${name}”`)} If you aren’t, leave it to ${name}.`
75
+ : `Pending You: your person answered setup’s test question (${requestId}). C${pickUp('with your name').slice(1)}`;
65
76
  }
66
77
  if (setup.test)
67
78
  return null;
68
79
  if (setup.reported)
69
- return `Pending You: this computer is connected (${title}) but setup’s test question isn’t sent. Call whoami with your name, then report_setup with source ${app}, and post your test question in its testAreaId. Nothing to add, install or approve.`;
70
- return `Pending You: this computer is connected (${title}) but setup isn’t finished. First: whoami with your name and guide true; report_setup with source ${app}, skillSaved true, hears instant and the guide’s skillVersion; then your test question in its testAreaId. Nothing to add, install or approve. If whoami says app.setUp is true, another session did it.`;
80
+ return `Pending You: ${title} is connected, but setup’s test question isn’t sent yet. ${setupCalls(app, false)}`;
81
+ return `Pending You: ${title} is connected, but its setup isn’t finished. ${setupCalls(app, true)}`;
71
82
  }
72
83
  /**
73
84
  * The line a session start adds for this app's unfinished setup, or null: only for a new, resumed or cleared session,
package/dist/shim.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // never blocks anyone. When the private copy couldn't be made, it runs this version through npx instead.
14
14
  import { readFile, rm, rmdir, stat } from 'node:fs/promises';
15
15
  import { dirname, join } from 'node:path';
16
- import { configDir, writeWhole } from "./files.js";
16
+ import { configDir, PERMISSION_FOLDER, writeWhole } from "./files.js";
17
17
  import { USUAL_NODES, usualWords } from "./remote.js";
18
18
  import { VERSION } from "./version.js";
19
19
  export const SHIM = 'pendingyou-hook';
@@ -32,6 +32,13 @@ export function shimScript(run, config, usual = USUAL_NODES) {
32
32
  # agents' hook definitions. Written by npx pendingyou init (pendingyou ${VERSION}); run that again to repair it.
33
33
  PENDINGYOU_CONFIG_DIR=${quoted(config)}
34
34
  export PENDINGYOU_CONFIG_DIR
35
+ # A Claude Code tool call ran or a session ended: nothing to do unless a permission card may be open.
36
+ if [ "$1" = permission-done ]; then
37
+ for open in "$PENDINGYOU_CONFIG_DIR"/${PERMISSION_FOLDER}/*.json; do
38
+ [ -e "$open" ] && break
39
+ exit 0
40
+ done
41
+ fi
35
42
  `;
36
43
  if ('npx' in run)
37
44
  return `${head}exec ${run.npx} "$@"\n`;
package/dist/stopcheck.js CHANGED
@@ -26,6 +26,7 @@ import { markNudged, turnRecord } from "./apps/codex-wake.js";
26
26
  import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
27
27
  import { asksToTypeHere, cardFits, confirmReason, sessionLink } from "./confirm.js";
28
28
  import { readCredential } from "./credentials.js";
29
+ import { settlePrompts, subagentsRunning } from "./permission.js";
29
30
  import { folderForms } from "./pickup.js";
30
31
  /** The most of the transcript it reads, from the end. A turn longer than this is let through. */
31
32
  export const READ_CAP = 4 * 1024 * 1024;
@@ -324,7 +325,16 @@ export async function stopcheck(io, options) {
324
325
  const app = options.app ?? DEFAULT_APP;
325
326
  try {
326
327
  const input = JSON.parse(await io.readStdin(1000));
327
- if (typeof input !== 'object' || input === null || input.stop_hook_active === true)
328
+ if (typeof input !== 'object' || input === null)
329
+ return 0;
330
+ // The turn ended: nothing it asked the person's OK for is waiting any more (0.13.0, permission.ts).
331
+ if (app === DEFAULT_APP)
332
+ await settlePrompts(io, {
333
+ session: typeof input.session_id === 'string' ? input.session_id : null,
334
+ event: 'stop',
335
+ subagents: subagentsRunning(input),
336
+ });
337
+ if (input.stop_hook_active === true)
328
338
  return 0;
329
339
  if (!(await readCredential(io, options.origin, app)))
330
340
  return 0;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The command line's version, as published (package.json). A test keeps them the same.
2
- export const VERSION = '0.12.0';
2
+ export const VERSION = '0.13.0';
@@ -19,7 +19,7 @@
19
19
  // if Claude Code may call whoami without asking anyone, it does (no name), and when Pending You says Claude Code isn't
20
20
  // set up here, it starts one turn with the setup prompt once the session is idle. Once per session, in at most three
21
21
  // sessions, never two within ten minutes, and never again once whoami says it's set up (wake.ts's setupNeeded).
22
- import { argumentsOf, blockedNote, CLI_COPY, cardTool, checked, claimLease, dueCards, emptyBook, failed, holdOf, holdReply, isRefusal, isStale, isWaitingOnYou, learn, leaseHolder, momentOf, prompted, put, readBook, readSetup, resultObject, SETUP_AFTER_MS, SETUP_RETRY_MS, SETUP_SERVER, SETUP_TRIES, settle, setUpOf, setupNeeded, setupPrompt, stateOf, statusText, TICK_MS, told, toTell, untold, wakeText, } from "./wake.js";
22
+ import { argumentsOf, blockedNote, CLI_COPY, cardTool, checked, claimLease, connectionOf, dueCards, emptyBook, failed, holdOf, holdReply, isRefusal, isStale, isWaitingOnYou, learn, leaseHolder, momentOf, prompted, put, readBook, readSetup, resultObject, SETUP_AFTER_MS, SETUP_RETRY_MS, SETUP_SERVER, SETUP_TRIES, settle, setUpOf, setupNeeded, setupPrompt, stateOf, statusText, TICK_MS, told, toTell, untold, wakeText, } from "./wake.js";
23
23
  /** After a prompt that didn't go, how long before the mod tries again. */
24
24
  const RETRY_MS = 60_000;
25
25
  /** What the mod holds while its module is loaded. The cards themselves are kept in the store. */
@@ -44,6 +44,8 @@ const live = {
44
44
  noted: new Set(),
45
45
  /** Pending You said Claude Code isn't set up here: start the setup turn once the session is idle. */
46
46
  setupDue: false,
47
+ /** The connection whoami named ("Claude Code on devbox"), for the setup turn's words. */
48
+ setupConnection: 'Claude Code',
47
49
  };
48
50
  const keyOf = (sessionId) => `session:${sessionId}`;
49
51
  /** A server Claude Code refuses checks on, while the session has a card there. */
@@ -172,7 +174,7 @@ async function deliverSetup($) {
172
174
  if (!setupNeeded(record, live.sessionId, now))
173
175
  return;
174
176
  await $.store.set('setup', prompted(record, live.sessionId, now));
175
- $.prompt.submit({ text: setupPrompt() }).catch(() => { });
177
+ $.prompt.submit({ text: setupPrompt(live.setupConnection) }).catch(() => { });
176
178
  }
177
179
  /**
178
180
  * Asks Pending You whether Claude Code is set up here (whoami, no name, only when Claude Code allows it without asking),
@@ -187,7 +189,9 @@ async function checkSetup($, attempt) {
187
189
  return;
188
190
  let setUp = null;
189
191
  try {
190
- setUp = setUpOf(resultObject(await $.mcp.call(SETUP_SERVER, 'whoami', {})));
192
+ const output = resultObject(await $.mcp.call(SETUP_SERVER, 'whoami', {}));
193
+ setUp = setUpOf(output);
194
+ live.setupConnection = connectionOf(output);
191
195
  }
192
196
  catch { }
193
197
  if (setUp === null) {
package/mod/hooks/wake.js CHANGED
@@ -644,9 +644,22 @@ export const prompted = (record, sessionId, now) => ({
644
644
  lastAt: now,
645
645
  });
646
646
  /**
647
- * The turn that finishes setting up, as the session reads it: the sentence a person would say (the command line's
648
- * FINISH_SAY; a test holds them equal), then exactly what to do, so it needs nothing else.
647
+ * The connection whoami names ("Claude Code on Darin’s MacBook Pro"): its app's name, and the machine it signed in
648
+ * from; "Claude Code" when whoami's answer doesn't say.
649
649
  */
650
- export function setupPrompt() {
651
- return 'Finish setting up Pending You: it’s connected here already. Call whoami with your name and guide true; report_setup with source claude-code, skillSaved true, hears instant and the guide’s skillVersion; then send your test question to its testAreaId. Nothing to add, install or approve. If whoami says app.setUp is true, another session did it: just introduce yourself.';
650
+ export function connectionOf(output) {
651
+ const app = output && isObject(output.app) ? output.app : null;
652
+ const agent = output && isObject(output.agent) ? output.agent : null;
653
+ const name = typeof app?.name === 'string' && app.name.trim() ? app.name.trim() : 'Claude Code';
654
+ const machine = typeof agent?.machine === 'string' ? agent.machine.trim() : '';
655
+ return machine ? `${name} on ${machine}` : name;
656
+ }
657
+ /**
658
+ * The turn that finishes setting up (CLI 0.13.0), as the session reads it: exactly the three calls, as the command
659
+ * line's session-start line says them (its setupLine; a test holds them equal), so it needs nothing else. Before, it
660
+ * asked for whoami with guide true, whose answer is the whole guide (87,000 characters), and for testAreaId, which
661
+ * only report_setup's answer has: Pi spent about 20,000 tokens on that line on Darin's devbox.
662
+ */
663
+ export function setupPrompt(connection = 'Claude Code') {
664
+ return `Pending You: ${connection} is connected, but its setup isn’t finished. Three calls, nothing to install or approve: 1. whoami with your name. If it says app.setUp is true, another session did this: stop. 2. report_setup with source claude-code, your name, skillSaved true, hears instant, and skillVersion set to whoami’s skill.latest. Its answer has a testAreaId. 3. post_request a short test question in that testAreaId, tell your person it’s on its way, and end your turn: you’re woken when they answer, so don’t ask them to type anything.`;
652
665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Pending You for your coding agents: one command sets up Claude Code, Codex, OpenCode and Pi to ask you through Pending You and hear your answers, even while they sit idle.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.pendingyou.com",