pendingyou 0.7.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,22 @@
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
+
3
20
  ## 0.7.0
4
21
 
5
22
  - Faster hooks. They ran `npx -y --prefer-offline pendingyou@<version> …`, and npx alone took about half a second
package/README.md CHANGED
@@ -26,13 +26,18 @@ that. If Claude Code already has a `pendingyou` server pointing somewhere else (
26
26
  asks before replacing it; `--yes` replaces it without asking, and with nobody to ask it prints the two commands to
27
27
  run. Afterwards restart Claude Code, run `/mcp`, choose pendingyou and Authenticate.
28
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
+
29
34
  The hooks never get in your way: if anything goes wrong (no network, not signed in, a bad option), they print nothing
30
35
  and let your message through. Each one stops within 3 seconds whatever happens, with at most its one-line reminder, and
31
36
  never refreshes the sign-in itself: when that's due, it starts the refresh in the background, which finishes on its
32
37
  own. Running `init` again updates them in place, and adds any a newer version brings.
33
38
 
34
39
  The hooks run that copy with Node directly, like
35
- `"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.7.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
40
+ `"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.8.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
36
41
  paths in full). If the copy can't be made (no npm, no network), they run through `npx -y --prefer-offline
37
42
  pendingyou@<version>` instead. `npx pendingyou status` says which. If you remove the Node they run with, run `init`
38
43
  again.
@@ -125,8 +130,9 @@ The sign-in only hears answers to your own Claude Code assistants' questions, or
125
130
  answer or change anything, and it doesn't show up as one. It's stored in `~/.config/pendingyou/credentials.json`
126
131
  (readable only by you), refreshes itself, and ends after 30 days or on `logout`. `state.json` beside it remembers
127
132
  which answers were already handed over (ids only), `claude-code.json` what `init` added, and `cli/` the hooks' copy
128
- of the command line. `init` reads `~/.claude.json` only to see whether Claude Code has a `pendingyou` MCP server and
129
- where it points. Nothing is sent anywhere but Pending You.
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.
130
136
 
131
137
  ## Development
132
138
 
@@ -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
+ }
package/dist/main.js CHANGED
@@ -4,6 +4,7 @@ import { getJson, refreshInBackground, SignInNeeded, Unavailable } from "./api.j
4
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,7 +72,11 @@ 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');
77
82
  const url = `${origin}/mcp`;
@@ -83,7 +88,7 @@ async function status(io, origin) {
83
88
  ? 'not added; run npx pendingyou init'
84
89
  : mcpOk
85
90
  ? `pendingyou${mcp.url ? ` (${mcp.url})` : ''}`
86
- : `points at ${mcp.url}, not ${url}; run npx pendingyou init${origin === DEFAULT_ORIGIN ? '' : ` --origin ${origin}`} to replace it`;
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`;
87
92
  const form = claude.form;
88
93
  const hooksRun = form?.runs ?? true;
89
94
  const formText = !form
@@ -104,7 +109,15 @@ async function status(io, origin) {
104
109
  : signIn.state === 'none'
105
110
  ? 'not signed in; run npx pendingyou login'
106
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'}`);
107
- const ready = signIn.state === 'ok' && claude.hooks && hooksRun;
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.`);
108
121
  lines.push(ready
109
122
  ? 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
110
123
  : 'Not ready: Claude Code hears answers while it’s working (report_setup hears "while-working").');
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.7.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.7.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",