pendingyou 0.8.0 → 0.10.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 +72 -0
- package/README.md +89 -23
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/confirm.js +231 -0
- package/dist/credentials.js +39 -1
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -17
- package/dist/index.js +1 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +195 -35
- package/dist/mod.js +87 -0
- package/dist/oauth.js +27 -6
- package/dist/pickup.js +16 -18
- package/dist/qr.js +365 -0
- package/dist/remote.js +306 -0
- package/dist/state.js +5 -32
- package/dist/stopcheck.js +88 -8
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +314 -0
- package/mod/hooks/wake.js +599 -0
- package/mod/tests/wake.test.ts +198 -0
- package/package.json +11 -4
package/dist/claude.js
CHANGED
|
@@ -7,13 +7,20 @@
|
|
|
7
7
|
// Since 0.7.0 the hooks run a private copy of this version with Node directly (install.ts), and init asks Claude Code
|
|
8
8
|
// itself as little as it can: `claude mcp get` and `claude mcp add` each take up to a minute or more (Claude Code checks
|
|
9
9
|
// its servers), so the MCP server is looked up in ~/.claude.json first (read only), and init says what it's waiting on.
|
|
10
|
+
//
|
|
11
|
+
// Since 0.10.0, on Claude Code 2.1.287 or later, settings' env.CLAUDE_CODE_PLUGIN_DIRS also loads the wake mod from
|
|
12
|
+
// that private copy (mod.ts), so a session is woken when the person answers, with no hold running. And on a computer
|
|
13
|
+
// with no browser (remote.ts) the MCP server is added with a headersHelper, `claude mcp add-json`, which signs Claude
|
|
14
|
+
// Code in through this computer's own sign-in instead of OAuth.
|
|
10
15
|
import { createHash } from 'node:crypto';
|
|
11
|
-
import { rm, rmdir } from 'node:fs/promises';
|
|
16
|
+
import { rm, rmdir, stat } from 'node:fs/promises';
|
|
12
17
|
import { dirname, join } from 'node:path';
|
|
13
18
|
import { DEFAULT_ORIGIN } from "./args.js";
|
|
14
19
|
import { PlainError } from "./errors.js";
|
|
15
20
|
import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
|
|
16
|
-
import { hookForm, hookRuns, installCli, pruneCli, removeCli, shellQuote, } from "./install.js";
|
|
21
|
+
import { cliPackage, hookForm, hookRuns, installCli, pruneCli, removeCli, shellQuote, } from "./install.js";
|
|
22
|
+
import { claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pluginDirs, pluginEnabled, separatorFor, withModDir, } from "./mod.js";
|
|
23
|
+
import { helperCommand, helperPath, isOurHelper, removeHelper, writeHelper } from "./remote.js";
|
|
17
24
|
import { VERSION } from "./version.js";
|
|
18
25
|
export const MCP_NAME = 'pendingyou';
|
|
19
26
|
/** The hooks, by Claude Code event, and the command each runs. */
|
|
@@ -212,7 +219,12 @@ export async function mcpFromFile(io) {
|
|
|
212
219
|
if (typeof server !== 'object' || server === null)
|
|
213
220
|
return 'unknown';
|
|
214
221
|
const url = server.url;
|
|
215
|
-
|
|
222
|
+
const helper = server.headersHelper;
|
|
223
|
+
return {
|
|
224
|
+
url: typeof url === 'string' ? url : null,
|
|
225
|
+
scope: 'user',
|
|
226
|
+
helper: typeof helper === 'string' ? helper : null,
|
|
227
|
+
};
|
|
216
228
|
}
|
|
217
229
|
/** `claude mcp get pendingyou`'s answer: the server, null when there isn't one, 'timeout' when it took too long. */
|
|
218
230
|
async function mcpFromClaude(io) {
|
|
@@ -239,7 +251,10 @@ const removeCommand = (scope) => `claude mcp remove${scope ? ` --scope ${scope}`
|
|
|
239
251
|
async function addMcp(io, origin, options, progress) {
|
|
240
252
|
const url = `${origin}/mcp`;
|
|
241
253
|
let existing = await mcpFromFile(io);
|
|
242
|
-
if (existing !== 'unknown' &&
|
|
254
|
+
if (existing !== 'unknown' &&
|
|
255
|
+
existing?.url &&
|
|
256
|
+
sameUrl(existing.url, url) &&
|
|
257
|
+
!isOurHelper(existing.helper))
|
|
243
258
|
return {
|
|
244
259
|
added: false,
|
|
245
260
|
step: { ok: true, text: `The pendingyou MCP server (${url}) was already in Claude Code.` },
|
|
@@ -266,7 +281,9 @@ async function addMcp(io, origin, options, progress) {
|
|
|
266
281
|
};
|
|
267
282
|
}
|
|
268
283
|
let replacing = null;
|
|
269
|
-
|
|
284
|
+
// `init --browser` where Claude Code signed in through this computer's helper (remote.ts): back to its own sign-in.
|
|
285
|
+
const helped = existing && isOurHelper(existing.helper) && (!existing.url || sameUrl(existing.url, url));
|
|
286
|
+
if (existing && !helped) {
|
|
270
287
|
if (!existing.url || sameUrl(existing.url, url))
|
|
271
288
|
return {
|
|
272
289
|
added: false,
|
|
@@ -307,6 +324,19 @@ async function addMcp(io, origin, options, progress) {
|
|
|
307
324
|
};
|
|
308
325
|
replacing = existing;
|
|
309
326
|
}
|
|
327
|
+
if (existing && helped) {
|
|
328
|
+
progress('Removing the pendingyou MCP server that signs in through this computer’s helper…\n');
|
|
329
|
+
const removed = await io.run('claude', ['mcp', 'remove', ...(existing.scope ? ['--scope', existing.scope] : []), MCP_NAME], MCP_TIMEOUT_MS);
|
|
330
|
+
if (removed.code !== 0)
|
|
331
|
+
return {
|
|
332
|
+
added: false,
|
|
333
|
+
step: {
|
|
334
|
+
ok: false,
|
|
335
|
+
text: `Claude Code didn’t remove the MCP server that signs in through this computer’s helper. Run:\n ${removeCommand(existing.scope)}\n ${addCommand(url)}`,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
replacing = existing;
|
|
339
|
+
}
|
|
310
340
|
progress('Adding the pendingyou MCP server to Claude Code (this can take a minute)…\n');
|
|
311
341
|
const add = await io.run('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', MCP_NAME, url], MCP_TIMEOUT_MS);
|
|
312
342
|
if (add.code === 0)
|
|
@@ -314,9 +344,11 @@ async function addMcp(io, origin, options, progress) {
|
|
|
314
344
|
added: true,
|
|
315
345
|
step: {
|
|
316
346
|
ok: true,
|
|
317
|
-
text: replacing
|
|
318
|
-
? `
|
|
319
|
-
:
|
|
347
|
+
text: !replacing
|
|
348
|
+
? `Added the pendingyou MCP server (${url}) for every project.`
|
|
349
|
+
: isOurHelper(replacing.helper) && replacing.url && sameUrl(replacing.url, url)
|
|
350
|
+
? `Switched the pendingyou MCP server (${url}) from this computer’s sign-in to your browser’s. Restart Claude Code, then run /mcp, choose pendingyou and Authenticate.`
|
|
351
|
+
: `Replaced the pendingyou MCP server (was ${replacing.url}) with ${url}. Restart Claude Code, then run /mcp, choose pendingyou and Authenticate.`,
|
|
320
352
|
},
|
|
321
353
|
};
|
|
322
354
|
return {
|
|
@@ -329,6 +361,122 @@ async function addMcp(io, origin, options, progress) {
|
|
|
329
361
|
},
|
|
330
362
|
};
|
|
331
363
|
}
|
|
364
|
+
/** `claude mcp add-json`'s arguments for the server signed in through the helper, and the same as a command to type. */
|
|
365
|
+
function helperServer(url, command) {
|
|
366
|
+
const json = JSON.stringify({ type: 'http', url, headersHelper: command });
|
|
367
|
+
return {
|
|
368
|
+
args: ['mcp', 'add-json', '--scope', 'user', MCP_NAME, json],
|
|
369
|
+
text: `claude mcp add-json --scope user ${MCP_NAME} ${quote(json)}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* On a computer with no browser (remote.ts): the MCP server at user scope with the helper as its headersHelper, so
|
|
374
|
+
* Claude Code signs in through this computer's sign-in and never needs Authenticate. One already like that is kept. One
|
|
375
|
+
* at this origin that signs in through a browser (a plain `claude mcp add`, which can't finish here) is switched over
|
|
376
|
+
* without asking: it's the same Pending You. One with a headersHelper of someone else's is kept, with the commands to
|
|
377
|
+
* switch. One at another origin is replaced as addMcp replaces it: once the person agrees.
|
|
378
|
+
*/
|
|
379
|
+
async function addMcpWithHelper(io, origin, command, options, progress) {
|
|
380
|
+
const url = `${origin}/mcp`;
|
|
381
|
+
const add = helperServer(url, command);
|
|
382
|
+
let existing = await mcpFromFile(io);
|
|
383
|
+
if (existing !== 'unknown' &&
|
|
384
|
+
existing?.url &&
|
|
385
|
+
sameUrl(existing.url, url) &&
|
|
386
|
+
existing.helper === command)
|
|
387
|
+
return {
|
|
388
|
+
added: false,
|
|
389
|
+
step: {
|
|
390
|
+
ok: true,
|
|
391
|
+
text: `The pendingyou MCP server (${url}) already signs in through this computer’s sign-in.`,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
const version = await io.run('claude', ['--version'], 20_000);
|
|
395
|
+
if (version.code === 127)
|
|
396
|
+
return {
|
|
397
|
+
added: false,
|
|
398
|
+
step: {
|
|
399
|
+
ok: false,
|
|
400
|
+
text: `Claude Code isn’t on this computer’s PATH. Install it, then run init again, or run: ${add.text}`,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
if (existing === 'unknown') {
|
|
404
|
+
progress('Checking Claude Code’s MCP servers (this can take a minute)…\n');
|
|
405
|
+
existing = await mcpFromClaude(io);
|
|
406
|
+
if (existing === 'timeout')
|
|
407
|
+
return {
|
|
408
|
+
added: false,
|
|
409
|
+
step: {
|
|
410
|
+
ok: false,
|
|
411
|
+
text: `Claude Code took more than 3 minutes to list its MCP servers, so I left them alone. Check with: claude mcp get ${MCP_NAME}. If it isn’t there, run: ${add.text}`,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
let replacing = null;
|
|
416
|
+
if (existing) {
|
|
417
|
+
const commands = ` ${removeCommand(existing.scope)}\n ${add.text}`;
|
|
418
|
+
const here = !existing.url || sameUrl(existing.url, url);
|
|
419
|
+
if (here && existing.helper && !isOurHelper(existing.helper))
|
|
420
|
+
return {
|
|
421
|
+
added: false,
|
|
422
|
+
step: {
|
|
423
|
+
ok: false,
|
|
424
|
+
text: `Kept the pendingyou MCP server: it signs in through a headersHelper of its own (${existing.helper}). To use this computer’s sign-in instead, run:\n ${commands}`,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
if (!here) {
|
|
428
|
+
progress(`Claude Code’s pendingyou MCP server points at ${existing.url}, not ${url}.\n`);
|
|
429
|
+
let replace = options.yes;
|
|
430
|
+
if (!replace && io.interactive) {
|
|
431
|
+
const answer = await io.ask(`Replace it with ${url}? [y/N] `);
|
|
432
|
+
replace = /^\s*y(es)?\s*$/i.test(answer);
|
|
433
|
+
}
|
|
434
|
+
if (!replace)
|
|
435
|
+
return {
|
|
436
|
+
added: false,
|
|
437
|
+
step: {
|
|
438
|
+
ok: false,
|
|
439
|
+
text: `Kept the MCP server at ${existing.url}. To use ${url}, run init again with --yes, or run:\n ${commands}`,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
progress('Removing the old pendingyou MCP server (this can take a minute)…\n');
|
|
444
|
+
const removed = await io.run('claude', ['mcp', 'remove', ...(existing.scope ? ['--scope', existing.scope] : []), MCP_NAME], MCP_TIMEOUT_MS);
|
|
445
|
+
if (removed.code !== 0)
|
|
446
|
+
return {
|
|
447
|
+
added: false,
|
|
448
|
+
step: {
|
|
449
|
+
ok: false,
|
|
450
|
+
text: `${removed.code === 124 ? 'Claude Code took more than 3 minutes to remove' : 'Claude Code didn’t remove'} the old MCP server. Run:\n ${commands}`,
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
replacing = existing;
|
|
454
|
+
}
|
|
455
|
+
progress('Adding the pendingyou MCP server to Claude Code…\n');
|
|
456
|
+
const added = await io.run('claude', add.args, MCP_TIMEOUT_MS);
|
|
457
|
+
if (added.code !== 0)
|
|
458
|
+
return {
|
|
459
|
+
added: false,
|
|
460
|
+
step: {
|
|
461
|
+
ok: false,
|
|
462
|
+
text: added.code === 124
|
|
463
|
+
? `Claude Code took more than 3 minutes to add the MCP server. Check with: claude mcp get ${MCP_NAME}. If it isn’t there, run: ${add.text}`
|
|
464
|
+
: `Claude Code didn’t add the MCP server. Run: ${add.text}`,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
const how = `It signs in through this computer’s sign-in (${helperPath(io)}): no browser, nothing to Authenticate.`;
|
|
468
|
+
return {
|
|
469
|
+
added: true,
|
|
470
|
+
step: {
|
|
471
|
+
ok: true,
|
|
472
|
+
text: !replacing
|
|
473
|
+
? `Added the pendingyou MCP server (${url}) for every project. ${how}`
|
|
474
|
+
: replacing.url && !sameUrl(replacing.url, url)
|
|
475
|
+
? `Replaced the pendingyou MCP server (was ${replacing.url}) with ${url}. ${how} Restart Claude Code.`
|
|
476
|
+
: `Switched the pendingyou MCP server (${url}) from a browser sign-in, which can’t finish on this computer, to this computer’s own. ${how}`,
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
332
480
|
/** The stub skill: written when it isn't there, or refreshed while it's still the one init wrote. */
|
|
333
481
|
async function saveSkill(io, origin, before) {
|
|
334
482
|
const path = skillPath(io);
|
|
@@ -363,7 +511,11 @@ async function saveSkill(io, origin, before) {
|
|
|
363
511
|
step: { ok: true, text: `Saved the skill to ${path}.` },
|
|
364
512
|
};
|
|
365
513
|
}
|
|
366
|
-
/**
|
|
514
|
+
/**
|
|
515
|
+
* Everything init does in Claude Code (not the sign-in). Idempotent. Each step is reported as it's done. `remote`: a
|
|
516
|
+
* computer with no browser (remote.ts), where the MCP server signs in through the helper, which runs the hooks' copy,
|
|
517
|
+
* so that copy comes first.
|
|
518
|
+
*/
|
|
367
519
|
export async function installClaude(io, origin, options = {}) {
|
|
368
520
|
const before = await readManifest(io);
|
|
369
521
|
const steps = [];
|
|
@@ -372,13 +524,11 @@ export async function installClaude(io, origin, options = {}) {
|
|
|
372
524
|
options.report?.(step);
|
|
373
525
|
};
|
|
374
526
|
const progress = (text) => io.out(text);
|
|
375
|
-
const mcp = await addMcp(io, origin, { yes: options.yes ?? false }, progress);
|
|
376
|
-
report(mcp.step);
|
|
377
|
-
const skill = await saveSkill(io, origin, before?.skill ?? null);
|
|
378
|
-
report(skill.step);
|
|
379
527
|
// The hooks' own copy of this version, unless PENDINGYOU_SELF says what they run.
|
|
380
528
|
let installed = null;
|
|
381
|
-
|
|
529
|
+
const installCopy = async () => {
|
|
530
|
+
if (io.env.PENDINGYOU_SELF)
|
|
531
|
+
return;
|
|
382
532
|
const cli = await installCli(io, progress);
|
|
383
533
|
installed = cli.installed;
|
|
384
534
|
report(cli.installed
|
|
@@ -387,26 +537,66 @@ export async function installClaude(io, origin, options = {}) {
|
|
|
387
537
|
text: `${cli.installed.how === 'kept' ? `pendingyou ${VERSION} for the hooks was already in` : `Installed pendingyou ${VERSION} for the hooks in`} ${cli.installed.prefix} (they run it with ${cli.installed.node}, no npx).`,
|
|
388
538
|
}
|
|
389
539
|
: {
|
|
390
|
-
ok:
|
|
540
|
+
ok: !options.remote,
|
|
391
541
|
text: `Couldn’t install pendingyou for the hooks (${cli.error}), so they run it through npx, which is slower. Run init again to retry.`,
|
|
392
542
|
});
|
|
543
|
+
};
|
|
544
|
+
let mcp;
|
|
545
|
+
let helper = null;
|
|
546
|
+
if (options.remote) {
|
|
547
|
+
await installCopy();
|
|
548
|
+
const copy = installed;
|
|
549
|
+
const written = copy ? await writeHelper(io, copy).catch(() => null) : null;
|
|
550
|
+
if (written) {
|
|
551
|
+
helper = { path: written.path, command: helperCommand(io, origin) };
|
|
552
|
+
report({
|
|
553
|
+
ok: true,
|
|
554
|
+
text: written.changed
|
|
555
|
+
? `Saved Claude Code’s sign-in helper for this computer: ${written.path}.`
|
|
556
|
+
: 'Claude Code’s sign-in helper was already in place.',
|
|
557
|
+
});
|
|
558
|
+
mcp = await addMcpWithHelper(io, origin, helper.command, { yes: options.yes ?? false }, progress);
|
|
559
|
+
}
|
|
560
|
+
else
|
|
561
|
+
mcp = {
|
|
562
|
+
added: false,
|
|
563
|
+
step: {
|
|
564
|
+
ok: false,
|
|
565
|
+
text: copy
|
|
566
|
+
? `Couldn’t write Claude Code’s sign-in helper to ${helperPath(io)}. Run init again.`
|
|
567
|
+
: 'Didn’t add the pendingyou MCP server: on this computer it signs in through pendingyou’s own copy, which isn’t installed. Run init again.',
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
report(mcp.step);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
mcp = await addMcp(io, origin, { yes: options.yes ?? false }, progress);
|
|
574
|
+
report(mcp.step);
|
|
393
575
|
}
|
|
576
|
+
const skill = await saveSkill(io, origin, before?.skill ?? null);
|
|
577
|
+
report(skill.step);
|
|
578
|
+
if (!options.remote)
|
|
579
|
+
await installCopy();
|
|
580
|
+
const copy = installed;
|
|
394
581
|
const commands = Object.fromEntries(Object.keys(HOOKS).map((event) => [
|
|
395
582
|
event,
|
|
396
|
-
hookCommand(io, origin, HOOKS[event],
|
|
583
|
+
hookCommand(io, origin, HOOKS[event], copy),
|
|
397
584
|
]));
|
|
398
585
|
const merged = mergeSettings(await readSettings(io), commands);
|
|
399
|
-
if (merged.changed)
|
|
400
|
-
await writeSettings(io, merged.settings);
|
|
401
586
|
report({
|
|
402
587
|
ok: true,
|
|
403
588
|
text: merged.changed
|
|
404
589
|
? `Installed or updated the session-start pickup, next-message hand-off and Stop check hooks in ${settingsPath(io)}.`
|
|
405
590
|
: 'The hooks were already installed.',
|
|
406
591
|
});
|
|
592
|
+
const mod = await addMod(io, merged.settings, copy);
|
|
593
|
+
if (mod.step)
|
|
594
|
+
report(mod.step);
|
|
595
|
+
if (merged.changed || mod.changed)
|
|
596
|
+
await writeSettings(io, mod.settings);
|
|
407
597
|
// The hooks run this version now (or npx): the copies of other versions are no longer used.
|
|
408
598
|
if (!io.env.PENDINGYOU_SELF)
|
|
409
|
-
await pruneCli(io,
|
|
599
|
+
await pruneCli(io, copy?.version ?? null);
|
|
410
600
|
const manifest = {
|
|
411
601
|
version: 1,
|
|
412
602
|
origin,
|
|
@@ -415,25 +605,69 @@ export async function installClaude(io, origin, options = {}) {
|
|
|
415
605
|
skill: skill.skill,
|
|
416
606
|
hooks: commands,
|
|
417
607
|
permissions: [...new Set([...(before?.permissions ?? []), ...merged.permissionsAdded])],
|
|
418
|
-
cli:
|
|
608
|
+
cli: copy ? { version: copy.version, prefix: copy.prefix } : null,
|
|
609
|
+
modDir: mod.dir,
|
|
610
|
+
// A remote setup's helper stays recorded when this run couldn't redo it, so uninstall still takes it out.
|
|
611
|
+
helper: helper ?? (options.remote ? (before?.helper ?? null) : null),
|
|
419
612
|
};
|
|
420
613
|
await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
|
|
421
614
|
return steps;
|
|
422
615
|
}
|
|
616
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
617
|
+
/**
|
|
618
|
+
* The wake mod in Claude Code's settings (0.10.0): on Claude Code 2.1.287 or later, this version's copy in
|
|
619
|
+
* CLAUDE_CODE_PLUGIN_DIRS, every other entry kept and an older version's replaced. Nothing, and nothing said, for an
|
|
620
|
+
* older Claude Code or none. Without the hooks' private copy there's no folder to point at, so it says so.
|
|
621
|
+
*/
|
|
622
|
+
async function addMod(io, settings, installed) {
|
|
623
|
+
const unchanged = { settings, changed: false, dir: null };
|
|
624
|
+
const result = await io.run('claude', ['--version'], 20_000);
|
|
625
|
+
const version = result.code === 0 ? claudeVersion(result.stdout) : null;
|
|
626
|
+
if (!version || !loadsMods(version))
|
|
627
|
+
return unchanged;
|
|
628
|
+
const dir = installed ? modDir(cliPackage(installed.prefix)) : null;
|
|
629
|
+
if (!dir || !(await exists(join(dir, 'hooks', 'hooks.json'))))
|
|
630
|
+
return {
|
|
631
|
+
...unchanged,
|
|
632
|
+
step: {
|
|
633
|
+
ok: true,
|
|
634
|
+
text: 'Couldn’t add the wake mod without the hooks’ own copy of pendingyou, so Claude Code hears answers through hold for now. Run init again to retry.',
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
const added = withModDir(settings, dir, separatorFor(io.platform));
|
|
638
|
+
if ('invalid' in added)
|
|
639
|
+
return { ...unchanged, step: { ok: false, text: added.invalid } };
|
|
640
|
+
return {
|
|
641
|
+
settings: added.settings,
|
|
642
|
+
changed: added.changed,
|
|
643
|
+
dir,
|
|
644
|
+
step: {
|
|
645
|
+
ok: true,
|
|
646
|
+
text: added.changed
|
|
647
|
+
? `Added the wake mod to ${PLUGIN_DIRS} in ${settingsPath(io)}: Claude Code ${version} wakes a session when you answer, with no hold running.`
|
|
648
|
+
: 'The wake mod was already in Claude Code’s settings.',
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
423
652
|
/** Removes what init added: the hooks, its permissions, its skill (if unchanged), its MCP server. */
|
|
424
653
|
export async function uninstallClaude(io) {
|
|
425
654
|
const manifest = await readManifest(io);
|
|
426
655
|
const steps = [];
|
|
427
656
|
const settings = await readSettings(io);
|
|
428
657
|
const removed = unmergeSettings(settings, manifest?.permissions ?? []);
|
|
429
|
-
|
|
430
|
-
|
|
658
|
+
// The wake mod lives in the private copy, which goes too: any copy of it in CLAUDE_CODE_PLUGIN_DIRS is ours.
|
|
659
|
+
const unmodded = withModDir(removed.settings, null, separatorFor(io.platform));
|
|
660
|
+
const modRemoved = !('invalid' in unmodded) && unmodded.changed;
|
|
661
|
+
if (removed.changed || modRemoved)
|
|
662
|
+
await writeSettings(io, 'invalid' in unmodded ? removed.settings : unmodded.settings);
|
|
431
663
|
steps.push({
|
|
432
664
|
ok: true,
|
|
433
665
|
text: removed.changed
|
|
434
666
|
? 'Removed the Pending You hooks and permissions from Claude Code.'
|
|
435
667
|
: 'No Pending You hooks were installed.',
|
|
436
668
|
});
|
|
669
|
+
if (modRemoved)
|
|
670
|
+
steps.push({ ok: true, text: `Removed the wake mod from ${PLUGIN_DIRS}.` });
|
|
437
671
|
if (manifest?.skill) {
|
|
438
672
|
const text = await readText(manifest.skill.path);
|
|
439
673
|
if (text !== null && sha256(text) === manifest.skill.sha256) {
|
|
@@ -446,6 +680,8 @@ export async function uninstallClaude(io) {
|
|
|
446
680
|
}
|
|
447
681
|
if (await removeCli(io))
|
|
448
682
|
steps.push({ ok: true, text: 'Removed the hooks’ copy of pendingyou.' });
|
|
683
|
+
if (await removeHelper(io))
|
|
684
|
+
steps.push({ ok: true, text: 'Removed Claude Code’s sign-in helper for this computer.' });
|
|
449
685
|
if (manifest?.mcpAdded) {
|
|
450
686
|
io.out('Removing the pendingyou MCP server from Claude Code (this can take a minute)…\n');
|
|
451
687
|
const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], MCP_TIMEOUT_MS);
|
|
@@ -473,8 +709,14 @@ export async function claudeState(io) {
|
|
|
473
709
|
}
|
|
474
710
|
const skill = (await readText(skillPath(io))) !== null;
|
|
475
711
|
let ours = (_event) => undefined;
|
|
712
|
+
const mod = { loads: false, dirs: [], present: false, plugin: false };
|
|
713
|
+
const loads = claude ? claudeVersion(claude) : null;
|
|
714
|
+
mod.loads = loads !== null && loadsMods(loads);
|
|
476
715
|
try {
|
|
477
|
-
const
|
|
716
|
+
const settings = await readSettings(io);
|
|
717
|
+
mod.dirs = (pluginDirs(settings, separatorFor(io.platform)) ?? []).filter(isOurModDir);
|
|
718
|
+
mod.plugin = pluginEnabled(settings);
|
|
719
|
+
const all = objectAt(settings, 'hooks') ?? {};
|
|
478
720
|
ours = (event) => Array.isArray(all[event])
|
|
479
721
|
? all[event]
|
|
480
722
|
.flatMap((group) => group.hooks ?? [])
|
|
@@ -484,6 +726,8 @@ export async function claudeState(io) {
|
|
|
484
726
|
catch { }
|
|
485
727
|
const start = ours('SessionStart');
|
|
486
728
|
const form = typeof start?.command === 'string' ? hookForm(start.command) : null;
|
|
729
|
+
const found = await Promise.all(mod.dirs.map((dir) => exists(join(dir, 'hooks', 'hooks.json'))));
|
|
730
|
+
mod.present = found.length > 0 && found.every(Boolean);
|
|
487
731
|
return {
|
|
488
732
|
claude,
|
|
489
733
|
mcp,
|
|
@@ -491,5 +735,6 @@ export async function claudeState(io) {
|
|
|
491
735
|
hooks: Boolean(start && ours('UserPromptSubmit')),
|
|
492
736
|
stopcheck: Boolean(ours('Stop')),
|
|
493
737
|
form: form ? { ...form, runs: await hookRuns(form) } : null,
|
|
738
|
+
mod,
|
|
494
739
|
};
|
|
495
740
|
}
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// "Confirm in your agent" (guide 2.21, CLI 0.9.0). Some steps an agent takes only on its person's own words in its
|
|
2
|
+
// conversation: a card answer reaches it as a tool's result, and its rules (or its app's) want more for something that
|
|
3
|
+
// can't be undone, credentials, or anything outside its sandbox. That's right, but on 2026-10-03 a Claude Code agent
|
|
4
|
+
// declined to act on an approved "Erase disk12?" card, asked for "yes, erase disk12" in its own chat, and never told
|
|
5
|
+
// Pending You, so Darin had to go and find the session.
|
|
6
|
+
//
|
|
7
|
+
// The Stop hook (stopcheck.ts) now catches a final message that asks the person to type or reply something in this
|
|
8
|
+
// chat: asksToTypeHere finds it, cardFits says whether a card already asks for it (one posted in the turn, or one still
|
|
9
|
+
// open), and confirmReason is what Claude Code reads when none does: post an action card that sends them here, with
|
|
10
|
+
// the exact words as a step's command and, when Claude Code says it, the session's link.
|
|
11
|
+
//
|
|
12
|
+
// Pattern matching only, deliberately narrow (a block costs a whole extra turn): an ask said to the person (an
|
|
13
|
+
// imperative, "please …", "can you …", "I need you to …"), with the words quoted or a plain "yes", and anchored to this
|
|
14
|
+
// chat ("here", "in this chat") or to going ahead ("to confirm", "and I'll …"). Never in code, block quotes or quoted
|
|
15
|
+
// text; never what a program, a page or a message says; never a step somewhere else (their terminal, a prompt, a page,
|
|
16
|
+
// a link) or a standing offer ("anytime"); never a command or slash command to run.
|
|
17
|
+
/** Verbs that hand the person something to type. */
|
|
18
|
+
const VERB = '(?:type|reply|respond|write|say|send|enter|paste|answer)';
|
|
19
|
+
/**
|
|
20
|
+
* What comes right before a verb said to the person: the start of a sentence or clause, "and"/"then", a request
|
|
21
|
+
* ("can you", "I need you to", "I'll wait for you to", "you'll need to", "you can"), then any "please", "just" or
|
|
22
|
+
* "now".
|
|
23
|
+
*/
|
|
24
|
+
const LEAD = String.raw `(?:^|[,;:(—–→]\s*|\s-\s+|\b(?:and|then|or|so)\s+|\b(?:can|could|would|will)\s+you\s+|\b(?:need|want|like|for)\s+you\s+to\s+|\byou(?:’|')ll\s+(?:need|have)\s+to\s+|\byou\s+(?:need|have)\s+to\s+|\byou\s+can\s+|\ball\s+you\s+need\s+to\s+do\s+is\s+)(?:(?:please|just|simply|kindly|now|then)\s+)*`;
|
|
25
|
+
const ASK = new RegExp(`${LEAD}${VERB}\\b`, 'giu');
|
|
26
|
+
/** "Confirm here", "please confirm in this chat", "can you confirm it here". */
|
|
27
|
+
const CONFIRM_HERE = new RegExp(`${LEAD}confirm(?:\\s+(?:it|this|that|so))?\\s+(?:here|below|in\\s+(?:this|our|the)\\s+(?:chat|conversation|session))\\b`, 'iu');
|
|
28
|
+
/** "Confirm (in this chat) by typing “…”". */
|
|
29
|
+
const CONFIRM_BY = /\bconfirm\b[^.!?\n]{0,30}?\bby\s+(?:typing|replying(?:\s+with)?|saying|sending)\s+⟦(\d+)⟧/iu;
|
|
30
|
+
/** This chat: where the words go. */
|
|
31
|
+
const HERE = /\b(?:here|below|in\s+(?:this|our|the)\s+(?:chat|conversation|session)|in\s+chat|in\s+this\s+(?:terminal|window)|(?:back\s+)?to\s+me)\b/iu;
|
|
32
|
+
/** What typing it does: lets the agent go ahead. */
|
|
33
|
+
const PURPOSE = /\b(?:to\s+(?:confirm|proceed|continue|go\s+ahead|approve|start|begin|carry\s+on|unblock\s+me)|(?:and|so|then)\s+I(?:’|')ll\b|(?:and|so)\s+I\s+(?:can|will)\b|if\s+you\s+want\s+me\s+to\b|when\s+you(?:’|')re\s+ready\b|before\s+I\b)/iu;
|
|
34
|
+
/** Plain words people are asked to type without quotes. */
|
|
35
|
+
const PLAIN_WORD = /^\s*(?:back\s+)?(?:with\s+)?(go ahead|ship it|do it|yes|y|ok|okay|go|proceed|confirm|confirmed|continue|approve|approved)\b/iu;
|
|
36
|
+
/**
|
|
37
|
+
* What may come between the verb and the quoted words: "Reply here with “…”", "Type these words in this chat: “…”",
|
|
38
|
+
* "Reply with the exact phrase “…”". Never a recipient ("Reply to Sarah with “…”").
|
|
39
|
+
*/
|
|
40
|
+
const FILLER = /^(?:\s|[,:]|back\b|me\b|it\b|to me\b|with\b|exactly\b|precisely\b|verbatim\b|just\b|the\b|exact\b|following\b|phrase\b|words?\b|text\b|these\b|this\b|that\b|here\b|below\b|in (?:this|our) (?:chat|conversation|session|terminal|window)\b)*$/iu;
|
|
41
|
+
/** Somewhere else to type it: their terminal, a prompt, a page or a form, or a link (but this session's own). */
|
|
42
|
+
const ELSEWHERE = /\b(?:in|into|at|on)\s+(?:your|a|the|that|their)\s+(?:terminal|shell|browser|console|prompt|form|field|dialog|email|inbox|page|app|site|website|portal|dashboard)\b|\bin\s+(?:Terminal|iTerm)\b|\bwhen\s+(?:prompted|asked|it\s+asks)\b|\bat\s+the\s+prompt\b|https?:\/\/(?!claude\.ai\/code\/)/iu;
|
|
43
|
+
/** This chat, said outright, which wins over another place in the same sentence. */
|
|
44
|
+
const THIS_CHAT = /\bin\s+(?:this|our)\s+(?:chat|conversation|session)\b/iu;
|
|
45
|
+
/** A standing offer, not a wait: "Say “stop” anytime". */
|
|
46
|
+
const OFFER = /\b(?:any\s?time|whenever|at\s+any\s+(?:point|time)|if\s+you\s+change\s+your\s+mind|if\s+(?:anything|something)\s+(?:looks|seems|goes))\b/iu;
|
|
47
|
+
/** What something says rather than an ask: "The email ends with: Reply “STOP” here". */
|
|
48
|
+
const DESCRIBED = /\b(?:says?|said|reads?|ends?\s+with|starts?\s+with|shows?|prints?|displays?|asks?|prompts?|tells?\s+\w+|text|message|copy|label|button|template)\s*[:,]?$/iu;
|
|
49
|
+
/** A command or a slash command: a step to run (the general check's business), not words to type to the agent. */
|
|
50
|
+
const COMMAND_LIKE = /^\s*(?:[!/]|\$\s|(?:cd|npm|npx|pnpm|yarn|bun|git|gh|curl|wget|brew|node|python3?|pip|docker|ssh|scp|sudo|claude|codex|wrangler)\s)/u;
|
|
51
|
+
/** Quoted spans the words come in: “…”, "…", `…`, ‘…’ and '…' (not an apostrophe). */
|
|
52
|
+
const QUOTED = /“([^“”\n]{1,160})”|"([^"\n]{1,160})"|`([^`\n]{1,160})`|‘([^‘’\n]{1,160})’|(?<![\p{L}\p{N}])'([^'\n]{1,160})'(?![\p{L}\p{N}])/gu;
|
|
53
|
+
/**
|
|
54
|
+
* The text as it reads to the person: without fenced code and block quotes (quoted words stay), and without Markdown's
|
|
55
|
+
* list markers, headings and emphasis (`**Reply “yes” here**`).
|
|
56
|
+
*/
|
|
57
|
+
export function proseOf(text) {
|
|
58
|
+
const kept = [];
|
|
59
|
+
let fence = null;
|
|
60
|
+
for (const line of text.split('\n')) {
|
|
61
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
62
|
+
if (fence) {
|
|
63
|
+
if (marker?.startsWith(fence))
|
|
64
|
+
fence = null;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (marker) {
|
|
68
|
+
fence = marker;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^\s*>/.test(line))
|
|
72
|
+
continue;
|
|
73
|
+
kept.push(line
|
|
74
|
+
.replace(/^\s*(?:[-*•+]|\d+[.)])\s+/u, '')
|
|
75
|
+
.replace(/^\s*#+\s*/u, '')
|
|
76
|
+
.replace(/\*+|__/gu, ''));
|
|
77
|
+
}
|
|
78
|
+
return kept.join('\n');
|
|
79
|
+
}
|
|
80
|
+
/** Each quoted span replaced by ⟦n⟧, with what it held. */
|
|
81
|
+
function marked(text) {
|
|
82
|
+
const quotes = [];
|
|
83
|
+
const out = text.replace(QUOTED, (...groups) => {
|
|
84
|
+
const inner = groups.slice(1, 6).find((group) => typeof group === 'string');
|
|
85
|
+
quotes.push(inner.trim());
|
|
86
|
+
return `⟦${quotes.length - 1}⟧`;
|
|
87
|
+
});
|
|
88
|
+
return { text: out, quotes };
|
|
89
|
+
}
|
|
90
|
+
/** One sentence's ask, or null. `quotes` are the message's quoted spans, by marker number. */
|
|
91
|
+
function askIn(sentence, quotes) {
|
|
92
|
+
if ((ELSEWHERE.test(sentence) && !THIS_CHAT.test(sentence)) || OFFER.test(sentence))
|
|
93
|
+
return null;
|
|
94
|
+
const by = CONFIRM_BY.exec(sentence);
|
|
95
|
+
if (by) {
|
|
96
|
+
const words = quotes[Number(by[1])] ?? '';
|
|
97
|
+
if (words && !COMMAND_LIKE.test(words))
|
|
98
|
+
return { words };
|
|
99
|
+
}
|
|
100
|
+
for (const found of sentence.matchAll(ASK)) {
|
|
101
|
+
const start = found.index ?? 0;
|
|
102
|
+
// What a program, a page or a message says isn't an ask: "The SMS ends with: Reply “STOP” here".
|
|
103
|
+
if (DESCRIBED.test(sentence.slice(0, start).trimEnd()))
|
|
104
|
+
continue;
|
|
105
|
+
const from = start + found[0].length;
|
|
106
|
+
const tail = sentence.slice(from, from + 140);
|
|
107
|
+
const quote = /⟦(\d+)⟧/u.exec(tail);
|
|
108
|
+
let words = null;
|
|
109
|
+
let after = tail;
|
|
110
|
+
let before = '';
|
|
111
|
+
if (quote && FILLER.test(tail.slice(0, quote.index))) {
|
|
112
|
+
words = quotes[Number(quote[1])] ?? null;
|
|
113
|
+
before = tail.slice(0, quote.index);
|
|
114
|
+
after = tail.slice(quote.index + quote[0].length);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const plain = PLAIN_WORD.exec(tail);
|
|
118
|
+
if (plain) {
|
|
119
|
+
words = plain[1].toLowerCase();
|
|
120
|
+
after = tail.slice(plain[0].length);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (words !== null) {
|
|
124
|
+
if (COMMAND_LIKE.test(words))
|
|
125
|
+
continue;
|
|
126
|
+
// Anchored to this chat or to going ahead, or the ask stands alone: "Reply “yes, erase disk12”."
|
|
127
|
+
const near = after.slice(0, 80);
|
|
128
|
+
const alone = quote !== null && /^[\s.!)…]*$/u.test(after);
|
|
129
|
+
if (HERE.test(before) || HERE.test(near) || PURPOSE.test(near) || alone)
|
|
130
|
+
return { words };
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// "Reply here to confirm": no words, but here and going ahead.
|
|
134
|
+
if (HERE.test(tail.slice(0, 40)) && PURPOSE.test(tail.slice(0, 80)))
|
|
135
|
+
return { words: null };
|
|
136
|
+
}
|
|
137
|
+
return CONFIRM_HERE.test(sentence) ? { words: null } : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether the final message asks the person to type or reply something in this chat, and what: "Reply “yes, erase
|
|
141
|
+
* disk12” here", "To confirm, type `yes`", "Please reply yes here and I'll merge it", "Confirm here". Null otherwise.
|
|
142
|
+
*/
|
|
143
|
+
export function asksToTypeHere(text) {
|
|
144
|
+
const { text: prose, quotes } = marked(proseOf(text));
|
|
145
|
+
for (const sentence of prose.split(/(?<=[.!?])\s+|\n+/u)) {
|
|
146
|
+
const ask = askIn(sentence.trim(), quotes);
|
|
147
|
+
if (ask)
|
|
148
|
+
return ask;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
/** Words that don't tell one card from another: "yes", "confirm", "please"… */
|
|
153
|
+
const COMMON = new Set([
|
|
154
|
+
'yes',
|
|
155
|
+
'y',
|
|
156
|
+
'ok',
|
|
157
|
+
'okay',
|
|
158
|
+
'sure',
|
|
159
|
+
'please',
|
|
160
|
+
'confirm',
|
|
161
|
+
'confirmed',
|
|
162
|
+
'go',
|
|
163
|
+
'ahead',
|
|
164
|
+
'proceed',
|
|
165
|
+
'continue',
|
|
166
|
+
'approve',
|
|
167
|
+
'approved',
|
|
168
|
+
'do',
|
|
169
|
+
'it',
|
|
170
|
+
'i',
|
|
171
|
+
'the',
|
|
172
|
+
'a',
|
|
173
|
+
'an',
|
|
174
|
+
'to',
|
|
175
|
+
'and',
|
|
176
|
+
'of',
|
|
177
|
+
'me',
|
|
178
|
+
'my',
|
|
179
|
+
'this',
|
|
180
|
+
'that',
|
|
181
|
+
]);
|
|
182
|
+
const wordsOf = (text) => text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
183
|
+
/** The words that say what's being confirmed: "yes, erase disk12" → erase, disk12. */
|
|
184
|
+
export const telling = (words) => words === null ? [] : [...new Set(wordsOf(words).filter((word) => !COMMON.has(word)))];
|
|
185
|
+
/**
|
|
186
|
+
* Whether a card asks for this: one posted in the turn (`posted`: the words its call carried) or one still open
|
|
187
|
+
* (`open`: its title). A card fits when it names everything that tells the words apart ("erase" and "disk12" for
|
|
188
|
+
* “yes, erase disk12”). With nothing to tell them by ("yes", or no words at all), any card posted in the turn fits,
|
|
189
|
+
* and an open one only when its title asks to confirm; an open "Confirm …" card that names some of them fits too.
|
|
190
|
+
*/
|
|
191
|
+
export function cardFits(text, words, where) {
|
|
192
|
+
const wanted = telling(words);
|
|
193
|
+
const has = new Set(wordsOf(text));
|
|
194
|
+
if (wanted.length && wanted.every((word) => has.has(word)))
|
|
195
|
+
return true;
|
|
196
|
+
if (where === 'posted')
|
|
197
|
+
return wanted.length === 0;
|
|
198
|
+
return /\bconfirm/i.test(text) && (wanted.length === 0 || wanted.some((word) => has.has(word)));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The session's link when Claude Code says it: Remote Control's session (CLAUDE_CODE_BRIDGE_SESSION_ID, in `session_`
|
|
202
|
+
* form) or a session on the web (CLAUDE_CODE_REMOTE_SESSION_ID, whose `cse_` is the link's `session_`), as
|
|
203
|
+
* https://code.claude.com/docs/en/env-vars documents them. Null when neither is set.
|
|
204
|
+
*/
|
|
205
|
+
export function sessionLink(env) {
|
|
206
|
+
const bridge = env.CLAUDE_CODE_BRIDGE_SESSION_ID?.trim();
|
|
207
|
+
if (bridge && /^session_[A-Za-z0-9]{8,80}$/.test(bridge))
|
|
208
|
+
return `https://claude.ai/code/${bridge}`;
|
|
209
|
+
const remote = env.CLAUDE_CODE_REMOTE_SESSION_ID?.trim();
|
|
210
|
+
if (remote && /^(?:cse|session)_[A-Za-z0-9]{8,80}$/.test(remote))
|
|
211
|
+
return `https://claude.ai/code/${remote.replace(/^cse_/, 'session_')}`;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/** The most of the asked-for words the reason repeats. */
|
|
215
|
+
export const REASON_WORDS = 60;
|
|
216
|
+
/**
|
|
217
|
+
* What Claude Code reads when it asked for words here and no card says so: post the action card that sends them here,
|
|
218
|
+
* or reopen the card they already answered (about 65 tokens, 80 with a link).
|
|
219
|
+
*/
|
|
220
|
+
export function confirmReason(words, link) {
|
|
221
|
+
const chars = [...(words?.replace(/\s+/g, ' ').trim() ?? '')];
|
|
222
|
+
const shown = chars.length > REASON_WORDS
|
|
223
|
+
? `${chars
|
|
224
|
+
.slice(0, REASON_WORDS - 1)
|
|
225
|
+
.join('')
|
|
226
|
+
.trimEnd()}…`
|
|
227
|
+
: chars.join('');
|
|
228
|
+
const asked = shown ? `to type “${shown}” here` : 'to confirm here';
|
|
229
|
+
const what = shown ? 'those exact words' : 'the words to type';
|
|
230
|
+
return `You asked them ${asked}, but no open Pending You card says so: post an action card “Confirm in Claude Code on <computer>: <step>” with ${what} as a step command, where Claude Code on <computer>${link ? `, ${link}` : ''}, done what you’ll do (a card they answered: reply_in_thread with reopen).`;
|
|
231
|
+
}
|