pendingyou 0.21.0 → 0.23.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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0 (2026-10-06)
4
+
5
+ - The Claude Code wake mod survives upgrades. `init` now copies it to `~/.config/pendingyou/mod`, a folder that stays
6
+ put across versions, and points `CLAUDE_CODE_PLUGIN_DIRS` there, so the entry never changes again. Before, it named
7
+ the version's own folder, which the next upgrade removed: a session already open (Claude Code reads its plugin
8
+ folders only as a session starts) was left with no wake mod and nothing said, and answers reached it only when you
9
+ typed. Open interactive sessions reload the mod when `init` updates it. `init` keeps the newest older copy for
10
+ sessions started before this version, and `status` says when the entry still names a version.
11
+ - A session the wake mod doesn't run in is told so, once: with its next message, the hooks say “Pending You can’t wake
12
+ this session: Claude Code started before Pending You’s wake was set up. Ask your person to restart it (/exit, then
13
+ claude --continue) at a good moment.”, and to background a hold until then. The mod notes each session it runs in
14
+ (`~/.config/pendingyou/wake/sessions/`), and presence remembers when an older mod said a session is live.
15
+ - `init` names the Claude Code sessions open here that started before the wake mod was set up, by the name they go by
16
+ and their folder, and says to restart each at a good moment.
17
+ - A question handed to your agent by its name reaches it in whatever folder it works. The wake mod and the hooks took
18
+ one only in the folder Pending You heard it in (its task's, or the agent's last task's), so an agent working elsewhere
19
+ never heard it. The hooks now send the session's name, and the mod reads Pending You's `byName`; one handed to the app
20
+ with no agent still goes by its folder. Needs Pending You's matching release.
21
+
22
+ ## 0.22.1 (2026-10-06)
23
+
24
+ - The Herdr plugin's popup finishes noting your last key press (what keeps your phone quiet while you're in Herdr)
25
+ before it closes, rather than leaving it half-written in the plugin's state folder.
26
+
27
+ ## 0.22.0 (2026-10-06)
28
+
29
+ - Trusting Codex's hooks is one command now. When `init` or `status` finds Pending You's hooks in Codex but not trusted
30
+ yet, it prints the command that opens a Codex here (`codex` on PATH, else the Codex command line the ChatGPT app
31
+ brings, by its full path, quoted) and says to type `/hooks` there, look over Pending You's hooks and trust them. At a
32
+ terminal it then waits, looking again every 3 seconds for up to 10 minutes, and says "Ready: Codex hears answers
33
+ right away" once they're trusted (Ctrl-C stops the wait; `npx pendingyou status` checks later). Run by an agent, it
34
+ prints the same steps without waiting. It never trusts them for you.
35
+ - The instructions no longer send you to a Hooks page in the Codex app's Settings, which people couldn't find, nor to
36
+ other ways of trusting the hooks that haven't been seen working: init's last lines and `status` lead with `/hooks`.
37
+ - Looking for the Codex inside the ChatGPT or Codex app, the command line tries the `CodexCLI.app` it carries first,
38
+ then its `bin` folder.
39
+
3
40
  ## 0.21.0 (2026-10-06)
4
41
 
5
42
  - Answer from Herdr. The Pending You plugin for Herdr (0.2) can now answer your cards from its popup, once you sign
package/README.md CHANGED
@@ -41,8 +41,8 @@ Finish setting up Pending You: it’s connected here already.
41
41
  ```
42
42
 
43
43
  The first session finishes setting up on its own: it checks in with Pending You, reports what's set up and sends you a
44
- test card. Claude Code 2.1.287 or later starts that turn by itself; otherwise (and in Codex, after you trust its hooks:
45
- “Trust all and continue” in Codex CLI, Review hooks › Allow all in the Codex app) the session start adds one line with
44
+ test card. Claude Code 2.1.287 or later starts that turn by itself; otherwise (and in Codex, after you trust its hooks in
45
+ `/hooks`) the session start adds one line with
46
46
  the three calls that are left, and you say the sentence.
47
47
  `npx pendingyou status` says what's set up for each agent, what's missing with the command that fixes it, and how setup
48
48
  stands. `npx pendingyou uninstall` removes exactly what `init` added.
@@ -99,9 +99,11 @@ default_tools_approval_mode = "approve"
99
99
  and hooks in `~/.codex/hooks.json`: a session start, each message, Pending You's card tools (so the Stop check knows a
100
100
  turn posted, and so the thread is woken), and the Stop check; and (0.15.0) Codex's permission prompt and every tool
101
101
  call that ran (a card when Codex waits for your OK, [below](#when-claude-code-waits-for-your-ok)) and the session's end
102
- ([below](#which-sessions-are-open)). **Codex runs a hook only once you trust it**: in Codex CLI, start `codex` and
103
- choose “Trust all and continue” (or trust them in `/hooks`); in the Codex app, click Review hooks › Allow all (or
104
- Settings › Hooks › Trust all). `status` says which are trusted. Upgrading from 0.14.0 adds three hooks, which Codex
102
+ ([below](#which-sessions-are-open)). **Codex runs a hook only once you trust it**, in Codex's `/hooks`. When `init` or
103
+ `status` finds Pending You's hooks not trusted yet, it prints the one command that opens a Codex here (`codex`, or
104
+ the one the ChatGPT app brings, by its full path) and says to type `/hooks` there and trust them; at a terminal it
105
+ then waits (every 3 seconds, for up to 10 minutes; Ctrl-C stops it) until Codex trusts them, and says whether Codex is
106
+ ready. It never trusts them for you. `status` says which are trusted. Upgrading from 0.14.0 adds three hooks, which Codex
105
107
  skips until you trust them once; `init` says so, and the ones you trusted stay trusted. A `pendingyou` server or
106
108
  hooks Codex's `/import` copied from Claude Code (without its sign-in) are replaced.
107
109
 
@@ -352,6 +354,11 @@ with `claude --continue`. Under the prompt it shows `⚠ pendingyou-wake: 2 wait
352
354
  `npx pendingyou hold`, the mod answers it at once and nothing runs in the background. With the Pending You plugin
353
355
  installed as well, only one copy acts. `npx pendingyou status` says whether the mod is loaded.
354
356
 
357
+ Claude Code loads the mod only as a session starts, so a session that was already open when you ran `init` the first
358
+ time can't be woken until you restart it (`/exit`, then `claude --continue`): `init` names those sessions, and the
359
+ session itself is told once with your next message. Later upgrades don't need a restart: the mod lives in
360
+ `~/.config/pendingyou/mod`, which `init` updates in place, and open sessions reload it.
361
+
355
362
  The mod's checks are Pending You tool calls like Claude Code's own, so they need Pending You's tools allowed, which
356
363
  `init` does (`mcp__pendingyou`). If they aren't, the line under the prompt says which rule to add in `/permissions`, and
357
364
  holds run as before.
@@ -2,7 +2,8 @@ import { addCommand, claudeState, helperServer, installClaude, mcpFromClaude, mc
2
2
  import { connectionMachine, readCredential } from "../credentials.js";
3
3
  import { differs, serverForFolder, siteOf } from "../environment.js";
4
4
  import { originArgs } from "../hooks.js";
5
- import { claudeVersion, loadsMods, MOD_CLAUDE } from "../mod.js";
5
+ import { modConfigured, pruneLoadedNotes, unwokenLines, unwokenSessions, } from "../loaded.js";
6
+ import { claudeVersion, isOurModDir, loadsMods, MOD_CLAUDE } from "../mod.js";
6
7
  import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, machineOf, usesHelper, } from "../remote.js";
7
8
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
8
9
  import { shimPath } from "../shim.js";
@@ -144,32 +145,41 @@ export const claudeCode = {
144
145
  plan = { action: 'add' };
145
146
  const signIn = mode === 'helper' ? 'connection' : 'hear';
146
147
  const added = plan.action !== 'keep';
148
+ let unwoken = [];
147
149
  return {
148
150
  app: claudeCode,
149
151
  detected,
150
152
  signIn,
151
- install: (ictx) => installClaude(io, ctx.origin, {
152
- version: detected.version,
153
- mcp: plan,
154
- mode,
155
- helper: ictx.helper,
156
- plugin: Boolean(plugin),
157
- copy: ictx.copy,
158
- hookLine: ictx.hookLine,
159
- report: ictx.report,
160
- progress: ictx.progress,
161
- ...(ctx.permissionCards === undefined ? {} : { permissionCards: ctx.permissionCards }),
162
- }),
153
+ install: async (ictx) => {
154
+ const steps = await installClaude(io, ctx.origin, {
155
+ version: detected.version,
156
+ mcp: plan,
157
+ mode,
158
+ helper: ictx.helper,
159
+ plugin: Boolean(plugin),
160
+ copy: ictx.copy,
161
+ hookLine: ictx.hookLine,
162
+ report: ictx.report,
163
+ progress: ictx.progress,
164
+ ...(ctx.permissionCards === undefined ? {} : { permissionCards: ctx.permissionCards }),
165
+ });
166
+ await pruneLoadedNotes(io).catch(() => { });
167
+ if (await modConfigured(io))
168
+ unwoken = await unwokenSessions(io).catch(() => []);
169
+ return steps;
170
+ },
163
171
  next: (ictx) => {
172
+ const restart = unwokenLines(unwoken);
164
173
  if (mode === 'oauth') {
165
174
  const authenticate = added || !ictx.signedIn;
166
175
  return {
167
176
  lines: [
177
+ ...restart,
168
178
  authenticate
169
179
  ? 'Next, in Claude Code: if it’s open, restart it (/exit, then claude --continue), run /mcp, choose pendingyou and Authenticate. Then ask Claude Code to finish setting up Pending You.'
170
180
  : 'Next, in Claude Code: if it’s open, restart it (/exit, then claude --continue), so the hooks load. Then ask Claude Code to finish setting up Pending You.',
171
181
  ],
172
- together: `- Claude Code: restart it (/exit, then claude --continue)${authenticate ? ', run /mcp, choose pendingyou and Authenticate' : ''}, then ask it to finish setting up Pending You.`,
182
+ together: `- Claude Code: restart it (/exit, then claude --continue)${authenticate ? ', run /mcp, choose pendingyou and Authenticate' : ''}, then ask it to finish setting up Pending You.${restart.length ? ` ${restart.join(' ')}` : ''}`,
173
183
  say: false,
174
184
  };
175
185
  }
@@ -177,6 +187,7 @@ export const claudeCode = {
177
187
  const byItself = version !== null && loadsMods(version);
178
188
  return {
179
189
  lines: [
190
+ ...restart,
180
191
  `Next: ${RESTART}.`,
181
192
  byItself
182
193
  ? 'It finishes setting up by itself and sends you a test card. If it doesn’t, say this to it:'
@@ -184,7 +195,7 @@ export const claudeCode = {
184
195
  '',
185
196
  FINISH_SAY,
186
197
  ],
187
- together: `- Claude Code: ${RESTART}. ${byItself ? 'It finishes setting up by itself and sends you a test card.' : 'Then say the line below to it.'}`,
198
+ together: `- Claude Code: ${RESTART}. ${byItself ? 'It finishes setting up by itself and sends you a test card.' : 'Then say the line below to it.'}${restart.length ? ` ${restart.join(' ')}` : ''}`,
188
199
  say: true,
189
200
  };
190
201
  },
@@ -269,7 +280,7 @@ async function claudeStatus(io, ctx) {
269
280
  : !mod.loads
270
281
  ? `needs Claude Code ${MOD_CLAUDE} or later (this is ${claude.claude}); until then, a background hold wakes it`
271
282
  : mod.present
272
- ? `in CLAUDE_CODE_PLUGIN_DIRS (${mod.dirs.join(', ')}); a session is woken when you answer, with no hold running`
283
+ ? `in CLAUDE_CODE_PLUGIN_DIRS (${mod.dirs.join(', ')}); a session is woken when you answer, with no hold running${mod.dirs.every((dir) => isOurModDir(dir)) ? `. Its folder names a version, so an update leaves open sessions without it: run npx pendingyou@latest init${flag}` : ''}`
273
284
  : mod.plugin
274
285
  ? 'in the Pending You plugin; a session is woken when you answer, with no hold running'
275
286
  : mod.dirs.length
@@ -4,7 +4,7 @@ const APP_NAMES = ['ChatGPT.app', 'Codex.app'];
4
4
  export function appCodexPaths(home, system = '/Applications') {
5
5
  return [system, join(home, 'Applications')].flatMap((root) => APP_NAMES.flatMap((name) => {
6
6
  const cli = join(root, name, 'Contents', 'Resources', 'codex-cli');
7
- return [join(cli, 'bin', 'codex'), join(cli, 'CodexCLI.app', 'Contents', 'MacOS', 'codex')];
7
+ return [join(cli, 'CodexCLI.app', 'Contents', 'MacOS', 'codex'), join(cli, 'bin', 'codex')];
8
8
  }));
9
9
  }
10
10
  const versionOf = (stdout) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? 'installed';
@@ -19,8 +19,22 @@ export const CODEX_MIN = '0.152.0';
19
19
  export const CODEX_WAKE = '0.157.0';
20
20
  export const CODEX_APP_WAKE = '0.149.0';
21
21
  const FROM_APP = 'the Codex app’s';
22
- const TRUST_CLI = 'in Codex CLI choose “Trust all and continue” (or trust them in /hooks)';
23
- const TRUST_APP = 'in the Codex app click Review hooks › Allow all (or Settings › Hooks › Trust all)';
22
+ const TRUST = 'type /hooks in Codex and trust';
23
+ export function trustSteps(found) {
24
+ const why = 'Codex runs Pending You’s hooks only once you trust them.';
25
+ if (!found)
26
+ return [
27
+ `${why} Open Codex in a terminal (codex, or the one that comes inside the ChatGPT app), type /hooks, look over Pending You’s hooks, and trust them.`,
28
+ ];
29
+ const command = found.app ? `'${found.command.replaceAll("'", `'\\''`)}'` : found.command;
30
+ return [
31
+ `${why} Open Codex in a terminal:`,
32
+ '',
33
+ ` ${command}`,
34
+ '',
35
+ 'Type /hooks, look over Pending You’s hooks, and trust them.',
36
+ ];
37
+ }
24
38
  const DONT_SAVE = 'In the Codex app, don’t press Save on pendingyou in Settings › MCP: that writes it again without this computer’s sign-in (if you did, run init again to put it back).';
25
39
  const CARD_TOOLS = '^mcp__pendingyou__(post_request|update_request|reply_in_thread|cancel_request)$';
26
40
  export const CODEX_HOOKS = [
@@ -161,7 +175,7 @@ async function readHooksFile(io) {
161
175
  };
162
176
  return { path, json: parsed };
163
177
  }
164
- async function hookTrust(io) {
178
+ async function readHookTrust(io) {
165
179
  const file = await readHooksFile(io);
166
180
  const config = (await readText(await configPath(io)).catch(() => null)) ?? '';
167
181
  const hooks = 'json' in file && file.json.hooks && typeof file.json.hooks === 'object'
@@ -193,6 +207,7 @@ async function hookTrust(io) {
193
207
  return { event: spec.event, sub: spec.sub, trust: 'missing' };
194
208
  });
195
209
  }
210
+ const untrusted = async (io) => (await readHookTrust(io)).filter((hook) => hook.trust === 'untrusted' || hook.trust === 'changed');
196
211
  async function signedInByItself(io, program) {
197
212
  const result = await io.run(program, ['mcp', 'list', '--json'], 30_000);
198
213
  if (result.code !== 0)
@@ -433,7 +448,7 @@ export const codex = {
433
448
  report({
434
449
  ok: true,
435
450
  text: merged.changed
436
- ? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks${extra ? `, ${cards ? 'its permission-prompt hooks (a card when Codex waits for your OK; --no-permission-cards leaves them out) ' : ''}and its session-end hook (Pending You hears when a thread is open)` : ''} in ${file.path}. Codex asks you to trust them once.`
451
+ ? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks${extra ? `, ${cards ? 'its permission-prompt hooks (a card when Codex waits for your OK; --no-permission-cards leaves them out) ' : ''}and its session-end hook (Pending You hears when a thread is open)` : ''} in ${file.path}. Codex runs them once you trust them (how: below).`
437
452
  : removed.changed
438
453
  ? `Took out Codex’s permission-prompt hooks, as you asked: no card when Codex waits for your OK. To turn them on: npx pendingyou init --app codex --permission-cards${originArgs(ictx.origin)}`
439
454
  : 'Codex’s hooks were already installed.',
@@ -469,14 +484,17 @@ export const codex = {
469
484
  });
470
485
  return steps;
471
486
  },
472
- next: () => {
487
+ next: async () => {
473
488
  const login = mode === 'oauth' && action !== 'keep';
474
489
  const first = login ? `run ${shWord(program)} mcp login pendingyou, then ` : '';
475
- const start = detected.command
476
- ? `open the Codex app, start a chat in the folder you work in, and trust Pending You’s hooks: click Review hooks › Allow all (or Settings › Hooks › Trust all)`
477
- : `start codex here, or in the folder you work in, and trust Pending You’s hooks: ${TRUST_CLI}; ${TRUST_APP}`;
478
- const again = newHooks
479
- ? `Codex has new Pending You hooks (${newHooks}), which it skips until you trust them once: ${detected.command ? TRUST_APP : `${TRUST_CLI}; ${TRUST_APP}`}.`
490
+ const waiting = (await untrusted(io)).length > 0;
491
+ const start = waiting
492
+ ? 'open Codex as above, type /hooks and trust Pending You’s hooks'
493
+ : detected.command
494
+ ? 'open the Codex app and start a chat in the folder you work in'
495
+ : 'start codex here, or in the folder you work in';
496
+ const again = newHooks && waiting
497
+ ? `Codex has new Pending You hooks (${newHooks}), which it skips until you trust them once.`
480
498
  : null;
481
499
  return {
482
500
  lines: [
@@ -542,7 +560,7 @@ export const codex = {
542
560
  codex.detect(io),
543
561
  codexServer(io),
544
562
  readCodexManifest(io),
545
- hookTrust(io),
563
+ readHookTrust(io),
546
564
  readText(codexSkillPath(io)).catch(() => null),
547
565
  ]);
548
566
  const credential = await readCredential(io, origin, 'codex');
@@ -593,7 +611,7 @@ export const codex = {
593
611
  const hooksOk = core.every((hook) => hook.trust === 'trusted');
594
612
  const hooksText = core.some((hook) => hook.trust === 'missing')
595
613
  ? `not installed; run npx pendingyou init --app codex${flag}`
596
- : `${core.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
614
+ : `${core.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST} them`}`;
597
615
  const extraLine = (label, what, subs, off) => {
598
616
  const hooks = trust.filter((hook) => subs.includes(hook.sub));
599
617
  if (off)
@@ -603,7 +621,7 @@ export const codex = {
603
621
  const trusted = hooks.every((hook) => hook.trust === 'trusted');
604
622
  return trusted
605
623
  ? ` ok ${label} ${what} (${hooks.map((hook) => `${hook.event} trusted`).join(', ')})`
606
- : ` missing ${label} ${what}, once you trust ${hooks.length === 1 ? 'its hook' : 'its hooks'} (${hooks.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}): ${TRUST_CLI}; ${TRUST_APP}`;
624
+ : ` missing ${label} ${what}, once you trust ${hooks.length === 1 ? 'its hook' : 'its hooks'} (${hooks.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}): ${TRUST} ${hooks.length === 1 ? 'it' : 'them'}`;
607
625
  };
608
626
  const ownConnection = credential?.kind === 'connection' && helper;
609
627
  const notOwn = 'off: it needs Codex signed in through this computer’s own connection';
@@ -650,4 +668,12 @@ export const codex = {
650
668
  : 'Not ready: Codex here can’t reach Pending You until the lines marked missing are fixed.');
651
669
  return { lines, ready };
652
670
  },
671
+ async untrustedHooks(io) {
672
+ if ((await untrusted(io)).length === 0)
673
+ return null;
674
+ return {
675
+ lines: trustSteps(await findCodex(io)),
676
+ trusted: async () => (await untrusted(io)).length === 0,
677
+ };
678
+ },
653
679
  };
package/dist/claude.js CHANGED
@@ -5,8 +5,9 @@ import { DEFAULT_ORIGIN } from "./args.js";
5
5
  import { PlainError } from "./errors.js";
6
6
  import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
7
7
  import { findHook, hooksOf, mergeHooks, originArgs, removeHooks } from "./hooks.js";
8
- import { cliPackage, hookForm, hookRuns, shellQuote, } from "./install.js";
9
- import { atLeast, claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pluginDirs, pluginEnabled, separatorFor, withModDir, } from "./mod.js";
8
+ import { cliPackage, hookForm, hookRuns, installMod, shellQuote, } from "./install.js";
9
+ import { removeLoadedNotes } from "./loaded.js";
10
+ import { atLeast, claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pluginDirs, pluginEnabled, separatorFor, stableModDir, withModDir, } from "./mod.js";
10
11
  import { removePermissionFiles } from "./permission.js";
11
12
  import { removePresenceFiles } from "./presence.js";
12
13
  import { helperPath } from "./remote.js";
@@ -396,8 +397,8 @@ async function addMod(io, settings, installed, found) {
396
397
  const version = claudeVersion(found);
397
398
  if (!version || !loadsMods(version))
398
399
  return unchanged;
399
- const dir = installed ? modDir(cliPackage(installed.prefix)) : null;
400
- if (!dir || !(await exists(join(dir, 'hooks', 'hooks.json'))))
400
+ const source = installed ? modDir(cliPackage(installed.prefix)) : null;
401
+ if (!source || !(await exists(join(source, 'hooks', 'hooks.json'))))
401
402
  return {
402
403
  ...unchanged,
403
404
  step: {
@@ -405,18 +406,31 @@ async function addMod(io, settings, installed, found) {
405
406
  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.',
406
407
  },
407
408
  };
408
- const added = withModDir(settings, dir, separatorFor(io.platform));
409
+ const dir = stableModDir(io);
410
+ const separator = separatorFor(io.platform);
411
+ const added = withModDir(settings, dir, separator);
409
412
  if ('invalid' in added)
410
413
  return { ...unchanged, step: { ok: false, text: added.invalid } };
414
+ const copied = await installMod(source, dir).catch(() => null);
415
+ if (!copied)
416
+ return {
417
+ ...unchanged,
418
+ step: { ok: false, text: `Couldn’t copy the wake mod to ${dir}. Run init again.` },
419
+ };
420
+ const moved = (pluginDirs(settings, separator) ?? []).some((entry) => isOurModDir(entry));
411
421
  return {
412
422
  settings: added.settings,
413
423
  changed: added.changed,
414
424
  dir,
415
425
  step: {
416
426
  ok: true,
417
- text: added.changed
418
- ? `Added the wake mod to ${PLUGIN_DIRS} in ${settingsPath(io)}: Claude Code ${version} wakes a session when you answer, with no hold running.`
419
- : 'The wake mod was already in Claude Code’s settings.',
427
+ text: !added.changed
428
+ ? copied.changed
429
+ ? `Updated the wake mod in ${dir}: open Claude Code sessions reload it by themselves.`
430
+ : 'The wake mod was already in Claude Code’s settings.'
431
+ : moved
432
+ ? `Moved the wake mod to ${dir}, a folder that stays put when pendingyou updates, so an update never leaves an open session without it.`
433
+ : `Added the wake mod to ${PLUGIN_DIRS} in ${settingsPath(io)}: Claude Code ${version} wakes a session when you answer, with no hold running.`,
420
434
  },
421
435
  };
422
436
  }
@@ -425,7 +439,7 @@ export async function uninstallClaude(io) {
425
439
  const steps = [];
426
440
  const settings = await readSettings(io);
427
441
  const removed = unmergeSettings(settings, manifest?.permissions ?? []);
428
- const unmodded = withModDir(removed.settings, null, separatorFor(io.platform));
442
+ const unmodded = withModDir(removed.settings, null, separatorFor(io.platform), [stableModDir(io)]);
429
443
  const modRemoved = !('invalid' in unmodded) && unmodded.changed;
430
444
  if (removed.changed || modRemoved)
431
445
  await writeSettings(io, 'invalid' in unmodded ? removed.settings : unmodded.settings);
@@ -437,6 +451,8 @@ export async function uninstallClaude(io) {
437
451
  });
438
452
  if (modRemoved)
439
453
  steps.push({ ok: true, text: `Removed the wake mod from ${PLUGIN_DIRS}.` });
454
+ await rm(stableModDir(io), { recursive: true, force: true }).catch(() => { });
455
+ await removeLoadedNotes(io);
440
456
  await removePermissionFiles(io);
441
457
  await removePresenceFiles(io, 'claude-code');
442
458
  const skill = await removeSkill(manifest?.skill ?? null);
@@ -484,7 +500,7 @@ export async function claudeState(io) {
484
500
  let plugins = [];
485
501
  try {
486
502
  const settings = await readSettings(io);
487
- mod.dirs = (pluginDirs(settings, separatorFor(io.platform)) ?? []).filter(isOurModDir);
503
+ mod.dirs = (pluginDirs(settings, separatorFor(io.platform)) ?? []).filter((entry) => isOurModDir(entry, [stableModDir(io)]));
488
504
  mod.plugin = pluginEnabled(settings);
489
505
  plugins = enabledPlugins(settings);
490
506
  const all = objectAt(settings, 'hooks') ?? {};
@@ -201,6 +201,7 @@ export async function queuePopup(io, place, options) {
201
201
  ended = true;
202
202
  state = { ...state, note: failureWords(error) };
203
203
  };
204
+ const telling = new Set();
204
205
  const here = async () => {
205
206
  await markActive(place, io.now());
206
207
  if (!client || ended || !scopes.has('presence:desk'))
@@ -466,7 +467,9 @@ export async function queuePopup(io, place, options) {
466
467
  if (next.done)
467
468
  return 0;
468
469
  pending = keys.next();
469
- void here();
470
+ const told = here();
471
+ telling.add(told);
472
+ void told.finally(() => telling.delete(told)).catch(() => { });
470
473
  for (const key of options.splitKeys(next.value)) {
471
474
  const [after, effect] = queueKey(state, key, io.now());
472
475
  state = after;
@@ -482,6 +485,7 @@ export async function queuePopup(io, place, options) {
482
485
  stop();
483
486
  await keys.return?.();
484
487
  await feed.catch(() => { });
488
+ await Promise.allSettled(telling);
485
489
  io.signal.removeEventListener('abort', stop);
486
490
  io.out('\x1b[?25h');
487
491
  }
package/dist/init.js CHANGED
@@ -10,6 +10,7 @@ import { headlessReason, helperCommand, removeHelper, writeHelper } from "./remo
10
10
  import { FINISH_SAY } from "./setup.js";
11
11
  import { removeShim, shimPath, writeShim } from "./shim.js";
12
12
  import { checkSignIn, logout, signInApps, titleFrom, titleHere } from "./signin.js";
13
+ import { trustHooks } from "./trust.js";
13
14
  import { VERSION } from "./version.js";
14
15
  const line = (step) => ` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`;
15
16
  async function findApps(io, wanted) {
@@ -175,7 +176,7 @@ export async function init(io, options) {
175
176
  const helped = setUp.some((each) => each.signIn === 'connection' && !each.bridged);
176
177
  const bridged = setUp.some((each) => each.bridged);
177
178
  const shared = await installShared(io, helped, bridged, report);
178
- const nexts = [];
179
+ const installed = [];
179
180
  for (const each of setUp) {
180
181
  if (setUp.length > 1)
181
182
  io.out(`${each.app.name}:\n`);
@@ -191,14 +192,19 @@ export async function init(io, options) {
191
192
  bridge: shared.bridge && each.bridged ? bridgeCommand(io, options.origin, each.app.id) : null,
192
193
  signedIn: how === 'signed-in' || how === 'already',
193
194
  };
194
- const done = await each.install(ictx);
195
- const next = each.next(ictx, done);
196
- if (next)
197
- nexts.push(next);
195
+ installed.push({ each, ictx, done: await each.install(ictx) });
198
196
  }
199
197
  const others = await Promise.all(APPS.filter((app) => !setUp.some((each) => each.app.id === app.id)).map((app) => app.installed(io)));
200
198
  if (!io.env.PENDINGYOU_SELF && !others.some(Boolean))
201
199
  await pruneCli(io, shared.copy?.version ?? null);
200
+ for (const { each } of installed)
201
+ await trustHooks(io, each.app, options.origin);
202
+ const nexts = [];
203
+ for (const { each, ictx, done } of installed) {
204
+ const next = await each.next(ictx, done);
205
+ if (next)
206
+ nexts.push(next);
207
+ }
202
208
  const last = closing(nexts);
203
209
  if (last.length)
204
210
  io.out(`${last.join('\n')}\n`);
@@ -223,16 +229,21 @@ async function coveredApps(io, wanted) {
223
229
  }
224
230
  export async function status(io, options) {
225
231
  const apps = await coveredApps(io, options.apps);
226
- let ready = true;
232
+ const ready = [];
227
233
  const sections = [];
228
234
  for (const app of apps) {
229
235
  const signIn = await checkSignIn(io, options.origin, app.id);
230
236
  const section = await app.status(io, { origin: options.origin, signIn });
231
- ready &&= section.ready;
237
+ ready.push(section.ready);
232
238
  sections.push(section.lines.join('\n'));
233
239
  }
234
240
  io.out(`${sections.join('\n\n')}\n`);
235
- return ready ? 0 : 1;
241
+ for (const [at, app] of apps.entries()) {
242
+ const after = await trustHooks(io, app, options.origin);
243
+ if (after)
244
+ ready[at] = after.ready;
245
+ }
246
+ return ready.every(Boolean) ? 0 : 1;
236
247
  }
237
248
  export async function uninstall(io, options) {
238
249
  const installed = [];
package/dist/install.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { cp, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
3
- import { basename, dirname, isAbsolute, join } from 'node:path';
4
- import { configDir, readJson } from "./files.js";
2
+ import { cp, mkdir, readdir, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
4
+ import { configDir, readJson, readText, writeWhole } from "./files.js";
5
5
  import { VERSION } from "./version.js";
6
6
  export const cliRoot = (io) => join(configDir(io), 'cli');
7
7
  export const cliPrefix = (io, version = VERSION) => join(cliRoot(io), version);
@@ -97,13 +97,59 @@ export async function installCli(io, progress) {
97
97
  await rm(staging, { recursive: true, force: true }).catch(() => { });
98
98
  }
99
99
  }
100
+ function byVersion(a, b) {
101
+ const parts = (version) => version.split('.').map((part) => Number.parseInt(part, 10));
102
+ const [left, right] = [parts(a), parts(b)];
103
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
104
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
105
+ if (difference)
106
+ return difference;
107
+ }
108
+ return 0;
109
+ }
110
+ const VERSION_NAME = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
100
111
  export async function pruneCli(io, keep) {
101
112
  const root = cliRoot(io);
102
113
  const entries = await readdir(root).catch(() => []);
114
+ const previous = entries
115
+ .filter((entry) => entry !== keep && VERSION_NAME.test(entry))
116
+ .sort(byVersion)
117
+ .at(-1);
103
118
  for (const entry of entries)
104
- if (entry !== keep)
119
+ if (entry !== keep && entry !== previous)
105
120
  await rm(join(root, entry), { recursive: true, force: true }).catch(() => { });
106
121
  }
122
+ async function filesUnder(root, at = root) {
123
+ const found = [];
124
+ for (const entry of await readdir(at, { withFileTypes: true })) {
125
+ const path = join(at, entry.name);
126
+ if (entry.isDirectory()) {
127
+ if (at === root && entry.name === 'tests')
128
+ continue;
129
+ found.push(...(await filesUnder(root, path)));
130
+ }
131
+ else if (entry.isFile())
132
+ found.push(relative(root, path));
133
+ }
134
+ return found;
135
+ }
136
+ const MOD_ORDER = ['hooks/wake.js', 'hooks/register.js', 'hooks/hooks.json'];
137
+ const modRank = (path) => {
138
+ const at = MOD_ORDER.indexOf(path.replaceAll('\\', '/'));
139
+ return at === -1 ? MOD_ORDER.length : at;
140
+ };
141
+ export async function installMod(from, to) {
142
+ const files = (await filesUnder(from)).sort((a, b) => modRank(a) - modRank(b));
143
+ let changed = false;
144
+ for (const file of files) {
145
+ const text = await readFile(join(from, file), 'utf8');
146
+ if ((await readText(join(to, file)).catch(() => null)) === text)
147
+ continue;
148
+ await writeWhole(join(to, file), text, { mode: 0o644 });
149
+ changed = true;
150
+ }
151
+ return { changed };
152
+ }
107
153
  export async function removeCli(io) {
108
154
  const root = cliRoot(io);
109
155
  if (!(await exists(root)))
package/dist/loaded.js ADDED
@@ -0,0 +1,88 @@
1
+ import { readdir, rm, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { configDir, readJson } from "./files.js";
4
+ import { holdCommand } from "./hold.js";
5
+ import { readSaid } from "./presence.js";
6
+ import { claimUnwoken } from "./state.js";
7
+ const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
8
+ export const LOADED_GRACE_MS = 15_000;
9
+ export const OPEN_FOR_MS = 12 * 60 * 60_000;
10
+ const KEEP_NOTES_MS = 7 * 24 * 60 * 60_000;
11
+ const notesDir = (io) => join(configDir(io), 'wake', 'sessions');
12
+ export const loadedPath = (io, session) => join(notesDir(io), `${session}.json`);
13
+ export function unwokenText(origin) {
14
+ return `Pending You can’t wake this session: Claude Code started before Pending You’s wake was set up. Ask your person to restart it (/exit, then claude --continue) at a good moment. Until then, after each card, run \`${holdCommand('<requestId>', origin)}\` in the background.`;
15
+ }
16
+ export async function modConfigured(io) {
17
+ const manifest = await readJson(join(configDir(io), 'claude-code.json')).catch(() => null);
18
+ return typeof manifest?.modDir === 'string' && manifest.modDir !== '';
19
+ }
20
+ export async function modLoaded(io, session, said) {
21
+ if (said?.mod)
22
+ return true;
23
+ if (!SESSION.test(session))
24
+ return false;
25
+ return stat(loadedPath(io, session)).then(() => true, () => false);
26
+ }
27
+ export async function unwokenLine(io, origin, session, said) {
28
+ try {
29
+ if (!session || !SESSION.test(session))
30
+ return null;
31
+ if (!(await modConfigured(io)))
32
+ return null;
33
+ const latest = (await readSaid(io, 'claude-code').catch(() => ({})))[session];
34
+ if ((await modLoaded(io, session, said)) || latest?.mod)
35
+ return null;
36
+ if (said?.since !== undefined && io.now() - said.since < LOADED_GRACE_MS)
37
+ return null;
38
+ if (!(await claimUnwoken(io, session)))
39
+ return null;
40
+ return unwokenText(origin);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ export async function unwokenSessions(io) {
47
+ const said = await readSaid(io, 'claude-code').catch(() => ({}));
48
+ const found = [];
49
+ for (const [session, each] of Object.entries(said)) {
50
+ if (each.state !== 'live' || io.now() - each.at > OPEN_FOR_MS)
51
+ continue;
52
+ if (await modLoaded(io, session, each))
53
+ continue;
54
+ found.push({
55
+ session,
56
+ at: each.at,
57
+ ...(each.cwd ? { cwd: each.cwd } : {}),
58
+ ...(each.name ? { name: each.name } : {}),
59
+ });
60
+ }
61
+ return found.sort((a, b) => b.at - a.at).map(({ at: _, ...rest }) => rest);
62
+ }
63
+ const named = (each) => `${each.name ? `“${each.name}”` : 'a session'}${each.cwd ? ` in ${each.cwd}` : ''}`;
64
+ const NAMED = 4;
65
+ export function unwokenLines(sessions) {
66
+ if (sessions.length === 0)
67
+ return [];
68
+ const shown = sessions.slice(0, NAMED).map(named);
69
+ const more = sessions.length - shown.length;
70
+ const list = `${shown.join('; ')}${more ? `; and ${more} more` : ''}`;
71
+ return [
72
+ sessions.length === 1
73
+ ? `Pending You can’t wake the Claude Code session open here (${list}): it started before Pending You’s wake was set up. Restart it at a good moment: /exit, then claude --continue.`
74
+ : `Pending You can’t wake ${sessions.length} Claude Code sessions open here (${list}): they started before Pending You’s wake was set up. Restart each at a good moment: /exit, then claude --continue.`,
75
+ ];
76
+ }
77
+ export async function pruneLoadedNotes(io) {
78
+ const dir = notesDir(io);
79
+ for (const name of await readdir(dir).catch(() => [])) {
80
+ const path = join(dir, name);
81
+ const info = await stat(path).catch(() => null);
82
+ if (info && io.now() - info.mtimeMs > KEEP_NOTES_MS)
83
+ await rm(path, { force: true }).catch(() => { });
84
+ }
85
+ }
86
+ export async function removeLoadedNotes(io) {
87
+ await rm(join(configDir(io), 'wake'), { recursive: true, force: true }).catch(() => { });
88
+ }
package/dist/mod.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { join } from 'node:path';
2
+ import { configDir } from "./files.js";
2
3
  export const MOD_CLAUDE = '2.1.287';
3
4
  export const PLUGIN_DIRS = 'CLAUDE_CODE_PLUGIN_DIRS';
4
5
  export const PLUGINS_WITH_MOD = ['pending-you@pendingyou', 'pending-you-staging@pendingyou-staging'];
5
6
  export const modDir = (packageRoot) => join(packageRoot, 'mod');
7
+ export const stableModDir = (io) => join(configDir(io), 'mod');
6
8
  export function claudeVersion(output) {
7
9
  return /^\s*(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
8
10
  }
@@ -18,7 +20,9 @@ export function atLeast(version, least) {
18
20
  return true;
19
21
  }
20
22
  export const loadsMods = (version) => atLeast(version, MOD_CLAUDE);
21
- export const isOurModDir = (entry) => /(^|[\\/])node_modules[\\/]pendingyou[\\/]mod[\\/]?$/.test(entry.trim());
23
+ const bare = (path) => path.trim().replace(/[\\/]+$/, '');
24
+ export const isOurModDir = (entry, also = []) => /(^|[\\/])node_modules[\\/]pendingyou[\\/]mod[\\/]?$/.test(entry.trim()) ||
25
+ also.some((dir) => bare(dir) === bare(entry));
22
26
  export const separatorFor = (platform) => (platform === 'win32' ? ';' : ':');
23
27
  const objectAt = (parent, key) => {
24
28
  const value = parent[key];
@@ -37,7 +41,7 @@ export function pluginDirs(settings, separator) {
37
41
  return null;
38
42
  return value.split(separator).filter((entry) => entry.trim() !== '');
39
43
  }
40
- export function withModDir(settings, dir, separator) {
44
+ export function withModDir(settings, dir, separator, also = []) {
41
45
  const entries = pluginDirs(settings, separator);
42
46
  if (entries === null)
43
47
  return {
@@ -45,7 +49,8 @@ export function withModDir(settings, dir, separator) {
45
49
  };
46
50
  if (dir?.includes(separator))
47
51
  return { invalid: `The wake mod’s folder has a “${separator}” in its path: ${dir}` };
48
- const wanted = [...entries.filter((entry) => !isOurModDir(entry)), ...(dir ? [dir] : [])];
52
+ const ours = dir ? [...also, dir] : also;
53
+ const wanted = [...entries.filter((entry) => !isOurModDir(entry, ours)), ...(dir ? [dir] : [])];
49
54
  if (wanted.join(separator) === entries.join(separator))
50
55
  return { settings, changed: false };
51
56
  const next = structuredClone(settings);
package/dist/pickup.js CHANGED
@@ -8,8 +8,9 @@ import { clip, delegatedOf, describe, HANDED_PREFIX, isHandedWake, } from "./for
8
8
  import { goesBy, HANDED_GRACE_MS, namesAnotherComputer } from "./handed.js";
9
9
  import { herdrTarget, reportCommand } from "./herdr.js";
10
10
  import { holdCommand } from "./hold.js";
11
+ import { unwokenLine } from "./loaded.js";
11
12
  import { isPermissionCard, isPromptApp, sessionName, settlePrompts } from "./permission.js";
12
- import { announce, keepPresent } from "./presence.js";
13
+ import { announce, keepPresent, readSaid } from "./presence.js";
13
14
  import { setupReminder } from "./setup.js";
14
15
  import { claimHanded, markHanded, momentOf, releaseHanded, wasHanded } from "./state.js";
15
16
  const TIME_BOX_MS = { pickup: 2500, handoff: 2000 };
@@ -115,11 +116,15 @@ export async function pickup(io, options, hook = { fallback: '' }) {
115
116
  let line = null;
116
117
  let warning = null;
117
118
  let reminder = null;
119
+ let unwoken = null;
118
120
  try {
119
121
  const input = await hookInput(io);
120
122
  const { cwd } = input;
121
123
  if (mode === 'handoff' && isPromptApp(app))
122
124
  await settlePrompts(io, { session: input.session, event: 'prompt' });
125
+ const known = mode === 'handoff' && app === DEFAULT_APP && input.session
126
+ ? (await readSaid(io, app).catch(() => ({})))[input.session]
127
+ : undefined;
123
128
  if (app === 'codex')
124
129
  await keepPresent(io, origin, input.session, input.given ? cwd : null);
125
130
  else if (app === DEFAULT_APP && (mode === 'pickup' || input.session))
@@ -144,6 +149,8 @@ export async function pickup(io, options, hook = { fallback: '' }) {
144
149
  return 0;
145
150
  line = REMINDER;
146
151
  hook.fallback = `${[REMINDER, warning].filter(Boolean).join('\n')}\n`;
152
+ if (app === DEFAULT_APP)
153
+ unwoken = await unwokenLine(io, origin, input.session, known);
147
154
  }
148
155
  if (app !== DEFAULT_APP && input.session) {
149
156
  await touchThread(io, app, input.session, input.given ? input.cwd : null);
@@ -152,11 +159,16 @@ export async function pickup(io, options, hook = { fallback: '' }) {
152
159
  }
153
160
  if (app === 'codex' && input.session)
154
161
  await keepListening(io, origin, input.session);
162
+ const claudeNames = app === DEFAULT_APP
163
+ ? sessionName(input.transcript ?? undefined).then((name) => (name ? [name] : []))
164
+ : null;
165
+ const named = claudeNames ? (await claudeNames)[0] : undefined;
155
166
  const query = new URLSearchParams([
156
167
  ...folderForms(cwd, io.home).map((form) => ['cwd', form]),
157
168
  ['limit', String(ANSWERS_ASKED)],
158
169
  ...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
159
170
  ['source', app],
171
+ ...(named ? [['name', named]] : []),
160
172
  ]);
161
173
  const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, TIME_BOX_MS[mode], io.signal, {
162
174
  hook: true,
@@ -190,10 +202,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
190
202
  return computers !== null && namesAnotherComputer(heard.machine, computers);
191
203
  },
192
204
  names: () => {
193
- names ??=
194
- app === DEFAULT_APP
195
- ? sessionName(input.transcript ?? undefined).then((name) => (name ? [name] : []))
196
- : threadNames(io, app, input.session);
205
+ names ??= claudeNames ?? threadNames(io, app, input.session);
197
206
  return names;
198
207
  },
199
208
  };
@@ -241,10 +250,12 @@ export async function pickup(io, options, hook = { fallback: '' }) {
241
250
  ...(line ? [line] : []),
242
251
  ...(warning ? [warning] : []),
243
252
  ...(reminder ? [reminder] : []),
253
+ ...(unwoken ? [unwoken] : []),
244
254
  ].join('\n')}\n`);
245
255
  line = null;
246
256
  warning = null;
247
257
  reminder = null;
258
+ unwoken = null;
248
259
  await markHanded(io, origin, fresh.map((heard) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
249
260
  return 0;
250
261
  }
@@ -256,7 +267,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
256
267
  return 0;
257
268
  }
258
269
  finally {
259
- const last = [line, warning, reminder].filter(Boolean);
270
+ const last = [line, warning, reminder, unwoken].filter(Boolean);
260
271
  if (last.length)
261
272
  io.out(`${last.join('\n')}\n`);
262
273
  }
package/dist/presence.js CHANGED
@@ -100,15 +100,28 @@ export async function sendPresence(io, origin, presence, timeoutMs = SEND_MS, op
100
100
  }
101
101
  const recordPath = (io, app) => join(configDir(io), 'presence', `${app}.json`);
102
102
  const leasePath = (io, app, session) => join(configDir(io), 'presence', `${app}-${session}.lease`);
103
- async function readSaid(io, app) {
103
+ const numberOr = (value) => (typeof value === 'number' ? value : undefined);
104
+ export async function readSaid(io, app) {
104
105
  const file = await readJson(recordPath(io, app)).catch(() => null);
105
106
  const sessions = {};
106
107
  if (isObject(file?.sessions))
107
108
  for (const [id, value] of Object.entries(file.sessions))
108
109
  if (isObject(value) &&
109
110
  (value.state === 'live' || value.state === 'closed') &&
110
- typeof value.at === 'number')
111
- sessions[id] = { state: value.state, at: value.at };
111
+ typeof value.at === 'number') {
112
+ const since = numberOr(value.since);
113
+ const mod = numberOr(value.mod);
114
+ const cwd = text(value.cwd);
115
+ const name = text(value.name);
116
+ sessions[id] = {
117
+ state: value.state,
118
+ at: value.at,
119
+ ...(since !== undefined ? { since } : {}),
120
+ ...(mod !== undefined ? { mod } : {}),
121
+ ...(cwd ? { cwd } : {}),
122
+ ...(name ? { name } : {}),
123
+ };
124
+ }
112
125
  return sessions;
113
126
  }
114
127
  async function noteSaid(io, app, session, said) {
@@ -118,7 +131,14 @@ async function noteSaid(io, app, session, said) {
118
131
  const before = sessions[session] ?? null;
119
132
  if (before && before.at > said.at)
120
133
  return { newer: true, before };
121
- sessions[session] = said;
134
+ sessions[session] = {
135
+ ...before,
136
+ ...said,
137
+ since: before ? Math.min(before.since ?? 0, said.at) : said.at,
138
+ ...(said.mod !== undefined || before?.mod !== undefined
139
+ ? { mod: Math.max(said.mod ?? 0, before?.mod ?? 0) }
140
+ : {}),
141
+ };
122
142
  const now = io.now();
123
143
  for (const [id, each] of Object.entries(sessions))
124
144
  if (now - each.at > FORGET_MS)
@@ -131,7 +151,16 @@ async function noteSaid(io, app, session, said) {
131
151
  }
132
152
  async function sendOnce(io, command) {
133
153
  const at = command.at ?? io.now();
134
- const noted = await noteSaid(io, command.app, command.session, { state: command.state, at });
154
+ const cwd = presenceCwd(command.cwd, io.home);
155
+ const name = await nameFor(io, command);
156
+ const mod = command.app === 'claude-code' && command.state === 'live' && !command.transcript;
157
+ const noted = await noteSaid(io, command.app, command.session, {
158
+ state: command.state,
159
+ at,
160
+ ...(mod ? { mod: at } : {}),
161
+ ...(cwd ? { cwd } : {}),
162
+ ...(name ? { name } : {}),
163
+ });
135
164
  if (noted.newer)
136
165
  return null;
137
166
  if (command.state === 'live' &&
@@ -142,8 +171,8 @@ async function sendOnce(io, command) {
142
171
  app: command.app,
143
172
  session: command.session,
144
173
  state: command.state,
145
- cwd: presenceCwd(command.cwd, io.home),
146
- name: await nameFor(io, command),
174
+ cwd,
175
+ name,
147
176
  }, SEND_MS, { patient: true });
148
177
  }
149
178
  const GIVE_UP = new Set(['refused', 'absent', 'skipped']);
package/dist/state.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { configDir, readJson, withLock, writeWhole } from "./files.js";
3
3
  const KEEP_HANDED = 300;
4
+ const KEEP_UNWOKEN = 100;
4
5
  const statePath = (io) => join(configDir(io), 'state.json');
5
6
  const key = (origin, requestId) => `${origin} ${requestId}`;
6
7
  export const momentOf = (heard) => `${heard.version}:${heard.messages.at(-1)?.id ?? ''}`;
@@ -10,6 +11,7 @@ async function load(io) {
10
11
  version: 1,
11
12
  handed: file?.handed && typeof file.handed === 'object' ? file.handed : {},
12
13
  ...(file?.reminded && typeof file.reminded === 'object' ? { reminded: file.reminded } : {}),
14
+ ...(file?.unwoken && typeof file.unwoken === 'object' ? { unwoken: file.unwoken } : {}),
13
15
  };
14
16
  }
15
17
  async function change(io, edit) {
@@ -20,6 +22,9 @@ async function change(io, edit) {
20
22
  const handed = Object.entries(state.handed);
21
23
  if (handed.length > KEEP_HANDED)
22
24
  state.handed = Object.fromEntries(handed.slice(-KEEP_HANDED));
25
+ const unwoken = Object.entries(state.unwoken ?? {});
26
+ if (unwoken.length > KEEP_UNWOKEN)
27
+ state.unwoken = Object.fromEntries(unwoken.slice(-KEEP_UNWOKEN));
23
28
  await writeWhole(path, `${JSON.stringify(state, null, 2)}\n`, { secret: true });
24
29
  });
25
30
  }
@@ -70,3 +75,13 @@ export function markReminded(io, origin, app, since) {
70
75
  state.reminded = { ...state.reminded, [name]: { since, times, at: io.now() } };
71
76
  });
72
77
  }
78
+ export async function claimUnwoken(io, session) {
79
+ let claimed = false;
80
+ await change(io, (state) => {
81
+ if (state.unwoken?.[session] !== undefined)
82
+ return;
83
+ state.unwoken = { ...state.unwoken, [session]: io.now() };
84
+ claimed = true;
85
+ });
86
+ return claimed;
87
+ }
package/dist/trust.js ADDED
@@ -0,0 +1,37 @@
1
+ import { originArgs } from "./hooks.js";
2
+ import { checkSignIn } from "./signin.js";
3
+ export const TRUST_EVERY_MS = 3000;
4
+ export const TRUST_FOR_MS = 10 * 60_000;
5
+ export async function waitForTrust(io, trusted) {
6
+ const until = io.now() + TRUST_FOR_MS;
7
+ while (io.now() < until) {
8
+ if (io.signal.aborted)
9
+ return 'stopped';
10
+ await io.sleep(TRUST_EVERY_MS, io.signal);
11
+ if (io.signal.aborted)
12
+ return 'stopped';
13
+ if (await trusted())
14
+ return 'trusted';
15
+ }
16
+ return 'timed-out';
17
+ }
18
+ export async function trustHooks(io, app, origin) {
19
+ const pending = await app.untrustedHooks?.(io);
20
+ if (!pending)
21
+ return null;
22
+ const later = `npx pendingyou status${originArgs(origin)}`;
23
+ io.out(`\n${pending.lines.join('\n')}\n`);
24
+ if (!io.interactive) {
25
+ io.out(`Once you have, run ${later} to check.\n`);
26
+ return null;
27
+ }
28
+ io.out(`Waiting for you to trust the hooks… (Ctrl-C to stop; run ${later} later)\n`);
29
+ const outcome = await waitForTrust(io, pending.trusted);
30
+ if (outcome !== 'trusted') {
31
+ io.out(`${outcome === 'stopped' ? 'Stopped waiting.' : 'Stopped waiting after 10 minutes.'} Once you’ve trusted them, run ${later}.\n`);
32
+ return null;
33
+ }
34
+ const section = await app.status(io, { origin, signIn: await checkSignIn(io, origin, app.id) });
35
+ io.out(`${section.lines.at(-1)}\n`);
36
+ return section;
37
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.21.0';
1
+ export const VERSION = '0.23.0';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.34.0",
4
+ "version": "2.34.2",
5
5
  "description": "Wakes Claude Code when your person answers a Pending You card it posted, even while it sits idle. Installed by `npx pendingyou init`.",
6
6
  "author": {
7
7
  "name": "RecordPlane",
package/mod/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Pending You wake mod
2
2
 
3
- The pendingyou command line’s copy of the Pending You plugin’s wake mod, for Claude Code 2.1.287 and later. `npx pendingyou init` adds this folder to `CLAUDE_CODE_PLUGIN_DIRS` in `~/.claude/settings.json`, so every Claude Code session loads it; `npx pendingyou uninstall` takes it out.
3
+ The pendingyou command line’s copy of the Pending You plugin’s wake mod, for Claude Code 2.1.287 and later. `npx pendingyou init` copies it to `~/.config/pendingyou/mod`, a folder that stays put when pendingyou updates, and adds that folder to `CLAUDE_CODE_PLUGIN_DIRS` in `~/.claude/settings.json`, so every Claude Code session started after loads it (one already open needs a restart: /exit, then claude --continue); `npx pendingyou uninstall` takes it out.
4
4
 
5
5
  In each session it learns the cards the session posts to Pending You, checks them through the session’s own Pending You connection, and starts a turn when your person answers, writes back or a fallback comes due, or hands the session a question from another of their assistants (CLI 0.14.0), even while it sits idle. It answers `npx pendingyou hold` itself, so nothing waits in the background, and shows “2 waiting on you” under the prompt, after the plugin’s name. With the Pending You plugin installed too, only one copy acts.
6
6
 
7
- Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.34.0; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.34.2; send changes there.
@@ -1,7 +1,7 @@
1
1
  // Generated from packages/claude-plugin/src/wake/register.ts in recordplane/pendingyou: edit that, then run
2
2
  // `pnpm --filter @pendingyou/claude-plugin generate`.
3
3
 
4
- import { argumentsOf, blockedNote, CLAIM_PREFIX, CLI_COPY, cardTool, checked, claimLease, cliConfigOf, connectionOf, dueCards, emptyBook, failed, folderOf, HANDED_FAST_MS, HANDED_READS, HERDR_EVERY_MS, HERDR_RUN_MS, handedDue, handedNext, handedOf, handedText, handedView, herdrArgv, herdrPayload, holdOf, holdReply, inHerdr, isRefusal, isStale, isWaitingOnYou, KEEP_MS, learn, leaseHolder, momentOf, originOf, PRESENCE_EVERY_MS, PRESENCE_RUN_MS, presenceArgv, prompted, put, readBook, readClaim, readSetup, readSince, resultObject, SETUP_AFTER_MS, SETUP_RETRY_MS, SETUP_SERVER, SETUP_TRIES, settle, setUpOf, setupNeeded, setupPrompt, stateOf, statusText, TICK_MS, takesHanded, told, toTell, untold, wakeText, } from "./wake.js";
4
+ import { argumentsOf, blockedNote, CLAIM_PREFIX, CLI_COPY, cardTool, checked, claimLease, cliConfigOf, connectionOf, dueCards, emptyBook, failed, folderOf, HANDED_FAST_MS, HANDED_READS, HERDR_EVERY_MS, HERDR_RUN_MS, handedDue, handedNext, handedOf, handedText, handedView, herdrArgv, herdrPayload, holdOf, holdReply, inHerdr, isRefusal, isStale, isWaitingOnYou, KEEP_MS, learn, leaseHolder, loadedNote, loadedPath, momentOf, originOf, PRESENCE_EVERY_MS, PRESENCE_RUN_MS, presenceArgv, prompted, put, readBook, readClaim, readSetup, readSince, resultObject, SETUP_AFTER_MS, SETUP_RETRY_MS, SETUP_SERVER, SETUP_TRIES, settle, setUpOf, setupNeeded, setupPrompt, stateOf, statusText, TICK_MS, takesHanded, told, toTell, untold, wakeText, } from "./wake.js";
5
5
  const RETRY_MS = 60_000;
6
6
  const live = {
7
7
  me: '',
@@ -85,7 +85,7 @@ async function startHerdr($) {
85
85
  live.herdrTimer = null;
86
86
  live.herdr = null;
87
87
  live.herdrSaid = undefined;
88
- const config = cliConfigOf($.plugin.root);
88
+ const config = cliConfigOf($.plugin.root, $.plugin.name);
89
89
  if (!config)
90
90
  return;
91
91
  const herdr = inHerdr(await $.env.get('HERDR_ENV'), await $.env.get('HERDR_BIN_PATH'), await $.env.get('HERDR_PANE_ID'));
@@ -319,6 +319,15 @@ async function folderOfSession($, cwd) {
319
319
  live.home = undefined;
320
320
  }
321
321
  }
322
+ async function noteLoaded($) {
323
+ try {
324
+ const config = cliConfigOf($.plugin.root, $.plugin.name);
325
+ const path = config && live.sessionId ? loadedPath(config, live.sessionId) : null;
326
+ if (path)
327
+ await $.fs.write(path, loadedNote(await $.clock.now()));
328
+ }
329
+ catch { }
330
+ }
322
331
  async function sayLive($) {
323
332
  try {
324
333
  const presence = live.presence;
@@ -338,7 +347,7 @@ async function startPresence($) {
338
347
  live.presenceTimer?.cancel();
339
348
  live.presenceTimer = null;
340
349
  live.presence = null;
341
- const config = cliConfigOf($.plugin.root);
350
+ const config = cliConfigOf($.plugin.root, $.plugin.name);
342
351
  if (!config)
343
352
  return;
344
353
  let manifest = null;
@@ -359,6 +368,7 @@ async function start($, e) {
359
368
  live.book = readBook(await $.store.get(keyOf(live.sessionId)));
360
369
  freshHanded();
361
370
  await folderOfSession($, e.cwd);
371
+ await noteLoaded($);
362
372
  await claim($);
363
373
  live.timer?.cancel();
364
374
  live.timer = $.clock.every(TICK_MS, () => tick($));
@@ -377,6 +387,7 @@ async function switchSession($, e) {
377
387
  live.book = carried ?? readBook(await $.store.get(keyOf(id)));
378
388
  live.noted.clear();
379
389
  freshHanded();
390
+ await noteLoaded($);
380
391
  if (typeof e.cwd === 'string' && e.cwd)
381
392
  await folderOfSession($, e.cwd);
382
393
  if (carried)
package/mod/hooks/wake.js CHANGED
@@ -616,7 +616,15 @@ export function handedOf(output) {
616
616
  const updatedAt = text(request.updatedAt, 40);
617
617
  const cwd = text(request.cwd, 1000);
618
618
  return requestId && title && updatedAt
619
- ? [{ requestId, title, updatedAt, ...(cwd ? { cwd } : {}) }]
619
+ ? [
620
+ {
621
+ requestId,
622
+ title,
623
+ updatedAt,
624
+ ...(cwd ? { cwd } : {}),
625
+ ...(request.byName === true ? { byName: true } : {}),
626
+ },
627
+ ]
620
628
  : [];
621
629
  });
622
630
  }
@@ -641,7 +649,7 @@ export function inTaskFolder(folders, task, home) {
641
649
  });
642
650
  }
643
651
  export function takesHanded(handed, folders, home, now) {
644
- if (!handed.cwd)
652
+ if (handed.byName || !handed.cwd)
645
653
  return true;
646
654
  if (folders.length === 0)
647
655
  return now - Date.parse(handed.updatedAt) >= HANDED_GRACE_MS;
@@ -760,10 +768,18 @@ export function setupPrompt(connection = 'Claude Code') {
760
768
  export const PRESENCE_EVERY_MS = 5 * 60_000;
761
769
  export const PRESENCE_RUN_MS = 15_000;
762
770
  const PRODUCTION = 'https://www.pendingyou.com';
763
- export function cliConfigOf(root) {
771
+ export function cliConfigOf(root, name = CLI_COPY) {
772
+ if (name !== CLI_COPY)
773
+ return null;
764
774
  return (/^(.+)[\\/]cli[\\/][^\\/]+[\\/]node_modules[\\/]pendingyou[\\/]mod[\\/]?$/.exec(root)?.[1] ??
775
+ /^(.+)[\\/]mod[\\/]?$/.exec(root)?.[1] ??
765
776
  null);
766
777
  }
778
+ const SESSION_FILE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
779
+ export function loadedPath(config, sessionId) {
780
+ return SESSION_FILE.test(sessionId) ? `${config}/wake/sessions/${sessionId}.json` : null;
781
+ }
782
+ export const loadedNote = (at) => `${JSON.stringify({ at: Math.floor(at) })}\n`;
767
783
  export function originOf(manifest) {
768
784
  let parsed = manifest;
769
785
  if (typeof manifest === 'string')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Pending You for your coding agents: one command sets up Claude Code, Codex, OpenCode and Pi to ask you through Pending You and hear your answers, even while they sit idle.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.pendingyou.com",