pendingyou 0.6.0 → 0.8.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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - The hooks say when a session is on another Pending You. A Claude Code session stayed connected to staging after its
6
+ person moved to production, so its cards went where they no longer looked. Now both hooks (`pickup` at session
7
+ start, `handoff` with every message) compare the `pendingyou` MCP server Claude Code uses in the session's folder
8
+ with the origin they run against (`--origin`), and when they're different sites add one line (about 45 tokens):
9
+ `Pending You: your MCP server is https://staging.pendingyou.com but your hooks and sign-in are
10
+ https://www.pendingyou.com, so your cards and their answers may not meet. Ask your person which one they use, and
11
+ restart the session after fixing it (npx pendingyou init --origin <that one>).` The server is the one Claude Code
12
+ would use there: local scope (`~/.claude.json`'s `projects`), then the folder's `.mcp.json`, then user scope, then
13
+ the Pending You plugin when `~/.claude/settings.json` enables it (production's server). All read only, and only the
14
+ `pendingyou` server's address is looked at; nothing is sent. `pendingyou.com` and `www.pendingyou.com` are the same
15
+ site; a file that can't be read is never a mismatch. The line is said even when the hooks' origin isn't signed in,
16
+ and it's kept when a hook reaches its 3-second deadline.
17
+ - `status` says the same: a `Mismatch:` line naming both sites and what to do, a line for a folder's own
18
+ (local- or project-scope) server that points elsewhere, and "Not ready" (exit 1) until they agree.
19
+
20
+ ## 0.7.0
21
+
22
+ - Faster hooks. They ran `npx -y --prefer-offline pendingyou@<version> …`, and npx alone took about half a second
23
+ (and real CPU) before each one started; sessions starting or resuming together queued on npm's cache lock. `init`
24
+ now keeps a copy of this version in `~/.config/pendingyou/cli/<version>/` (copied from the package it runs from, or
25
+ installed with `npm install --prefix`, checked before it's used) and the hooks run it with Node directly:
26
+ `"<node>" "~/.config/pendingyou/cli/0.7.0/node_modules/pendingyou/dist/cli.js" handoff || true`. A signed-out
27
+ pickup went from 0.42 s to 0.04 s; a hand-off with its request from 0.77 s to 0.26 s. When the copy can't be made,
28
+ the hooks keep the npx form. Running `init` again upgrades the copy in place and removes old versions;
29
+ `uninstall` removes it; `status` says how the hooks run. To switch: `npx pendingyou@latest init`.
30
+ - `init` no longer looks hung: it says before each slow step what it's waiting on ("Adding the pendingyou MCP server
31
+ to Claude Code (this can take a minute)…"), gives Claude Code 3 minutes, and says plainly if it ran out. It reads
32
+ the MCP server from `~/.claude.json` (read only) instead of asking `claude mcp get`, which takes a minute, so a
33
+ server that's already there costs no `claude` call at all.
34
+ - Fixed: `init` kept a `pendingyou` MCP server that pointed at another origin (staging, when you asked for
35
+ production) and only said "check Claude Code didn't add the MCP server". It now names both addresses and replaces
36
+ the old one once you say so: asked at the terminal, or `--yes` to replace without asking. With nobody to ask, it
37
+ prints the two `claude mcp` commands. Then restart Claude Code, run `/mcp` and Authenticate.
38
+ - Everything from 0.6.0 stays: the 3-second deadline (which no longer loses half a second to npx), the background
39
+ refresh and the Stop check.
40
+
3
41
  ## 0.6.0
4
42
 
5
43
  - Fixed: a hook could run past Claude Code's 10 seconds (`UserPromptSubmit hook … timed out after 10s — output
package/README.md CHANGED
@@ -15,15 +15,33 @@ Run it once on each computer. It:
15
15
  - adds three hooks to `~/.claude/settings.json`: a new session picks up answers that arrived while nothing was
16
16
  running, your next message brings a waiting answer with it, and a check when Claude Code finishes a turn (below),
17
17
  - lets Claude Code start `npx pendingyou hold` and use Pending You's tools without a permission prompt, so a question
18
- it asks while you're away doesn't wait on one.
18
+ it asks while you're away doesn't wait on one,
19
+ - keeps a copy of this version for the hooks in `~/.config/pendingyou/cli/<version>/`, so they start in a few
20
+ hundredths of a second instead of waiting on npx (about half a second each).
19
21
 
20
22
  Your other settings are kept as they are. `npx pendingyou uninstall` removes exactly what `init` added.
21
23
 
24
+ Adding the MCP server can take Claude Code a minute or more (it checks its servers); `init` says when it's waiting on
25
+ that. If Claude Code already has a `pendingyou` server pointing somewhere else (staging, say), `init` names both and
26
+ asks before replacing it; `--yes` replaces it without asking, and with nobody to ask it prints the two commands to
27
+ run. Afterwards restart Claude Code, run `/mcp`, choose pendingyou and Authenticate.
28
+
29
+ If the `pendingyou` MCP server Claude Code uses in a session's folder is another Pending You than the hooks' (staging
30
+ and production, say), both hooks add one line telling Claude Code so, and to ask you which one you use: its cards
31
+ would otherwise go where you don't look. `npx pendingyou status` flags it too. Fix it with `npx pendingyou init
32
+ --origin <the one you use>`, then restart the session.
33
+
22
34
  The hooks never get in your way: if anything goes wrong (no network, not signed in, a bad option), they print nothing
23
35
  and let your message through. Each one stops within 3 seconds whatever happens, with at most its one-line reminder, and
24
36
  never refreshes the sign-in itself: when that's due, it starts the refresh in the background, which finishes on its
25
37
  own. Running `init` again updates them in place, and adds any a newer version brings.
26
38
 
39
+ The hooks run that copy with Node directly, like
40
+ `"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.8.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
41
+ paths in full). If the copy can't be made (no npm, no network), they run through `npx -y --prefer-offline
42
+ pendingyou@<version>` instead. `npx pendingyou status` says which. If you remove the Node they run with, run `init`
43
+ again.
44
+
27
45
  ## Nothing left only in chat
28
46
 
29
47
  Deep into a long conversation, Claude Code tends to end a turn with a question for you, a step only you can do, or a
@@ -94,7 +112,7 @@ more, then skipped. `--once` stops after the first.
94
112
 
95
113
  | Command | What it does |
96
114
  | --- | --- |
97
- | `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`, `--device`) |
115
+ | `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`, `--device`, `--yes`) |
98
116
  | `hold <requestId>` | Wait for the answer, print it, exit (`--timeout 4h`) |
99
117
  | `status` | What's set up; exits 0 when Claude Code hears answers right away |
100
118
  | `login` / `logout` | Sign this computer in or out (`--device`, `--force`, `--all`) |
@@ -111,7 +129,10 @@ before or after the command.
111
129
  The sign-in only hears answers to your own Claude Code assistants' questions, or to the one assistant you picked. It isn't an assistant: it can't ask,
112
130
  answer or change anything, and it doesn't show up as one. It's stored in `~/.config/pendingyou/credentials.json`
113
131
  (readable only by you), refreshes itself, and ends after 30 days or on `logout`. `state.json` beside it remembers
114
- which answers were already handed over (ids only). Nothing is sent anywhere but Pending You.
132
+ which answers were already handed over (ids only), `claude-code.json` what `init` added, and `cli/` the hooks' copy
133
+ of the command line. `init`, `status` and the hooks read `~/.claude.json` (and a folder's `.mcp.json`, and
134
+ `~/.claude/settings.json`'s enabled plugins) only to see whether Claude Code has a `pendingyou` MCP server and where
135
+ it points. Nothing is sent anywhere but Pending You.
115
136
 
116
137
  ## Development
117
138
 
package/dist/args.js CHANGED
@@ -62,7 +62,7 @@ const FLAGS = {
62
62
  pickup: { origin: true },
63
63
  handoff: { origin: true },
64
64
  stopcheck: { origin: true },
65
- init: { origin: true, 'no-login': false, 'no-browser': false, device: false },
65
+ init: { origin: true, 'no-login': false, 'no-browser': false, device: false, yes: false },
66
66
  uninstall: { origin: true },
67
67
  watch: { origin: true, once: false },
68
68
  refresh: { origin: true, force: false },
@@ -191,6 +191,7 @@ export function parseArgs(argv, env = {}) {
191
191
  login: !flags.has('no-login'),
192
192
  browser: !flags.has('no-browser'),
193
193
  device: flags.has('device'),
194
+ yes: flags.has('yes'),
194
195
  };
195
196
  case 'watch':
196
197
  none();
@@ -230,5 +231,6 @@ export const USAGE = `pendingyou: Pending You for Claude Code
230
231
  npx pendingyou stopcheck Questions or steps for you left only in chat (the Stop hook)
231
232
 
232
233
  Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser and --device (login,
233
- init), --no-login (init), --all (logout), --force (login), --once (watch).
234
+ init), --no-login (init), --yes (init: replace a pendingyou MCP server that points elsewhere without asking),
235
+ --all (logout), --force (login), --once (watch).
234
236
  `;
package/dist/claude.js CHANGED
@@ -3,12 +3,17 @@
3
3
  // ~/.claude/settings.json. Settings are merged, never replaced: a file that isn't JSON is left alone, entries that
4
4
  // aren't ours are never touched, and running init again changes nothing it doesn't need to. What init added is written
5
5
  // down in ~/.config/pendingyou/claude-code.json, so uninstall removes exactly that.
6
+ //
7
+ // Since 0.7.0 the hooks run a private copy of this version with Node directly (install.ts), and init asks Claude Code
8
+ // itself as little as it can: `claude mcp get` and `claude mcp add` each take up to a minute or more (Claude Code checks
9
+ // its servers), so the MCP server is looked up in ~/.claude.json first (read only), and init says what it's waiting on.
6
10
  import { createHash } from 'node:crypto';
7
11
  import { rm, rmdir } from 'node:fs/promises';
8
12
  import { dirname, join } from 'node:path';
9
13
  import { DEFAULT_ORIGIN } from "./args.js";
10
14
  import { PlainError } from "./errors.js";
11
15
  import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
16
+ import { hookForm, hookRuns, installCli, pruneCli, removeCli, shellQuote, } from "./install.js";
12
17
  import { VERSION } from "./version.js";
13
18
  export const MCP_NAME = 'pendingyou';
14
19
  /** The hooks, by Claude Code event, and the command each runs. */
@@ -46,13 +51,15 @@ export function selfCommand(io) {
46
51
  : `node ${quote(io.script)}`;
47
52
  }
48
53
  /**
49
- * A hook's command line: the command first, then `--origin` only when it isn't the default. `|| true` keeps the hook
50
- * from ever blocking the person, even when npx can't fetch the package or the command fails before it starts (Claude
51
- * Code blocks a message when its UserPromptSubmit hook exits 2, and flags any other non-zero exit).
54
+ * A hook's command line: the private copy run by Node directly when there is one (`"<node>" "<…/cli.js>"`, both
55
+ * quoted), otherwise selfCommand; the command first, then `--origin` only when it isn't the default. `|| true` keeps
56
+ * the hook from ever blocking the person, even when npx can't fetch the package or the command fails before it starts
57
+ * (Claude Code blocks a message when its UserPromptSubmit hook exits 2, and flags any other non-zero exit).
52
58
  */
53
- export function hookCommand(io, origin, sub) {
59
+ export function hookCommand(io, origin, sub, direct) {
54
60
  const flag = origin === DEFAULT_ORIGIN ? '' : ` --origin ${quote(origin)}`;
55
- return `${selfCommand(io)} ${sub}${flag} || true`;
61
+ const self = direct ? `${shellQuote(direct.node)} ${shellQuote(direct.script)}` : selfCommand(io);
62
+ return `${self} ${sub}${flag} || true`;
56
63
  }
57
64
  /** Whether a hook command is one of ours: it runs pendingyou's `pickup`, `handoff` or `stopcheck`. */
58
65
  export function isOurHook(command, sub) {
@@ -170,39 +177,157 @@ export function unmergeSettings(settings, permissionsAdded) {
170
177
  return { settings: next, changed };
171
178
  }
172
179
  const writeSettings = (io, settings) => writeWhole(settingsPath(io), `${JSON.stringify(settings, null, 2)}\n`);
173
- /** The MCP server at user scope, unless Claude Code already has one called pendingyou. */
174
- async function addMcp(io, origin) {
180
+ /** How long Claude Code may take to look up, add or remove an MCP server (it checks each server it has). */
181
+ const MCP_TIMEOUT_MS = 180_000;
182
+ /** Claude Code's own state file: ~/.claude.json, or .claude.json in $CLAUDE_CONFIG_DIR. */
183
+ export const claudeJsonPath = (io) => io.env.CLAUDE_CONFIG_DIR
184
+ ? join(io.env.CLAUDE_CONFIG_DIR, '.claude.json')
185
+ : join(io.home, '.claude.json');
186
+ /**
187
+ * The user-scope pendingyou MCP server, read from ~/.claude.json without asking Claude Code (which takes a minute):
188
+ * the server, null when there isn't one, or 'unknown' when the file isn't there or isn't the shape expected. Only
189
+ * mcpServers.pendingyou is looked at; nothing is written.
190
+ */
191
+ export async function mcpFromFile(io) {
192
+ let parsed;
193
+ try {
194
+ const text = await readText(claudeJsonPath(io));
195
+ if (text === null)
196
+ return 'unknown';
197
+ parsed = JSON.parse(text);
198
+ }
199
+ catch {
200
+ return 'unknown';
201
+ }
202
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
203
+ return 'unknown';
204
+ const servers = parsed.mcpServers;
205
+ if (servers === undefined)
206
+ return null;
207
+ if (typeof servers !== 'object' || servers === null || Array.isArray(servers))
208
+ return 'unknown';
209
+ const server = servers[MCP_NAME];
210
+ if (server === undefined)
211
+ return null;
212
+ if (typeof server !== 'object' || server === null)
213
+ return 'unknown';
214
+ const url = server.url;
215
+ return { url: typeof url === 'string' ? url : null, scope: 'user' };
216
+ }
217
+ /** `claude mcp get pendingyou`'s answer: the server, null when there isn't one, 'timeout' when it took too long. */
218
+ async function mcpFromClaude(io) {
219
+ const result = await io.run('claude', ['mcp', 'get', MCP_NAME], MCP_TIMEOUT_MS);
220
+ if (result.code === 124)
221
+ return 'timeout';
222
+ if (result.code !== 0)
223
+ return null;
224
+ const url = /^\s*URL:\s*(\S+)/m.exec(result.stdout)?.[1] ?? null;
225
+ const scope = /^\s*Scope:\s*(\w+)/m.exec(result.stdout)?.[1]?.toLowerCase();
226
+ return {
227
+ url,
228
+ scope: scope === 'user' || scope === 'local' || scope === 'project' ? scope : null,
229
+ };
230
+ }
231
+ const sameUrl = (a, b) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
232
+ const addCommand = (url) => `claude mcp add --scope user --transport http ${MCP_NAME} ${url}`;
233
+ const removeCommand = (scope) => `claude mcp remove${scope ? ` --scope ${scope}` : ''} ${MCP_NAME}`;
234
+ /**
235
+ * The MCP server at user scope. One already there pointing at this origin is kept; one pointing elsewhere (staging
236
+ * when you asked for production) is replaced once you say so: asked at the terminal, `--yes` without asking, or, with
237
+ * nobody to ask, the two commands are printed.
238
+ */
239
+ async function addMcp(io, origin, options, progress) {
240
+ const url = `${origin}/mcp`;
241
+ let existing = await mcpFromFile(io);
242
+ if (existing !== 'unknown' && existing?.url && sameUrl(existing.url, url))
243
+ return {
244
+ added: false,
245
+ step: { ok: true, text: `The pendingyou MCP server (${url}) was already in Claude Code.` },
246
+ };
175
247
  const version = await io.run('claude', ['--version'], 20_000);
176
248
  if (version.code === 127)
177
249
  return {
178
250
  added: false,
179
251
  step: {
180
252
  ok: false,
181
- text: `Claude Code isn’t on this computer’s PATH. Install it, then run: claude mcp add --scope user --transport http ${MCP_NAME} ${origin}/mcp`,
253
+ text: `Claude Code isn’t on this computer’s PATH. Install it, then run: ${addCommand(url)}`,
182
254
  },
183
255
  };
184
- const existing = await io.run('claude', ['mcp', 'get', MCP_NAME], 20_000);
185
- if (existing.code === 0)
256
+ if (existing === 'unknown') {
257
+ progress('Checking Claude Code’s MCP servers (this can take a minute)…\n');
258
+ existing = await mcpFromClaude(io);
259
+ if (existing === 'timeout')
260
+ return {
261
+ added: false,
262
+ step: {
263
+ ok: false,
264
+ text: `Claude Code took more than 3 minutes to list its MCP servers, so I left them alone. Check with: claude mcp get ${MCP_NAME}. If it isn’t there, run: ${addCommand(url)}`,
265
+ },
266
+ };
267
+ }
268
+ let replacing = null;
269
+ if (existing) {
270
+ if (!existing.url || sameUrl(existing.url, url))
271
+ return {
272
+ added: false,
273
+ step: { ok: true, text: 'The pendingyou MCP server was already in Claude Code.' },
274
+ };
275
+ progress(`Claude Code’s pendingyou MCP server points at ${existing.url}, not ${url}.\n`);
276
+ const commands = ` ${removeCommand(existing.scope)}\n ${addCommand(url)}`;
277
+ let replace = options.yes;
278
+ if (!replace && io.interactive) {
279
+ const answer = await io.ask(`Replace it with ${url}? [y/N] `);
280
+ replace = /^\s*y(es)?\s*$/i.test(answer);
281
+ if (!replace)
282
+ return {
283
+ added: false,
284
+ step: {
285
+ ok: false,
286
+ text: `Kept the MCP server at ${existing.url}. To switch later, run:\n ${commands}`,
287
+ },
288
+ };
289
+ }
290
+ if (!replace)
291
+ return {
292
+ added: false,
293
+ step: {
294
+ ok: false,
295
+ text: `Kept the MCP server at ${existing.url}. To use ${url}, run init again with --yes, or run:\n ${commands}`,
296
+ },
297
+ };
298
+ progress('Removing the old pendingyou MCP server (this can take a minute)…\n');
299
+ const removed = await io.run('claude', ['mcp', 'remove', ...(existing.scope ? ['--scope', existing.scope] : []), MCP_NAME], MCP_TIMEOUT_MS);
300
+ if (removed.code !== 0)
301
+ return {
302
+ added: false,
303
+ step: {
304
+ ok: false,
305
+ text: `${removed.code === 124 ? 'Claude Code took more than 3 minutes to remove' : 'Claude Code didn’t remove'} the old MCP server. Run:\n ${commands}`,
306
+ },
307
+ };
308
+ replacing = existing;
309
+ }
310
+ progress('Adding the pendingyou MCP server to Claude Code (this can take a minute)…\n');
311
+ const add = await io.run('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', MCP_NAME, url], MCP_TIMEOUT_MS);
312
+ if (add.code === 0)
186
313
  return {
187
- added: false,
188
- step: { ok: true, text: 'The pendingyou MCP server was already in Claude Code.' },
189
- };
190
- const add = await io.run('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', MCP_NAME, `${origin}/mcp`], 30_000);
191
- return add.code === 0
192
- ? {
193
314
  added: true,
194
315
  step: {
195
316
  ok: true,
196
- text: `Added the pendingyou MCP server (${origin}/mcp) for every project.`,
197
- },
198
- }
199
- : {
200
- added: false,
201
- step: {
202
- ok: false,
203
- text: `Claude Code didn’t add the MCP server. Run: claude mcp add --scope user --transport http ${MCP_NAME} ${origin}/mcp`,
317
+ text: replacing
318
+ ? `Replaced the pendingyou MCP server (was ${replacing.url}) with ${url}. Restart Claude Code, then run /mcp, choose pendingyou and Authenticate.`
319
+ : `Added the pendingyou MCP server (${url}) for every project.`,
204
320
  },
205
321
  };
322
+ return {
323
+ added: false,
324
+ step: {
325
+ ok: false,
326
+ text: add.code === 124
327
+ ? `Claude Code took more than 3 minutes to add the MCP server. Check with: claude mcp get ${MCP_NAME}. If it isn’t there, run: ${addCommand(url)}`
328
+ : `Claude Code didn’t add the MCP server. Run: ${addCommand(url)}`,
329
+ },
330
+ };
206
331
  }
207
332
  /** The stub skill: written when it isn't there, or refreshed while it's still the one init wrote. */
208
333
  async function saveSkill(io, origin, before) {
@@ -238,27 +363,50 @@ async function saveSkill(io, origin, before) {
238
363
  step: { ok: true, text: `Saved the skill to ${path}.` },
239
364
  };
240
365
  }
241
- /** Everything init does in Claude Code (not the sign-in). Idempotent. */
242
- export async function installClaude(io, origin) {
366
+ /** Everything init does in Claude Code (not the sign-in). Idempotent. Each step is reported as it's done. */
367
+ export async function installClaude(io, origin, options = {}) {
243
368
  const before = await readManifest(io);
244
369
  const steps = [];
245
- const mcp = await addMcp(io, origin);
246
- steps.push(mcp.step);
370
+ const report = (step) => {
371
+ steps.push(step);
372
+ options.report?.(step);
373
+ };
374
+ const progress = (text) => io.out(text);
375
+ const mcp = await addMcp(io, origin, { yes: options.yes ?? false }, progress);
376
+ report(mcp.step);
247
377
  const skill = await saveSkill(io, origin, before?.skill ?? null);
248
- steps.push(skill.step);
378
+ report(skill.step);
379
+ // The hooks' own copy of this version, unless PENDINGYOU_SELF says what they run.
380
+ let installed = null;
381
+ if (!io.env.PENDINGYOU_SELF) {
382
+ const cli = await installCli(io, progress);
383
+ installed = cli.installed;
384
+ report(cli.installed
385
+ ? {
386
+ ok: true,
387
+ text: `${cli.installed.how === 'kept' ? `pendingyou ${VERSION} for the hooks was already in` : `Installed pendingyou ${VERSION} for the hooks in`} ${cli.installed.prefix} (they run it with ${cli.installed.node}, no npx).`,
388
+ }
389
+ : {
390
+ ok: true,
391
+ text: `Couldn’t install pendingyou for the hooks (${cli.error}), so they run it through npx, which is slower. Run init again to retry.`,
392
+ });
393
+ }
249
394
  const commands = Object.fromEntries(Object.keys(HOOKS).map((event) => [
250
395
  event,
251
- hookCommand(io, origin, HOOKS[event]),
396
+ hookCommand(io, origin, HOOKS[event], installed),
252
397
  ]));
253
398
  const merged = mergeSettings(await readSettings(io), commands);
254
399
  if (merged.changed)
255
400
  await writeSettings(io, merged.settings);
256
- steps.push({
401
+ report({
257
402
  ok: true,
258
403
  text: merged.changed
259
404
  ? `Installed or updated the session-start pickup, next-message hand-off and Stop check hooks in ${settingsPath(io)}.`
260
405
  : 'The hooks were already installed.',
261
406
  });
407
+ // The hooks run this version now (or npx): the copies of other versions are no longer used.
408
+ if (!io.env.PENDINGYOU_SELF)
409
+ await pruneCli(io, installed?.version ?? null);
262
410
  const manifest = {
263
411
  version: 1,
264
412
  origin,
@@ -267,6 +415,7 @@ export async function installClaude(io, origin) {
267
415
  skill: skill.skill,
268
416
  hooks: commands,
269
417
  permissions: [...new Set([...(before?.permissions ?? []), ...merged.permissionsAdded])],
418
+ cli: installed ? { version: installed.version, prefix: installed.prefix } : null,
270
419
  };
271
420
  await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
272
421
  return steps;
@@ -295,8 +444,11 @@ export async function uninstallClaude(io) {
295
444
  else if (text !== null)
296
445
  steps.push({ ok: true, text: `Kept ${manifest.skill.path}: it was changed after init.` });
297
446
  }
447
+ if (await removeCli(io))
448
+ steps.push({ ok: true, text: 'Removed the hooks’ copy of pendingyou.' });
298
449
  if (manifest?.mcpAdded) {
299
- const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], 30_000);
450
+ io.out('Removing the pendingyou MCP server from Claude Code (this can take a minute)…\n');
451
+ const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], MCP_TIMEOUT_MS);
300
452
  steps.push(result.code === 0
301
453
  ? { ok: true, text: 'Removed the pendingyou MCP server from Claude Code.' }
302
454
  : {
@@ -311,20 +463,33 @@ export async function uninstallClaude(io) {
311
463
  export async function claudeState(io) {
312
464
  const version = await io.run('claude', ['--version'], 20_000);
313
465
  const claude = version.code === 0 ? version.stdout.trim().split(/\s+/)[0] || 'installed' : null;
314
- const mcp = claude ? (await io.run('claude', ['mcp', 'get', MCP_NAME], 20_000)).code === 0 : false;
466
+ let mcp = false;
467
+ const fromFile = await mcpFromFile(io);
468
+ if (fromFile !== 'unknown')
469
+ mcp = fromFile ?? false;
470
+ else if (claude) {
471
+ const fromClaude = await mcpFromClaude(io);
472
+ mcp = fromClaude === 'timeout' ? 'unknown' : (fromClaude ?? false);
473
+ }
315
474
  const skill = (await readText(skillPath(io))) !== null;
316
- let installed = (_event) => false;
475
+ let ours = (_event) => undefined;
317
476
  try {
318
477
  const all = objectAt(await readSettings(io), 'hooks') ?? {};
319
- installed = (event) => Array.isArray(all[event]) &&
320
- all[event].some((group) => (group.hooks ?? []).some((hook) => isOurHook(hook.command, HOOKS[event])));
478
+ ours = (event) => Array.isArray(all[event])
479
+ ? all[event]
480
+ .flatMap((group) => group.hooks ?? [])
481
+ .find((hook) => isOurHook(hook.command, HOOKS[event]))
482
+ : undefined;
321
483
  }
322
484
  catch { }
485
+ const start = ours('SessionStart');
486
+ const form = typeof start?.command === 'string' ? hookForm(start.command) : null;
323
487
  return {
324
488
  claude,
325
489
  mcp,
326
490
  skill,
327
- hooks: installed('SessionStart') && installed('UserPromptSubmit'),
328
- stopcheck: installed('Stop'),
491
+ hooks: Boolean(start && ours('UserPromptSubmit')),
492
+ stopcheck: Boolean(ours('Stop')),
493
+ form: form ? { ...form, runs: await hookRuns(form) } : null,
329
494
  };
330
495
  }
@@ -0,0 +1,98 @@
1
+ // Is this session on the Pending You its hooks are on? A Claude Code session stayed connected to staging after its
2
+ // person moved to production (2026-10-01): its cards went where they no longer looked, and the hooks, signed in to
3
+ // production, never heard them. So the hooks compare the `pendingyou` MCP server Claude Code has for the session's
4
+ // folder (read from ~/.claude.json and the folder's .mcp.json, read only) with the origin they run against (`--origin`),
5
+ // and when they differ, add one line telling the agent to ask its person which one they use. `status` says the same.
6
+ //
7
+ // Only the pendingyou server's url is read, never anything else from those files; nothing is written, nothing is sent.
8
+ // A server Claude Code can't be read for (no file, another shape, a plugin's server) is never called a mismatch.
9
+ import { join } from 'node:path';
10
+ import { claudeJsonPath, MCP_NAME } from "./claude.js";
11
+ import { claudeDir, readText } from "./files.js";
12
+ /**
13
+ * The published Pending You plugin for Claude (recordplane/pendingyou-plugin) and where its server is. A hand-added
14
+ * pendingyou server at any scope overrides it in Claude Code, so it counts only when there's none.
15
+ */
16
+ export const PLUGIN_ID = 'pending-you@pendingyou';
17
+ export const PLUGIN_MCP_URL = 'https://www.pendingyou.com/mcp';
18
+ const objectAt = (value, key) => {
19
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
20
+ return null;
21
+ const found = value[key];
22
+ return typeof found === 'object' && found !== null && !Array.isArray(found)
23
+ ? found
24
+ : null;
25
+ };
26
+ const urlOf = (servers) => {
27
+ const url = objectAt(servers, MCP_NAME)?.url;
28
+ return typeof url === 'string' && url.length > 0 ? url : null;
29
+ };
30
+ async function readJsonQuietly(path) {
31
+ try {
32
+ const text = await readText(path);
33
+ return text === null ? null : JSON.parse(text);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * The `pendingyou` MCP server Claude Code uses in `folder`: local scope (~/.claude.json's projects[folder]), then the
41
+ * folder's own .mcp.json, then user scope (~/.claude.json's mcpServers), then the Pending You plugin when
42
+ * ~/.claude/settings.json enables it. Null when none of them has one with a url.
43
+ */
44
+ export async function serverForFolder(io, folder) {
45
+ const [state, project, settings] = await Promise.all([
46
+ readJsonQuietly(claudeJsonPath(io)),
47
+ readJsonQuietly(join(folder, '.mcp.json')),
48
+ readJsonQuietly(join(claudeDir(io), 'settings.json')),
49
+ ]);
50
+ const local = urlOf(objectAt(objectAt(objectAt(state, 'projects'), folder), 'mcpServers'));
51
+ if (local)
52
+ return { url: local, scope: 'local' };
53
+ const shared = urlOf(objectAt(project, 'mcpServers'));
54
+ if (shared)
55
+ return { url: shared, scope: 'project' };
56
+ const user = urlOf(objectAt(state, 'mcpServers'));
57
+ if (user)
58
+ return { url: user, scope: 'user' };
59
+ return objectAt(settings, 'enabledPlugins')?.[PLUGIN_ID] === true
60
+ ? { url: PLUGIN_MCP_URL, scope: 'plugin' }
61
+ : null;
62
+ }
63
+ /**
64
+ * The site an address is on, as one name: www.pendingyou.com and pendingyou.com are the same Pending You (the bare
65
+ * name redirects), so both read https://www.pendingyou.com. Null when it isn't an address.
66
+ */
67
+ export function siteOf(address) {
68
+ try {
69
+ const url = new URL(address);
70
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
71
+ return null;
72
+ const host = url.host === 'pendingyou.com' ? 'www.pendingyou.com' : url.host;
73
+ return `${url.protocol}//${host}`;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /** Whether the server and the hooks' origin are different Pending Yous. Unknown addresses never differ. */
80
+ export function differs(server, origin) {
81
+ if (!server)
82
+ return false;
83
+ const a = siteOf(server.url);
84
+ const b = siteOf(origin);
85
+ return a !== null && b !== null && a !== b;
86
+ }
87
+ /** The line a hook adds when they differ (about 45 tokens). */
88
+ export const mismatchLine = (server, origin) => `Pending You: your MCP server is ${siteOf(server.url)} but your hooks and sign-in are ${siteOf(origin)}, so your cards and their answers may not meet. Ask your person which one they use, and restart the session after fixing it (npx pendingyou init --origin <that one>).`;
89
+ /** The hook's warning for this folder, or null when the two agree or can't be told. Never throws. */
90
+ export async function environmentWarning(io, folder, origin) {
91
+ try {
92
+ const server = await serverForFolder(io, folder);
93
+ return differs(server, origin) ? mismatchLine(server, origin) : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
@@ -0,0 +1,163 @@
1
+ // The command line's own copy for Claude Code's hooks (0.7.0). Hooks used to run `npx -y --prefer-offline
2
+ // pendingyou@<version> …`: npx costs about half a second before the command starts, and sessions starting together
3
+ // queue on npm's cache lock. Now `init` puts this exact version in a private folder,
4
+ // ~/.config/pendingyou/cli/<version>/node_modules/pendingyou, and the hooks run it with Node directly:
5
+ //
6
+ // "<node>" "<config>/cli/<version>/node_modules/pendingyou/dist/cli.js" handoff || true
7
+ //
8
+ // The copy comes from the package init is running from when that's this very version (npx's copy: no network), and
9
+ // otherwise from `npm install --prefix`. It's built in a temporary folder, checked (`--version` must print this
10
+ // version), then moved into place, so a hook never runs half a copy. Running init again keeps a copy that checks out,
11
+ // and removes the other versions once the hooks point at this one. When none of that works, the hooks keep the npx
12
+ // form, which is slower but works.
13
+ import { randomBytes } from 'node:crypto';
14
+ import { cp, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
15
+ import { basename, dirname, isAbsolute, join } from 'node:path';
16
+ import { configDir, readJson } from "./files.js";
17
+ import { VERSION } from "./version.js";
18
+ /** Where the private copies live: ~/.config/pendingyou/cli. */
19
+ export const cliRoot = (io) => join(configDir(io), 'cli');
20
+ /** One version's prefix: ~/.config/pendingyou/cli/<version>. */
21
+ export const cliPrefix = (io, version = VERSION) => join(cliRoot(io), version);
22
+ /** The command line inside a prefix. */
23
+ export const cliScript = (prefix) => join(prefix, 'node_modules', 'pendingyou', 'dist', 'cli.js');
24
+ /** How long npm may take to install the package (it usually takes a few seconds). */
25
+ const NPM_TIMEOUT_MS = 180_000;
26
+ const exists = (path) => stat(path).then(() => true, () => false);
27
+ /**
28
+ * The Node the hooks should run: the running one, by the path on PATH that leads to it when there is one
29
+ * (Homebrew's /opt/homebrew/bin/node outlives an upgrade; the versioned folder it points into doesn't).
30
+ */
31
+ export async function hookNode(io) {
32
+ const running = await realpath(io.execPath).catch(() => null);
33
+ if (running)
34
+ for (const dir of (io.env.PATH ?? '').split(':')) {
35
+ if (!dir || !isAbsolute(dir))
36
+ continue;
37
+ const candidate = join(dir, 'node');
38
+ if ((await realpath(candidate).catch(() => null)) === running)
39
+ return candidate;
40
+ }
41
+ return io.execPath;
42
+ }
43
+ /** Whether `script` run by `node` is this version of the command line. */
44
+ async function works(io, node, script) {
45
+ if (!(await exists(script)))
46
+ return false;
47
+ const result = await io.run(node, [script, '--version'], 30_000);
48
+ return result.code === 0 && result.stdout.trim() === VERSION;
49
+ }
50
+ /** The package init is running from, when it's this version of pendingyou (npx's copy, or a global install). */
51
+ async function runningPackage(io) {
52
+ if (!io.script || basename(io.script) !== 'cli.js' || basename(dirname(io.script)) !== 'dist')
53
+ return null;
54
+ const root = dirname(dirname(io.script));
55
+ const manifest = await readJson(join(root, 'package.json')).catch(() => null);
56
+ return manifest?.name === 'pendingyou' && manifest.version === VERSION ? root : null;
57
+ }
58
+ /** Copies the package's published files (no dependencies to bring: it has none). */
59
+ async function copyPackage(from, to) {
60
+ await mkdir(to, { recursive: true });
61
+ for (const name of ['package.json', 'dist', 'README.md', 'CHANGELOG.md', 'LICENSE'])
62
+ if (await exists(join(from, name)))
63
+ await cp(join(from, name), join(to, name), { recursive: true });
64
+ }
65
+ /**
66
+ * Puts this version in its private prefix, or keeps the one already there. Null (and why) when it couldn't: the hooks
67
+ * then use npx.
68
+ */
69
+ export async function installCli(io, progress) {
70
+ const node = await hookNode(io);
71
+ const prefix = cliPrefix(io);
72
+ const script = cliScript(prefix);
73
+ if (await works(io, node, script))
74
+ return { installed: { version: VERSION, prefix, node, script, how: 'kept' } };
75
+ const root = cliRoot(io);
76
+ const staging = join(root, `.tmp-${VERSION}-${randomBytes(4).toString('hex')}`);
77
+ try {
78
+ await mkdir(root, { recursive: true, mode: 0o700 });
79
+ let how = null;
80
+ const from = await runningPackage(io);
81
+ if (from) {
82
+ try {
83
+ await copyPackage(from, join(staging, 'node_modules', 'pendingyou'));
84
+ if (await works(io, node, cliScript(staging)))
85
+ how = 'copied';
86
+ }
87
+ catch { }
88
+ }
89
+ if (!how) {
90
+ await rm(staging, { recursive: true, force: true });
91
+ await mkdir(staging, { recursive: true });
92
+ progress(`Installing pendingyou@${VERSION} with npm, so the hooks start fast…\n`);
93
+ const result = await io.run('npm', [
94
+ 'install',
95
+ '--prefix',
96
+ staging,
97
+ '--no-save',
98
+ '--no-package-lock',
99
+ '--no-audit',
100
+ '--no-fund',
101
+ '--prefer-offline',
102
+ '--loglevel=error',
103
+ `pendingyou@${VERSION}`,
104
+ ], NPM_TIMEOUT_MS);
105
+ if (result.code === 127)
106
+ return { installed: null, error: 'npm isn’t on this computer’s PATH' };
107
+ if (result.code === 124)
108
+ return { installed: null, error: 'npm took more than 3 minutes to install it' };
109
+ if (result.code !== 0)
110
+ return { installed: null, error: 'npm couldn’t install it' };
111
+ if (!(await works(io, node, cliScript(staging))))
112
+ return { installed: null, error: 'the installed copy didn’t run' };
113
+ how = 'npm';
114
+ }
115
+ await rm(prefix, { recursive: true, force: true });
116
+ await rename(staging, prefix);
117
+ return { installed: { version: VERSION, prefix, node, script, how } };
118
+ }
119
+ catch {
120
+ return { installed: null, error: `couldn’t write to ${root}` };
121
+ }
122
+ finally {
123
+ await rm(staging, { recursive: true, force: true }).catch(() => { });
124
+ }
125
+ }
126
+ /** Removes every private copy but `keep` (the versions the hooks no longer run, and leftovers). */
127
+ export async function pruneCli(io, keep) {
128
+ const root = cliRoot(io);
129
+ const entries = await readdir(root).catch(() => []);
130
+ for (const entry of entries)
131
+ if (entry !== keep)
132
+ await rm(join(root, entry), { recursive: true, force: true }).catch(() => { });
133
+ }
134
+ /** Removes the private copies altogether (uninstall). True when there were any. */
135
+ export async function removeCli(io) {
136
+ const root = cliRoot(io);
137
+ if (!(await exists(root)))
138
+ return false;
139
+ await rm(root, { recursive: true, force: true });
140
+ return true;
141
+ }
142
+ /** A double-quoted shell word: safe for any path, spaces and quotes included. */
143
+ export const shellQuote = (text) => `"${text.replace(/["$`\\]/g, '\\$&')}"`;
144
+ const QUOTED = /^"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"\s/;
145
+ const unquote = (text) => text.replace(/\\(.)/g, '$1');
146
+ export function hookForm(command) {
147
+ const direct = QUOTED.exec(command);
148
+ if (direct) {
149
+ const script = unquote(direct[2]);
150
+ const version = /\/cli\/([^/]+)\/node_modules\/pendingyou\/dist\/cli\.js$/.exec(script);
151
+ return {
152
+ form: 'direct',
153
+ node: unquote(direct[1]),
154
+ script,
155
+ version: version?.[1] ?? null,
156
+ };
157
+ }
158
+ if (/^npx\s/.test(command))
159
+ return { form: 'npx', version: /pendingyou@([^\s]+)/.exec(command)?.[1] ?? null };
160
+ return { form: 'other' };
161
+ }
162
+ /** Whether a direct hook's Node and script are both still there. */
163
+ export const hookRuns = async (form) => form.form !== 'direct' || ((await exists(form.node)) && (await exists(form.script)));
package/dist/io.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // network, a fake browser and a fake `claude`. `realIo()` is the real thing.
3
3
  import { spawn } from 'node:child_process';
4
4
  import { homedir, hostname } from 'node:os';
5
+ import { createInterface } from 'node:readline';
5
6
  export function runProgram(command, args, timeoutMs = 60_000) {
6
7
  return new Promise((resolve) => {
7
8
  let stdout = '';
@@ -10,7 +11,6 @@ export function runProgram(command, args, timeoutMs = 60_000) {
10
11
  try {
11
12
  child = spawn(command, [...args], {
12
13
  stdio: ['ignore', 'pipe', 'pipe'],
13
- timeout: timeoutMs,
14
14
  windowsHide: true,
15
15
  });
16
16
  }
@@ -18,14 +18,23 @@ export function runProgram(command, args, timeoutMs = 60_000) {
18
18
  resolve({ code: 127, stdout, stderr });
19
19
  return;
20
20
  }
21
+ let timedOut = false;
22
+ const timer = setTimeout(() => {
23
+ timedOut = true;
24
+ child.kill('SIGTERM');
25
+ }, timeoutMs);
26
+ const done = (code) => {
27
+ clearTimeout(timer);
28
+ resolve({ code: timedOut ? 124 : code, stdout, stderr });
29
+ };
21
30
  child.stdout?.on('data', (chunk) => {
22
31
  stdout += String(chunk);
23
32
  });
24
33
  child.stderr?.on('data', (chunk) => {
25
34
  stderr += String(chunk);
26
35
  });
27
- child.on('error', (error) => resolve({ code: error.code === 'ENOENT' ? 127 : 1, stdout, stderr }));
28
- child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
36
+ child.on('error', (error) => done(error.code === 'ENOENT' ? 127 : 1));
37
+ child.on('close', (code) => done(code ?? 1));
29
38
  });
30
39
  }
31
40
  export function execProgram(command, args, options) {
@@ -96,6 +105,21 @@ function readStdin(timeoutMs) {
96
105
  process.stdin.on('error', finish);
97
106
  });
98
107
  }
108
+ function ask(question) {
109
+ return new Promise((resolve) => {
110
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
111
+ let answered = false;
112
+ prompt.on('close', () => {
113
+ if (!answered)
114
+ resolve('');
115
+ });
116
+ prompt.question(question, (answer) => {
117
+ answered = true;
118
+ prompt.close();
119
+ resolve(answer);
120
+ });
121
+ });
122
+ }
99
123
  async function openBrowser(url) {
100
124
  const [command, args] = process.platform === 'darwin'
101
125
  ? ['open', [url]]
@@ -138,6 +162,9 @@ export function realIo() {
138
162
  out: (text) => process.stdout.write(text),
139
163
  err: (text) => process.stderr.write(text),
140
164
  fetch: (input, init) => fetch(input, init),
165
+ execPath: process.execPath,
166
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
167
+ ask,
141
168
  run: runProgram,
142
169
  exec: execProgram,
143
170
  openBrowser,
package/dist/main.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // The command line: parse the arguments, run the command, return the exit code. `cli.ts` runs it for real; tests run
2
2
  // it with their own Io.
3
3
  import { getJson, refreshInBackground, SignInNeeded, Unavailable } from "./api.js";
4
- import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
4
+ import { commandOf, DEFAULT_ORIGIN, HOOK_COMMANDS, parseArgs, USAGE, UsageError, } from "./args.js";
5
5
  import { claudeState, installClaude, uninstallClaude } from "./claude.js";
6
6
  import { readCredential, signedInOrigins, updateCredential } from "./credentials.js";
7
+ import { differs, serverForFolder, siteOf } from "./environment.js";
7
8
  import { PlainError } from "./errors.js";
8
9
  import { hold } from "./hold.js";
9
10
  import { OAuthError, revoke, signIn, signInWithDevice } from "./oauth.js";
@@ -71,9 +72,34 @@ async function logout(io, options) {
71
72
  return 0;
72
73
  }
73
74
  async function status(io, origin) {
74
- const [signIn, claude] = await Promise.all([checkSignIn(io, origin), claudeState(io)]);
75
+ const [signIn, claude, here] = await Promise.all([
76
+ checkSignIn(io, origin),
77
+ claudeState(io),
78
+ serverForFolder(io, io.cwd),
79
+ ]);
75
80
  const lines = [`Pending You for Claude Code · ${origin}`];
76
81
  const mark = (ok) => (ok ? 'ok ' : 'missing');
82
+ const url = `${origin}/mcp`;
83
+ const mcp = claude.mcp;
84
+ const mcpOk = mcp !== false && mcp !== 'unknown' && (!mcp.url || mcp.url.replace(/\/+$/, '') === url);
85
+ const mcpText = mcp === 'unknown'
86
+ ? 'Claude Code didn’t say in time; check with claude mcp get pendingyou'
87
+ : mcp === false
88
+ ? 'not added; run npx pendingyou init'
89
+ : mcpOk
90
+ ? `pendingyou${mcp.url ? ` (${mcp.url})` : ''}`
91
+ : `points at ${mcp.url}, not ${url}, so sessions post cards where these hooks don’t hear them; run npx pendingyou init${origin === DEFAULT_ORIGIN ? '' : ` --origin ${origin}`} to replace it`;
92
+ const form = claude.form;
93
+ const hooksRun = form?.runs ?? true;
94
+ const formText = !form
95
+ ? ''
96
+ : form.form === 'direct'
97
+ ? form.runs
98
+ ? `they run pendingyou${form.version ? ` ${form.version}` : ''} directly (${form.script})`
99
+ : `the Node or the copy of pendingyou they run is gone (${form.node}, ${form.script}); run npx pendingyou@latest init`
100
+ : form.form === 'npx'
101
+ ? 'they run through npx, which is slower; run npx pendingyou@latest init to run them directly'
102
+ : 'they run pendingyou from PATH or a file of your own';
77
103
  lines.push(` ${mark(signIn.state === 'ok')} Sign-in: ${signIn.state === 'ok'
78
104
  ? signIn.for
79
105
  ? `hears for ${signIn.for.name} only (for pendingyou watch)`
@@ -82,8 +108,16 @@ async function status(io, origin) {
82
108
  ? 'ended; run npx pendingyou login'
83
109
  : signIn.state === 'none'
84
110
  ? 'not signed in; run npx pendingyou login'
85
- : 'Pending You couldn’t be reached'}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(claude.mcp)} MCP server: ${claude.mcp ? 'pendingyou' : 'not added; run npx pendingyou init'}`, ` ${mark(claude.skill)} Skill: ${claude.skill ? 'saved' : 'not saved; run npx pendingyou init'}`, ` ${mark(claude.hooks)} Hooks: ${claude.hooks ? 'session-start pickup and next-message hand-off' : 'not installed; run npx pendingyou init'}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : 'not installed; run npx pendingyou init'}`);
86
- const ready = signIn.state === 'ok' && claude.hooks;
111
+ : 'Pending You couldn’t be reached'}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(mcpOk)} MCP server: ${mcpText}`, ` ${mark(claude.skill)} Skill: ${claude.skill ? 'saved' : 'not saved; run npx pendingyou init'}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : 'not installed; run npx pendingyou init'}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : 'not installed; run npx pendingyou init'}`);
112
+ // A folder's own server (local or project scope) wins over the user one in Claude Code: say when it's elsewhere. (The
113
+ // plugin's server counts only when no server was added by hand, and is production's.)
114
+ if (here && (here.scope === 'local' || here.scope === 'project') && differs(here, origin))
115
+ lines.push(` ${mark(false)} This folder: its ${here.scope}-scope pendingyou MCP server (${here.url}) is another Pending You than ${origin}; Claude Code uses it here instead`);
116
+ const user = mcp !== false && mcp !== 'unknown' && mcp.url ? { url: mcp.url, scope: 'user' } : null;
117
+ const other = differs(here, origin) ? here : differs(user, origin) ? user : null;
118
+ const ready = signIn.state === 'ok' && claude.hooks && hooksRun && !other;
119
+ if (other)
120
+ lines.push(`Mismatch: Claude Code’s MCP server is ${siteOf(other.url)} but the hooks and sign-in are ${siteOf(origin) ?? origin}. Use the one you look at (npx pendingyou init --origin <it>), then restart your Claude Code sessions.`);
87
121
  lines.push(ready
88
122
  ? 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
89
123
  : 'Not ready: Claude Code hears answers while it’s working (report_setup hears "while-working").');
@@ -92,9 +126,10 @@ async function status(io, origin) {
92
126
  }
93
127
  async function init(io, options) {
94
128
  io.out(`Setting up Pending You for Claude Code (${options.origin}).\n`);
95
- const steps = await installClaude(io, options.origin);
96
- for (const step of steps)
97
- io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`);
129
+ const steps = await installClaude(io, options.origin, {
130
+ yes: options.yes,
131
+ report: (step) => io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`),
132
+ });
98
133
  if (options.login) {
99
134
  io.out('Signing this computer in, so answers can wake Claude Code:\n');
100
135
  await login(io, {
@@ -155,7 +190,8 @@ async function run(io, command, hook = { fallback: '' }) {
155
190
  }
156
191
  /**
157
192
  * A hook's hard deadline, from the moment this process started: Claude Code gives a hook 10 seconds (init's
158
- * `timeout`), npx takes about half a second before this process starts, and a normal hook is done in about one.
193
+ * `timeout`), npx (hooks from before 0.7.0) takes about half a second before this process starts, and a normal hook
194
+ * is done in well under one.
159
195
  * When it's reached, the hook prints its plain fallback (the hand-off's one-line reminder; nothing for pickup and
160
196
  * stopcheck) unless it already printed, and exits 0, whatever it was still waiting on.
161
197
  */
package/dist/pickup.js CHANGED
@@ -9,11 +9,15 @@
9
9
  // one small request per message (the open cards, and answers too while a hold from this computer is outstanding),
10
10
  // time-boxed short; on any error, or from a server too old to list open cards, the line is the plain reminder.
11
11
  //
12
+ // Both also add one line when the session's pendingyou MCP server is another Pending You than the hooks' origin
13
+ // (environment.ts): its cards would go where these hooks, and maybe its person, don't look.
14
+ //
12
15
  // Whatever they print, Claude Code adds to the conversation. A hook must never get in the way: each is time-boxed,
13
16
  // has a hard deadline (main.ts) after which it prints only its fallback, never refreshes the sign-in itself (api.ts),
14
17
  // prints nothing when there's nothing new, never repeats an answer it handed over, and always exits 0.
15
18
  import { getJson, SignInNeeded } from "./api.js";
16
19
  import { readCredential } from "./credentials.js";
20
+ import { environmentWarning } from "./environment.js";
17
21
  import { clip, describe, isClosed } from "./format.js";
18
22
  import { markHanded, momentOf, waitingOn, wasHanded } from "./state.js";
19
23
  /** Each request's own limit, inside the hook's 3-second deadline (main.ts). */
@@ -76,15 +80,20 @@ export async function pickup(io, options, hook = { fallback: '' }) {
76
80
  const { origin, mode } = options;
77
81
  // The hand-off's line, printed at the end unless something else was printed with it.
78
82
  let line = null;
83
+ // Said by either hook, signed in or not, when the session's MCP server is another Pending You (environment.ts).
84
+ let warning = null;
79
85
  try {
80
86
  const cwd = await sessionFolder(io);
87
+ warning = await environmentWarning(io, cwd, origin);
88
+ if (warning)
89
+ hook.fallback = `${warning}\n`;
81
90
  // The hand-off hears answers only while a hold from this computer is outstanding; it always asks for open cards.
82
91
  let answers = true;
83
92
  if (mode === 'handoff') {
84
93
  if ((await readCredential(io, origin)) === null)
85
94
  return 0;
86
95
  line = REMINDER;
87
- hook.fallback = `${REMINDER}\n`;
96
+ hook.fallback = `${[REMINDER, warning].filter(Boolean).join('\n')}\n`;
88
97
  answers = (await waitingOn(io, origin)).length > 0;
89
98
  }
90
99
  const query = new URLSearchParams([
@@ -116,8 +125,10 @@ export async function pickup(io, options, hook = { fallback: '' }) {
116
125
  ...fresh.flatMap((heard) => [...describe(heard, again(heard.requestId)), '']),
117
126
  'Tell them in one line first (“You answered in Pending You: …”), then act.',
118
127
  ...(line ? [line] : []),
128
+ ...(warning ? [warning] : []),
119
129
  ].join('\n')}\n`);
120
130
  line = null;
131
+ warning = null;
121
132
  await markHanded(io, origin, fresh.map((heard) => ({
122
133
  requestId: heard.requestId,
123
134
  moment: momentOf(heard),
@@ -133,7 +144,8 @@ export async function pickup(io, options, hook = { fallback: '' }) {
133
144
  return 0;
134
145
  }
135
146
  finally {
136
- if (line)
137
- io.out(`${line}\n`);
147
+ const last = [line, warning].filter(Boolean);
148
+ if (last.length)
149
+ io.out(`${last.join('\n')}\n`);
138
150
  }
139
151
  }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The command line's version, as published (package.json). A test keeps them the same.
2
- export const VERSION = '0.6.0';
2
+ export const VERSION = '0.8.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pending You for Claude Code: hear your answers from Pending You in the background, and set Claude Code up in one command.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.pendingyou.com",