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/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
  }
@@ -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
+ }