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/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
- return { url: typeof url === 'string' ? url : null, scope: 'user' };
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' && existing?.url && sameUrl(existing.url, url))
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
- if (existing) {
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
- ? `Replaced the pendingyou MCP server (was ${replacing.url}) with ${url}. Restart Claude Code, then run /mcp, choose pendingyou and Authenticate.`
319
- : `Added the pendingyou MCP server (${url}) for every project.`,
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
- /** Everything init does in Claude Code (not the sign-in). Idempotent. Each step is reported as it's done. */
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
- if (!io.env.PENDINGYOU_SELF) {
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: true,
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], installed),
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, installed?.version ?? null);
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: installed ? { version: installed.version, prefix: installed.prefix } : null,
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
- if (removed.changed)
430
- await writeSettings(io, removed.settings);
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 all = objectAt(await readSettings(io), 'hooks') ?? {};
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
  }
@@ -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, saying how the
8
- // answer will still arrive. Exit codes: 0 when it heard something or stopped cleanly; 1 when it can't wait at all
9
- // (not signed in, no such request, bad arguments).
10
- import { getJson, SignInNeeded, Unavailable } from "./api.js";
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, isClosed } from "./format.js";
13
- import { markHanded, momentOf, startWaiting, stopWaiting } from "./state.js";
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
- const holdCommand = (requestId) => `npx pendingyou hold ${requestId}`;
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
- 'Their answer still reaches you with your next message or session; or call get_request at a breakpoint.',
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 I stopped waiting.`,
60
- 'Don’t start another hold now. Their answer reaches you with your next message or session, or check list_pending at a breakpoint.',
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 \`${holdCommand(requestId)}\` in the background again`));
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 {