pendingyou 0.9.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 +49 -0
- package/README.md +80 -22
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- 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 +2 -1
- 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 +3 -2
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/credentials.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The command line's sign-in, one per Pending You address (production, staging, a local server), in
|
|
2
2
|
// ~/.config/pendingyou/credentials.json: readable only by you (0600, in a 0700 folder), written whole, never printed
|
|
3
3
|
// and never logged. A file someone loosened is tightened again on the next read.
|
|
4
|
-
import { chmod, stat } from 'node:fs/promises';
|
|
4
|
+
import { chmod, rm, stat } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { configDir, readJson, withLock, writeWhole } from "./files.js";
|
|
7
7
|
export const credentialsPath = (io) => join(configDir(io), 'credentials.json');
|
|
@@ -57,3 +57,41 @@ export async function updateCredential(io, origin, change) {
|
|
|
57
57
|
return next;
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
+
export const clientsPath = (io) => join(configDir(io), 'clients.json');
|
|
61
|
+
async function readClients(io) {
|
|
62
|
+
const file = await readJson(clientsPath(io)).catch(() => null);
|
|
63
|
+
return file && typeof file.origins === 'object' && file.origins !== null
|
|
64
|
+
? file
|
|
65
|
+
: { version: 1, origins: {} };
|
|
66
|
+
}
|
|
67
|
+
/** The client this computer's connection for `origin` signed in with last, if any. */
|
|
68
|
+
export async function connectionClient(io, origin) {
|
|
69
|
+
const entry = (await readClients(io)).origins[origin];
|
|
70
|
+
return typeof entry?.clientId === 'string' ? entry.clientId : null;
|
|
71
|
+
}
|
|
72
|
+
/** The name this computer's connection for `origin` signed in with ("devbox"), if it's known. */
|
|
73
|
+
export async function connectionMachine(io, origin) {
|
|
74
|
+
const entry = (await readClients(io)).origins[origin];
|
|
75
|
+
return typeof entry?.machine === 'string' && entry.machine ? entry.machine : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Remembers the client this computer's connection for `origin` signed in with, or with null forgets it (uninstall),
|
|
79
|
+
* removing the file once it holds none.
|
|
80
|
+
*/
|
|
81
|
+
export async function saveConnectionClient(io, origin, clientId, machine) {
|
|
82
|
+
const path = clientsPath(io);
|
|
83
|
+
if (!clientId && !(await connectionClient(io, origin)))
|
|
84
|
+
return;
|
|
85
|
+
await withLock(path, io, async () => {
|
|
86
|
+
const file = await readClients(io);
|
|
87
|
+
if (clientId)
|
|
88
|
+
file.origins[origin] = { clientId, ...(machine ? { machine } : {}) };
|
|
89
|
+
else
|
|
90
|
+
delete file.origins[origin];
|
|
91
|
+
const text = `${JSON.stringify({ version: 1, origins: file.origins }, null, 2)}\n`;
|
|
92
|
+
if (Object.keys(file.origins).length === 0)
|
|
93
|
+
await rm(path, { force: true });
|
|
94
|
+
else
|
|
95
|
+
await writeWhole(path, text, { secret: true });
|
|
96
|
+
});
|
|
97
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -34,15 +34,15 @@ export async function readJson(path) {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* Writes a whole file at once. `secret` files and their folder are private to you (0600 and 0700), however they were
|
|
37
|
-
* before; other files keep the mode they had.
|
|
37
|
+
* before; a `mode` given is the file's (a script's 0755); other files keep the mode they had.
|
|
38
38
|
*/
|
|
39
39
|
export async function writeWhole(path, text, options = {}) {
|
|
40
40
|
const folder = dirname(path);
|
|
41
41
|
await mkdir(folder, { recursive: true, ...(options.secret ? { mode: 0o700 } : {}) });
|
|
42
42
|
if (options.secret)
|
|
43
43
|
await chmod(folder, 0o700);
|
|
44
|
-
let mode = options.secret ? 0o600 : 0o644;
|
|
45
|
-
if (!options.secret) {
|
|
44
|
+
let mode = options.mode ?? (options.secret ? 0o600 : 0o644);
|
|
45
|
+
if (!options.secret && options.mode === undefined) {
|
|
46
46
|
try {
|
|
47
47
|
mode = (await stat(path)).mode & 0o777;
|
|
48
48
|
}
|
package/dist/hold.js
CHANGED
|
@@ -4,20 +4,41 @@
|
|
|
4
4
|
// person; a fallback that ran; or it closed. Then it prints a few lines and exits, and Claude Code, which re-invokes
|
|
5
5
|
// the model when a background command ends, wakes up with the answer, even if it was idle at the prompt.
|
|
6
6
|
//
|
|
7
|
-
// It lets go cleanly after --timeout (4 hours by default), on Ctrl-C or when Claude Code stops it
|
|
8
|
-
//
|
|
9
|
-
// (
|
|
10
|
-
|
|
7
|
+
// It lets go cleanly after --timeout (4 hours by default), on Ctrl-C or when Claude Code stops it (a background
|
|
8
|
+
// command's own time limit can stop it sooner). Since 0.10.0 it then says to start it again in the background, with the
|
|
9
|
+
// exact command: a session without the wake mod (Claude Code before 2.1.287, or no plugin and no init) has nothing
|
|
10
|
+
// else to wake it, and two sessions missed answers overnight when it said not to (2026-10-04). Exit codes: 0 when it
|
|
11
|
+
// heard something or stopped cleanly; 1 when it can't wait at all (not signed in, no such request, bad arguments).
|
|
12
|
+
import { getJson, SESSION_APP, SignInNeeded, Unavailable } from "./api.js";
|
|
13
|
+
import { DEFAULT_ORIGIN, DEFAULT_TIMEOUT_MS } from "./args.js";
|
|
11
14
|
import { readCredential } from "./credentials.js";
|
|
12
|
-
import { describe
|
|
13
|
-
import { markHanded, momentOf
|
|
15
|
+
import { describe } from "./format.js";
|
|
16
|
+
import { markHanded, momentOf } from "./state.js";
|
|
14
17
|
/** The longest one long poll asks the server to wait. */
|
|
15
18
|
export const LONG_POLL_SECONDS = 25;
|
|
16
19
|
/** After this long, a pause between long polls: an answer hours later needn't arrive within seconds. */
|
|
17
20
|
const PATIENT_AFTER_MS = 30 * 60 * 1000;
|
|
18
21
|
const PATIENT_PAUSE_MS = 30_000;
|
|
19
22
|
const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
|
|
20
|
-
|
|
23
|
+
/** A duration as `--timeout` takes it: 4h, 90m or 45s. */
|
|
24
|
+
export function durationText(ms) {
|
|
25
|
+
if (ms % 3_600_000 === 0)
|
|
26
|
+
return `${ms / 3_600_000}h`;
|
|
27
|
+
if (ms % 60_000 === 0)
|
|
28
|
+
return `${ms / 60_000}m`;
|
|
29
|
+
return `${Math.round(ms / 1000)}s`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The command that holds for this request again, as it was started: its origin and timeout when they aren't the
|
|
33
|
+
* defaults (`--origin` rather than PENDINGYOU_ORIGIN=…, so init's `Bash(npx pendingyou hold:*)` still allows it).
|
|
34
|
+
*/
|
|
35
|
+
export function holdCommand(requestId, origin = DEFAULT_ORIGIN, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
36
|
+
return [
|
|
37
|
+
`npx pendingyou hold ${requestId}`,
|
|
38
|
+
...(origin === DEFAULT_ORIGIN ? [] : [`--origin ${origin}`]),
|
|
39
|
+
...(timeoutMs === DEFAULT_TIMEOUT_MS ? [] : [`--timeout ${durationText(timeoutMs)}`]),
|
|
40
|
+
].join(' ');
|
|
41
|
+
}
|
|
21
42
|
const minutes = (ms) => {
|
|
22
43
|
if (ms < 60_000)
|
|
23
44
|
return `${Math.round(ms / 1000)} seconds`;
|
|
@@ -29,6 +50,7 @@ const minutes = (ms) => {
|
|
|
29
50
|
};
|
|
30
51
|
export async function hold(io, options) {
|
|
31
52
|
const { origin, requestId } = options;
|
|
53
|
+
const again = holdCommand(requestId, origin, options.timeoutMs);
|
|
32
54
|
const started = io.now();
|
|
33
55
|
const deadline = started + options.timeoutMs;
|
|
34
56
|
const print = (lines) => io.out(`${lines.join('\n')}\n`);
|
|
@@ -43,28 +65,27 @@ export async function hold(io, options) {
|
|
|
43
65
|
};
|
|
44
66
|
if (!(await readCredential(io, origin)))
|
|
45
67
|
return signInNeeded(false);
|
|
46
|
-
await startWaiting(io, origin, requestId, io.cwd).catch(() => { });
|
|
47
68
|
let failures = 0;
|
|
48
69
|
for (;;) {
|
|
49
70
|
if (io.signal.aborted) {
|
|
50
71
|
print([
|
|
51
72
|
`Pending You: stopped waiting on ${requestId}.`,
|
|
52
|
-
|
|
73
|
+
`If you’re still waiting on their answer, start it again as a background command: ${again}`,
|
|
53
74
|
]);
|
|
54
75
|
return 0;
|
|
55
76
|
}
|
|
56
77
|
const left = deadline - io.now();
|
|
57
78
|
if (left <= 0) {
|
|
58
79
|
print([
|
|
59
|
-
`Pending You: no answer on ${requestId} after ${minutes(options.timeoutMs)}, so
|
|
60
|
-
|
|
80
|
+
`Pending You: no answer on ${requestId} after ${minutes(options.timeoutMs)}, so this hold stopped.`,
|
|
81
|
+
`Start it again now as a background command, so their answer still wakes you: ${again}`,
|
|
61
82
|
]);
|
|
62
83
|
return 0;
|
|
63
84
|
}
|
|
64
85
|
const wait = Math.max(1, Math.min(LONG_POLL_SECONDS, Math.floor(left / 1000)));
|
|
65
86
|
let result;
|
|
66
87
|
try {
|
|
67
|
-
result = await getJson(io, origin, `/mcp/cli/requests/${encodeURIComponent(requestId)}?wait=${wait}`, (wait + 20) * 1000, io.signal);
|
|
88
|
+
result = await getJson(io, origin, `/mcp/cli/requests/${encodeURIComponent(requestId)}?wait=${wait}&source=${SESSION_APP}`, (wait + 20) * 1000, io.signal);
|
|
68
89
|
}
|
|
69
90
|
catch (error) {
|
|
70
91
|
if (io.signal.aborted)
|
|
@@ -80,7 +101,6 @@ export async function hold(io, options) {
|
|
|
80
101
|
}
|
|
81
102
|
failures = 0;
|
|
82
103
|
if (result.status === 404) {
|
|
83
|
-
await stopWaiting(io, origin, [requestId]).catch(() => { });
|
|
84
104
|
print([
|
|
85
105
|
`Pending You: can’t find ${requestId} among your person’s Claude Code questions on ${origin}.`,
|
|
86
106
|
'Check the id from post_request. If this Claude Code signed in to Pending You as someone else, use get_request instead.',
|
|
@@ -93,10 +113,8 @@ export async function hold(io, options) {
|
|
|
93
113
|
continue;
|
|
94
114
|
}
|
|
95
115
|
if (heard.ready) {
|
|
96
|
-
print(describe(heard, `run \`${
|
|
97
|
-
await markHanded(io, origin, [
|
|
98
|
-
{ requestId, moment: momentOf(heard), closed: isClosed(heard) },
|
|
99
|
-
]).catch(() => { });
|
|
116
|
+
print(describe(heard, `run \`${again}\` in the background again`));
|
|
117
|
+
await markHanded(io, origin, [{ requestId, moment: momentOf(heard) }]).catch(() => { });
|
|
100
118
|
return 0;
|
|
101
119
|
}
|
|
102
120
|
if (io.now() - started > PATIENT_AFTER_MS)
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { DEFAULT_ORIGIN, parseArgs } from "./args.js";
|
|
|
4
4
|
export { readCredential, updateCredential } from "./credentials.js";
|
|
5
5
|
export { main } from "./main.js";
|
|
6
6
|
export { CLIENT_NAME, discover, register, revoke, SCOPE, signIn, signInWithDevice, } from "./oauth.js";
|
|
7
|
+
export { qrCode, qrLines } from "./qr.js";
|
|
7
8
|
export { VERSION } from "./version.js";
|
package/dist/install.js
CHANGED
|
@@ -21,6 +21,8 @@ export const cliRoot = (io) => join(configDir(io), 'cli');
|
|
|
21
21
|
export const cliPrefix = (io, version = VERSION) => join(cliRoot(io), version);
|
|
22
22
|
/** The command line inside a prefix. */
|
|
23
23
|
export const cliScript = (prefix) => join(prefix, 'node_modules', 'pendingyou', 'dist', 'cli.js');
|
|
24
|
+
/** The package inside a prefix, where its mod/ is (0.10.0). */
|
|
25
|
+
export const cliPackage = (prefix) => join(prefix, 'node_modules', 'pendingyou');
|
|
24
26
|
/** How long npm may take to install the package (it usually takes a few seconds). */
|
|
25
27
|
const NPM_TIMEOUT_MS = 180_000;
|
|
26
28
|
const exists = (path) => stat(path).then(() => true, () => false);
|
|
@@ -55,10 +57,10 @@ async function runningPackage(io) {
|
|
|
55
57
|
const manifest = await readJson(join(root, 'package.json')).catch(() => null);
|
|
56
58
|
return manifest?.name === 'pendingyou' && manifest.version === VERSION ? root : null;
|
|
57
59
|
}
|
|
58
|
-
/** Copies the package's published files (no dependencies to bring: it has none). */
|
|
60
|
+
/** Copies the package's published files (no dependencies to bring: it has none), the wake mod included. */
|
|
59
61
|
async function copyPackage(from, to) {
|
|
60
62
|
await mkdir(to, { recursive: true });
|
|
61
|
-
for (const name of ['package.json', 'dist', 'README.md', 'CHANGELOG.md', 'LICENSE'])
|
|
63
|
+
for (const name of ['package.json', 'dist', 'mod', 'README.md', 'CHANGELOG.md', 'LICENSE'])
|
|
62
64
|
if (await exists(join(from, name)))
|
|
63
65
|
await cp(join(from, name), join(to, name), { recursive: true });
|
|
64
66
|
}
|
package/dist/io.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { homedir, hostname } from 'node:os';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
-
export function runProgram(command, args, timeoutMs = 60_000) {
|
|
6
|
+
export function runProgram(command, args, timeoutMs = 60_000, options = {}) {
|
|
7
7
|
return new Promise((resolve) => {
|
|
8
8
|
let stdout = '';
|
|
9
9
|
let stderr = '';
|
|
@@ -12,6 +12,8 @@ export function runProgram(command, args, timeoutMs = 60_000) {
|
|
|
12
12
|
child = spawn(command, [...args], {
|
|
13
13
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
14
14
|
windowsHide: true,
|
|
15
|
+
// Exactly that environment, nothing of this process's.
|
|
16
|
+
...(options.env ? { env: options.env } : {}),
|
|
15
17
|
});
|
|
16
18
|
}
|
|
17
19
|
catch {
|