pendingyou 0.22.0 → 0.25.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,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0 (2026-10-06)
4
+
5
+ - An answer reaches only the session that asked. The hooks asked Pending You for their folder's answers and open
6
+ cards, and got every card asked from that folder, or from any folder above it, whoever asked: two sessions in one
7
+ folder each heard the other's answers and were told to ack them, the next-message line listed the other's cards as
8
+ "Your open cards here", and a card asked from a folder above another session's reached it on any computer. Now each
9
+ hook says who its session is: the cards it posted (Claude Code's from its own transcript, with whether that's all of
10
+ them; Codex's, OpenCode's and Pi's from what their hooks remember), the name it goes by, and this computer's names.
11
+ Pending You hands it a card it posted (whatever name it asked under, the app's own included), else one asked under
12
+ its name; only a session that can say neither falls back to its folder, that exact folder on this computer, and
13
+ never for a card asked under another agent's name. The Stop check's look at open cards asks the same way. Needs
14
+ Pending You's matching release; until then the hooks hear as before.
15
+
16
+ ## 0.24.0 (2026-10-06)
17
+
18
+ - When your person approves a card that carried a draft, or picks a reply option that sends (`sends`), the hold, the
19
+ hooks' hand-off and the wake mods (Claude Code's and Codex's) add one line: “The approved words are in
20
+ get_request’s answer.approvedDrafts: send them exactly.” Pending You's `get_request` now gives those exact words with
21
+ the answer (and every answer the card's title), so an agent that starts fresh, or a smaller model, sends what was
22
+ approved instead of hunting for it or rewriting it. Needs Pending You's matching release; with an older one nothing
23
+ changes.
24
+
25
+ ## 0.23.0 (2026-10-06)
26
+
27
+ - The Claude Code wake mod survives upgrades. `init` now copies it to `~/.config/pendingyou/mod`, a folder that stays
28
+ put across versions, and points `CLAUDE_CODE_PLUGIN_DIRS` there, so the entry never changes again. Before, it named
29
+ the version's own folder, which the next upgrade removed: a session already open (Claude Code reads its plugin
30
+ folders only as a session starts) was left with no wake mod and nothing said, and answers reached it only when you
31
+ typed. Open interactive sessions reload the mod when `init` updates it. `init` keeps the newest older copy for
32
+ sessions started before this version, and `status` says when the entry still names a version.
33
+ - 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
34
+ this session: Claude Code started before Pending You’s wake was set up. Ask your person to restart it (/exit, then
35
+ claude --continue) at a good moment.”, and to background a hold until then. The mod notes each session it runs in
36
+ (`~/.config/pendingyou/wake/sessions/`), and presence remembers when an older mod said a session is live.
37
+ - `init` names the Claude Code sessions open here that started before the wake mod was set up, by the name they go by
38
+ and their folder, and says to restart each at a good moment.
39
+ - A question handed to your agent by its name reaches it in whatever folder it works. The wake mod and the hooks took
40
+ 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
41
+ never heard it. The hooks now send the session's name, and the mod reads Pending You's `byName`; one handed to the app
42
+ with no agent still goes by its folder. Needs Pending You's matching release.
43
+
44
+ ## 0.22.1 (2026-10-06)
45
+
46
+ - The Herdr plugin's popup finishes noting your last key press (what keeps your phone quiet while you're in Herdr)
47
+ before it closes, rather than leaving it half-written in the plugin's state folder.
48
+
3
49
  ## 0.22.0 (2026-10-06)
4
50
 
5
51
  - Trusting Codex's hooks is one command now. When `init` or `status` finds Pending You's hooks in Codex but not trusted
package/README.md CHANGED
@@ -354,6 +354,11 @@ with `claude --continue`. Under the prompt it shows `⚠ pendingyou-wake: 2 wait
354
354
  `npx pendingyou hold`, the mod answers it at once and nothing runs in the background. With the Pending You plugin
355
355
  installed as well, only one copy acts. `npx pendingyou status` says whether the mod is loaded.
356
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
+
357
362
  The mod's checks are Pending You tool calls like Claude Code's own, so they need Pending You's tools allowed, which
358
363
  `init` does (`mcp__pendingyou`). If they aren't, the line under the prompt says which rule to add in `/permissions`, and
359
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
@@ -5,7 +5,7 @@ import { getJson, SignInNeeded, Unavailable } from "../api.js";
5
5
  import { DEFAULT_ORIGIN } from "../args.js";
6
6
  import { connectionMachine, readCredential } from "../credentials.js";
7
7
  import { configDir, readJson, withLock, writeWhole } from "../files.js";
8
- import { clip, delegatedOf, handledOf, isClosed, noticeOf } from "../format.js";
8
+ import { APPROVED_DRAFT_NOTICE, approvesDraft, clip, delegatedOf, handledOf, isClosed, noticeOf, } from "../format.js";
9
9
  import { chooseThread, goesBy, HANDED_FOR_MS, handedCard, handedMessage, namesAnotherComputer, } from "../handed.js";
10
10
  import { clearHerdr, herdrTarget, isUrgency, PANE_ID, reportCommand, writeHerdr, } from "../herdr.js";
11
11
  import { macName, tailscaleName } from "../remote.js";
@@ -280,6 +280,12 @@ export async function threadNames(io, app, thread) {
280
280
  return [];
281
281
  return (await load(io, app).catch(() => null))?.threads[thread]?.names ?? [];
282
282
  }
283
+ export async function threadCards(io, app, thread) {
284
+ if (!thread)
285
+ return [];
286
+ const cards = (await load(io, app).catch(() => null))?.threads[thread]?.cards ?? {};
287
+ return Object.keys(cards).filter((id) => REQUEST_ID.test(id));
288
+ }
283
289
  export async function markHandedTurn(io, turn, app) {
284
290
  const now = io.now();
285
291
  await change(io, app, (state) => {
@@ -411,7 +417,7 @@ function line(heard, card) {
411
417
  ];
412
418
  return [
413
419
  `your person answered ${titled}`,
414
- `call ${get}, act on their words, then ack_answer with its version and a one-line outcome`,
420
+ `call ${get}, act on their words, then ack_answer with its version and a one-line outcome${approvesDraft(heard) ? `. ${APPROVED_DRAFT_NOTICE.slice(0, -1)}` : ''}`,
415
421
  ];
416
422
  default:
417
423
  return [
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') ?? {};
package/dist/format.js CHANGED
@@ -12,6 +12,8 @@ export function handledOf(heard) {
12
12
  return given === 'self' || given === 'leave' ? given : null;
13
13
  }
14
14
  const HANDLED_SAID = { self: 'I’ll do it myself', leave: 'Leave it' };
15
+ export const APPROVED_DRAFT_NOTICE = 'The approved words are in get_request’s answer.approvedDrafts: send them exactly.';
16
+ export const approvesDraft = (heard) => heard.approvedDraft === true;
15
17
  export const HANDED_PREFIX = 'Pending You: your person handed you';
16
18
  export const HANDED_REPLY = 'on the question handed to you';
17
19
  export const HANDED_TALK_TODO = 'If you know now, answer_delegated with your answer and how you know; if you need more, reply_in_thread to “asker”; if it’s your person’s to decide, reply_in_thread with escalate true; if not, hand_back with what you checked.';
@@ -170,6 +172,8 @@ export function describe(heard, again) {
170
172
  }
171
173
  if (wrote.length)
172
174
  lines.push(`${noticeOf(heard) ? 'Also' : 'They also wrote'}: ${wrote.join(' ')}`);
175
+ if (approvesDraft(heard))
176
+ lines.push(APPROVED_DRAFT_NOTICE);
173
177
  lines.push(`Act on it, then call ack_answer with requestId ${id}, name ${named(heard)}, expectedVersion ${heard.version} and a one-line outcome.`);
174
178
  break;
175
179
  }
@@ -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/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);
@@ -526,6 +526,95 @@ export async function sessionName(path) {
526
526
  await file.close().catch(() => { });
527
527
  }
528
528
  }
529
+ const POST_TOOL = /^mcp__.*pendingyou.*__post_request$/i;
530
+ const POSTED_ID = /"requestId"\s*:\s*"(req_[A-Za-z0-9-]{1,40})"/;
531
+ export const POSTED_MAX = 50;
532
+ function resultText(content) {
533
+ if (typeof content === 'string')
534
+ return content;
535
+ if (!Array.isArray(content))
536
+ return '';
537
+ return content.map((block) => (isObject(block) ? text(block.text) : '')).join('\n');
538
+ }
539
+ export async function sessionPosted(path) {
540
+ if (!path)
541
+ return null;
542
+ let file;
543
+ try {
544
+ file = await open(path, 'r');
545
+ }
546
+ catch (error) {
547
+ return error?.code === 'ENOENT' ? { posted: [], whole: true } : null;
548
+ }
549
+ try {
550
+ const size = (await file.stat()).size;
551
+ let position = size;
552
+ let carry = Buffer.alloc(0);
553
+ const results = new Map();
554
+ const posted = [];
555
+ let more = false;
556
+ const read = (line) => {
557
+ const asResult = line.includes('tool_result') && line.includes('req_');
558
+ const asCall = line.includes('post_request');
559
+ if (!asResult && !asCall)
560
+ return;
561
+ let entry;
562
+ try {
563
+ entry = JSON.parse(line);
564
+ }
565
+ catch {
566
+ return;
567
+ }
568
+ if (!isObject(entry) || entry.isSidechain === true)
569
+ return;
570
+ const message = isObject(entry.message) ? entry.message : {};
571
+ const blocks = Array.isArray(message.content) ? message.content : [];
572
+ for (const block of [...blocks].reverse()) {
573
+ if (!isObject(block))
574
+ continue;
575
+ if (entry.type === 'user' && block.type === 'tool_result' && block.is_error !== true) {
576
+ const id = POSTED_ID.exec(resultText(block.content))?.[1];
577
+ if (id && text(block.tool_use_id))
578
+ results.set(text(block.tool_use_id), id);
579
+ }
580
+ else if (entry.type === 'assistant' &&
581
+ block.type === 'tool_use' &&
582
+ POST_TOOL.test(text(block.name))) {
583
+ const id = results.get(text(block.id));
584
+ if (!id || posted.includes(id))
585
+ continue;
586
+ if (posted.length < POSTED_MAX)
587
+ posted.push(id);
588
+ else
589
+ more = true;
590
+ }
591
+ }
592
+ };
593
+ while (position > 0 && size - position < TRANSCRIPT_CAP) {
594
+ const length = Math.min(64 * 1024, position);
595
+ position -= length;
596
+ const chunk = Buffer.alloc(length);
597
+ await file.read(chunk, 0, length, position);
598
+ let buffer = Buffer.concat([chunk, carry]);
599
+ let newline = buffer.lastIndexOf(10);
600
+ while (newline !== -1) {
601
+ read(buffer.subarray(newline + 1).toString('utf8'));
602
+ buffer = buffer.subarray(0, newline);
603
+ newline = buffer.lastIndexOf(10);
604
+ }
605
+ carry = buffer;
606
+ }
607
+ if (position === 0)
608
+ read(carry.toString('utf8'));
609
+ return { posted, whole: position === 0 && !more };
610
+ }
611
+ catch {
612
+ return null;
613
+ }
614
+ finally {
615
+ await file.close().catch(() => { });
616
+ }
617
+ }
529
618
  export function remoteOf(url) {
530
619
  const plain = url
531
620
  .trim()
package/dist/pickup.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { getJson, SignInNeeded } from "./api.js";
2
- import { computerNames, hearsOnly, keepListening, markHandedTurn, sessionFor, threadNames, touchThread, } from "./apps/codex-wake.js";
2
+ import { computerNames, hearsOnly, keepListening, markHandedTurn, sessionFor, threadCards, threadNames, touchThread, } from "./apps/codex-wake.js";
3
3
  import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
4
- import { readCredential } from "./credentials.js";
4
+ import { connectionMachine, readCredential } from "./credentials.js";
5
5
  import { environmentWarning } from "./environment.js";
6
6
  import { configDir, readJson } from "./files.js";
7
7
  import { clip, delegatedOf, describe, HANDED_PREFIX, isHandedWake, } from "./format.js";
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 { isPermissionCard, isPromptApp, sessionName, settlePrompts } from "./permission.js";
12
- import { announce, keepPresent } from "./presence.js";
11
+ import { unwokenLine } from "./loaded.js";
12
+ import { isPermissionCard, isPromptApp, POSTED_MAX, sessionName, sessionPosted, settlePrompts, } from "./permission.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 };
@@ -109,17 +110,45 @@ export function folderForms(cwd, home) {
109
110
  forms.push(`~${cwd.slice(base.length)}`);
110
111
  return forms;
111
112
  }
113
+ export async function askerParams(io, origin, app, session) {
114
+ let name = session.name ?? undefined;
115
+ let posted = [];
116
+ let own = false;
117
+ if (app === DEFAULT_APP) {
118
+ const record = await sessionPosted(session.transcript ?? undefined);
119
+ if (record) {
120
+ posted = record.posted;
121
+ own = record.whole;
122
+ }
123
+ }
124
+ else if (session.id) {
125
+ name ??= (await threadNames(io, app, session.id).catch(() => [])).at(-1);
126
+ posted = (await threadCards(io, app, session.id).catch(() => [])).slice(0, POSTED_MAX);
127
+ }
128
+ const machine = await connectionMachine(io, origin, app).catch(() => null);
129
+ const machines = [...new Set([io.host, machine].filter((each) => Boolean(each)))];
130
+ return [
131
+ ...(name ? [['name', name]] : []),
132
+ ...posted.map((id) => ['posted', id]),
133
+ ...(own ? [['own', '1']] : []),
134
+ ...machines.slice(0, 4).map((each) => ['machine', each]),
135
+ ];
136
+ }
112
137
  export async function pickup(io, options, hook = { fallback: '' }) {
113
138
  const { origin, mode } = options;
114
139
  const app = options.app ?? DEFAULT_APP;
115
140
  let line = null;
116
141
  let warning = null;
117
142
  let reminder = null;
143
+ let unwoken = null;
118
144
  try {
119
145
  const input = await hookInput(io);
120
146
  const { cwd } = input;
121
147
  if (mode === 'handoff' && isPromptApp(app))
122
148
  await settlePrompts(io, { session: input.session, event: 'prompt' });
149
+ const known = mode === 'handoff' && app === DEFAULT_APP && input.session
150
+ ? (await readSaid(io, app).catch(() => ({})))[input.session]
151
+ : undefined;
123
152
  if (app === 'codex')
124
153
  await keepPresent(io, origin, input.session, input.given ? cwd : null);
125
154
  else if (app === DEFAULT_APP && (mode === 'pickup' || input.session))
@@ -144,6 +173,8 @@ export async function pickup(io, options, hook = { fallback: '' }) {
144
173
  return 0;
145
174
  line = REMINDER;
146
175
  hook.fallback = `${[REMINDER, warning].filter(Boolean).join('\n')}\n`;
176
+ if (app === DEFAULT_APP)
177
+ unwoken = await unwokenLine(io, origin, input.session, known);
147
178
  }
148
179
  if (app !== DEFAULT_APP && input.session) {
149
180
  await touchThread(io, app, input.session, input.given ? input.cwd : null);
@@ -152,11 +183,20 @@ export async function pickup(io, options, hook = { fallback: '' }) {
152
183
  }
153
184
  if (app === 'codex' && input.session)
154
185
  await keepListening(io, origin, input.session);
186
+ const claudeNames = app === DEFAULT_APP
187
+ ? sessionName(input.transcript ?? undefined).then((name) => (name ? [name] : []))
188
+ : null;
189
+ const named = claudeNames ? (await claudeNames)[0] : undefined;
155
190
  const query = new URLSearchParams([
156
191
  ...folderForms(cwd, io.home).map((form) => ['cwd', form]),
157
192
  ['limit', String(ANSWERS_ASKED)],
158
193
  ...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
159
194
  ['source', app],
195
+ ...(await askerParams(io, origin, app, {
196
+ id: input.session,
197
+ transcript: input.transcript,
198
+ name: named ?? null,
199
+ })),
160
200
  ]);
161
201
  const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, TIME_BOX_MS[mode], io.signal, {
162
202
  hook: true,
@@ -190,10 +230,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
190
230
  return computers !== null && namesAnotherComputer(heard.machine, computers);
191
231
  },
192
232
  names: () => {
193
- names ??=
194
- app === DEFAULT_APP
195
- ? sessionName(input.transcript ?? undefined).then((name) => (name ? [name] : []))
196
- : threadNames(io, app, input.session);
233
+ names ??= claudeNames ?? threadNames(io, app, input.session);
197
234
  return names;
198
235
  },
199
236
  };
@@ -241,10 +278,12 @@ export async function pickup(io, options, hook = { fallback: '' }) {
241
278
  ...(line ? [line] : []),
242
279
  ...(warning ? [warning] : []),
243
280
  ...(reminder ? [reminder] : []),
281
+ ...(unwoken ? [unwoken] : []),
244
282
  ].join('\n')}\n`);
245
283
  line = null;
246
284
  warning = null;
247
285
  reminder = null;
286
+ unwoken = null;
248
287
  await markHanded(io, origin, fresh.map((heard) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
249
288
  return 0;
250
289
  }
@@ -256,7 +295,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
256
295
  return 0;
257
296
  }
258
297
  finally {
259
- const last = [line, warning, reminder].filter(Boolean);
298
+ const last = [line, warning, reminder, unwoken].filter(Boolean);
260
299
  if (last.length)
261
300
  io.out(`${last.join('\n')}\n`);
262
301
  }
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/stopcheck.js CHANGED
@@ -5,8 +5,8 @@ import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
5
5
  import { asksToTypeHere, cardFits, confirmReason, sessionLink } from "./confirm.js";
6
6
  import { readCredential } from "./credentials.js";
7
7
  import { isHandedWake } from "./format.js";
8
- import { isPermissionCard, isPromptApp, settlePrompts, subagentsRunning } from "./permission.js";
9
- import { folderForms } from "./pickup.js";
8
+ import { isPermissionCard, isPromptApp, sessionName, settlePrompts, subagentsRunning, } from "./permission.js";
9
+ import { askerParams, folderForms } from "./pickup.js";
10
10
  export const READ_CAP = 4 * 1024 * 1024;
11
11
  const CHUNK = 64 * 1024;
12
12
  export const STOP_REASON = 'Items for your person are in chat but not in Pending You: post them as cards (ask in both places), or say they don’t need one.';
@@ -275,13 +275,15 @@ function opencodeTurn(input) {
275
275
  const text = final.join('\n').trim();
276
276
  return text ? { text, posted, cards, handed } : null;
277
277
  }
278
- async function openTitles(io, origin, cwd, app) {
278
+ async function openTitles(io, origin, cwd, app, session) {
279
279
  try {
280
+ const name = app === 'claude-code' ? await sessionName(session.transcript ?? undefined) : undefined;
280
281
  const query = new URLSearchParams([
281
282
  ...folderForms(cwd, io.home).map((form) => ['cwd', form]),
282
283
  ['limit', '0'],
283
284
  ['open', String(OPEN_CARDS)],
284
285
  ['source', app],
286
+ ...(await askerParams(io, origin, app, { ...session, name: name ?? null })),
285
287
  ]);
286
288
  const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, OPEN_TIME_BOX_MS, io.signal, { hook: true, app });
287
289
  const requests = status === 200 ? body.open?.requests : undefined;
@@ -366,11 +368,17 @@ export async function stopcheck(io, options) {
366
368
  return asksToTypeHere(text) || itemsForPerson(text) ? block(DELEGATED_REASON) : 0;
367
369
  }
368
370
  const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : io.cwd;
371
+ const session = {
372
+ id: typeof input.session_id === 'string' && input.session_id ? input.session_id : null,
373
+ transcript: typeof input.transcript_path === 'string' && input.transcript_path
374
+ ? input.transcript_path
375
+ : null,
376
+ };
369
377
  const ask = asksToTypeHere(text);
370
378
  if (ask) {
371
379
  if (saysNoCard(text) || turn.cards.some((card) => cardFits(card, ask.words, 'posted')))
372
380
  return 0;
373
- const open = await openTitles(io, options.origin, cwd, app);
381
+ const open = await openTitles(io, options.origin, cwd, app, session);
374
382
  if (open ? open.some((title) => cardFits(title, ask.words, 'open')) : saysPosted(text))
375
383
  return 0;
376
384
  return block(confirmReason(ask.words, sessionLink(io.env), APP_NAMES[app]));
@@ -378,7 +386,7 @@ export async function stopcheck(io, options) {
378
386
  if (turn.posted || saysPosted(text) || saysNoCard(text) || !itemsOutsideRecaps(text))
379
387
  return 0;
380
388
  if (mentionsCards(text)) {
381
- const open = await openTitles(io, options.origin, cwd, app);
389
+ const open = await openTitles(io, options.origin, cwd, app, session);
382
390
  if (open === null || open.some((title) => !isPermissionCard(title)))
383
391
  return 0;
384
392
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.22.0';
1
+ export const VERSION = '0.25.0';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.34.1",
4
+ "version": "2.37.0",
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.1; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.37.0; 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
@@ -31,6 +31,7 @@ const READS = new Set([
31
31
  'hand_back',
32
32
  ]);
33
33
  export const HANDED_READS = new Set(['get_request', 'answer_delegated', 'hand_back']);
34
+ export const APPROVED_DRAFT_NOTICE = 'The approved words are in get_request’s answer.approvedDrafts: send them exactly.';
34
35
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
35
36
  const parseJson = (text) => {
36
37
  try {
@@ -118,12 +119,16 @@ export function stateOf(output) {
118
119
  return null;
119
120
  const given = isObject(output.answer) ? output.answer.handled : output.handled;
120
121
  const handled = given === 'self' || given === 'leave' ? given : undefined;
122
+ const approvedDraft = isObject(output.answer)
123
+ ? Array.isArray(output.answer.approvedDrafts) && output.answer.approvedDrafts.length > 0
124
+ : output.approvedDraft === true;
121
125
  return {
122
126
  status,
123
127
  turn,
124
128
  version,
125
129
  cursor: typeof output.cursor === 'string' ? output.cursor : '',
126
130
  ...(handled ? { handled } : {}),
131
+ ...(approvedDraft ? { approvedDraft: true } : {}),
127
132
  };
128
133
  }
129
134
  export const emptyBook = (now = 0) => ({ v: 1, updatedAt: now, cards: {} });
@@ -395,7 +400,7 @@ function line(card) {
395
400
  ];
396
401
  return [
397
402
  `your person answered ${titled(card)}`,
398
- `call ${get}, act on their words, then ack_answer with its version and a one-line outcome`,
403
+ `call ${get}, act on their words, then ack_answer with its version and a one-line outcome${state.approvedDraft ? `. ${APPROVED_DRAFT_NOTICE.slice(0, -1)}` : ''}`,
399
404
  ];
400
405
  case 'delegated':
401
406
  return [
@@ -616,7 +621,15 @@ export function handedOf(output) {
616
621
  const updatedAt = text(request.updatedAt, 40);
617
622
  const cwd = text(request.cwd, 1000);
618
623
  return requestId && title && updatedAt
619
- ? [{ requestId, title, updatedAt, ...(cwd ? { cwd } : {}) }]
624
+ ? [
625
+ {
626
+ requestId,
627
+ title,
628
+ updatedAt,
629
+ ...(cwd ? { cwd } : {}),
630
+ ...(request.byName === true ? { byName: true } : {}),
631
+ },
632
+ ]
620
633
  : [];
621
634
  });
622
635
  }
@@ -641,7 +654,7 @@ export function inTaskFolder(folders, task, home) {
641
654
  });
642
655
  }
643
656
  export function takesHanded(handed, folders, home, now) {
644
- if (!handed.cwd)
657
+ if (handed.byName || !handed.cwd)
645
658
  return true;
646
659
  if (folders.length === 0)
647
660
  return now - Date.parse(handed.updatedAt) >= HANDED_GRACE_MS;
@@ -760,10 +773,18 @@ export function setupPrompt(connection = 'Claude Code') {
760
773
  export const PRESENCE_EVERY_MS = 5 * 60_000;
761
774
  export const PRESENCE_RUN_MS = 15_000;
762
775
  const PRODUCTION = 'https://www.pendingyou.com';
763
- export function cliConfigOf(root) {
776
+ export function cliConfigOf(root, name = CLI_COPY) {
777
+ if (name !== CLI_COPY)
778
+ return null;
764
779
  return (/^(.+)[\\/]cli[\\/][^\\/]+[\\/]node_modules[\\/]pendingyou[\\/]mod[\\/]?$/.exec(root)?.[1] ??
780
+ /^(.+)[\\/]mod[\\/]?$/.exec(root)?.[1] ??
765
781
  null);
766
782
  }
783
+ const SESSION_FILE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
784
+ export function loadedPath(config, sessionId) {
785
+ return SESSION_FILE.test(sessionId) ? `${config}/wake/sessions/${sessionId}.json` : null;
786
+ }
787
+ export const loadedNote = (at) => `${JSON.stringify({ at: Math.floor(at) })}\n`;
767
788
  export function originOf(manifest) {
768
789
  let parsed = manifest;
769
790
  if (typeof manifest === 'string')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.22.0",
3
+ "version": "0.25.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",