pendingyou 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -2,13 +2,15 @@
2
2
  // it with their own Io.
3
3
  import { getJson, refreshInBackground, SignInNeeded, Unavailable } from "./api.js";
4
4
  import { commandOf, DEFAULT_ORIGIN, HOOK_COMMANDS, parseArgs, USAGE, UsageError, } from "./args.js";
5
- import { claudeState, installClaude, uninstallClaude } from "./claude.js";
6
- import { readCredential, signedInOrigins, updateCredential } from "./credentials.js";
5
+ import { claudeState, installClaude, mcpFromFile, readManifest, uninstallClaude } from "./claude.js";
6
+ import { connectionClient, connectionMachine, readCredential, saveConnectionClient, signedInOrigins, updateCredential, } from "./credentials.js";
7
7
  import { differs, serverForFolder, siteOf } from "./environment.js";
8
8
  import { PlainError } from "./errors.js";
9
9
  import { hold } from "./hold.js";
10
+ import { MOD_CLAUDE, PLUGIN_DIRS } from "./mod.js";
10
11
  import { OAuthError, revoke, signIn, signInWithDevice } from "./oauth.js";
11
12
  import { pickup } from "./pickup.js";
13
+ import { checkHelper, computerName, foundBy, headlessReason, helperPath, isRemoteSetup, machineOf, mcpHeaders, usesHelper, } from "./remote.js";
12
14
  import { stopcheck } from "./stopcheck.js";
13
15
  import { VERSION } from "./version.js";
14
16
  import { watch } from "./watch.js";
@@ -30,27 +32,89 @@ async function checkSignIn(io, origin) {
30
32
  return { state: 'unreachable' };
31
33
  }
32
34
  }
35
+ /** `--origin <it>` for a command to run next, when it isn't production. */
36
+ const originFlag = (origin) => (origin === DEFAULT_ORIGIN ? '' : ` --origin ${origin}`);
37
+ /** What a computer's Claude Code connection is called: "Claude Code on devbox". */
38
+ const titleOf = (machine) => machine ? `Claude Code on ${machine}` : 'Claude Code on this computer';
39
+ /** This computer's connection's title: by the name it signed in with, else its hostname. */
40
+ const connectionTitle = async (io, origin) => titleOf((await connectionMachine(io, origin)) ?? machineOf(io.host));
41
+ /** A connection's title as Pending You has it: "Claude Code on devbox". */
42
+ const serverTitle = (heard) => heard.machine ? `${heard.name} on ${heard.machine}` : heard.name;
43
+ /** How to give this computer's connection another name: sign in again under it, which renames the connection. */
44
+ const renameAdvice = (origin) => `To call it something friendlier, run: npx pendingyou login --name <name>${originFlag(origin)}\n`;
45
+ /**
46
+ * Whether Claude Code here signs in through this computer's own sign-in for `origin` (remote.ts): its pendingyou MCP
47
+ * server runs our headersHelper, or init recorded that it set it up so. Then a sign-in must be that connection.
48
+ */
49
+ async function signsInThroughHelper(io, origin) {
50
+ if (io.platform === 'win32')
51
+ return false;
52
+ const server = await mcpFromFile(io);
53
+ if (server !== 'unknown')
54
+ return usesHelper(server, origin);
55
+ const manifest = await readManifest(io);
56
+ return Boolean(manifest?.helper && manifest.origin === origin);
57
+ }
33
58
  async function login(io, options) {
34
- if (!options.force && (await readCredential(io, options.origin))) {
59
+ const connection = options.connection ?? (await signsInThroughHelper(io, options.origin));
60
+ if (connection && options.device === false)
61
+ throw new PlainError(`Claude Code on this computer signs in through this computer’s own sign-in, which you approve with a code on your phone. Run npx pendingyou login${originFlag(options.origin)} without --browser.`);
62
+ const current = await readCredential(io, options.origin);
63
+ if (!options.force && current && (!connection || current.kind === 'connection')) {
35
64
  const check = await checkSignIn(io, options.origin);
36
- if (check.state === 'ok') {
65
+ // Another name for this computer's connection signs in again (one approval), which renames it on Pending You.
66
+ const known = check.state === 'ok' ? check.for?.machine : undefined;
67
+ const renaming = connection &&
68
+ options.machine !== undefined &&
69
+ options.machine !== (known ?? (await connectionMachine(io, options.origin)));
70
+ if (check.state === 'ok' && !renaming) {
37
71
  io.out(`Already signed in to Pending You at ${options.origin}.\n`);
38
72
  return 0;
39
73
  }
40
74
  }
41
75
  const previous = await readCredential(io, options.origin);
42
- const { clientId, tokens } = options.device
43
- ? await signInWithDevice(io, { origin: options.origin })
44
- : await signIn(io, { origin: options.origin, browser: options.browser });
76
+ // A code on the phone: for a connection, when asked, or with no browser here (an SSH session).
77
+ const device = connection || (options.device ?? headlessReason(io) !== null);
78
+ // What the phone and the Assistants page call this computer: --name, Tailscale's name, or the hostname.
79
+ const named = device ? await computerName(io, options.machine) : null;
80
+ const machine = named?.name ?? '';
81
+ const { clientId, tokens } = connection
82
+ ? await signInWithDevice(io, {
83
+ origin: options.origin,
84
+ machine,
85
+ connection: true,
86
+ clientId: await connectionClient(io, options.origin),
87
+ })
88
+ : device
89
+ ? await signInWithDevice(io, { origin: options.origin, machine })
90
+ : await signIn(io, { origin: options.origin, browser: options.browser });
45
91
  await updateCredential(io, options.origin, () => ({
46
92
  clientId,
47
93
  ...tokens,
48
94
  signedInAt: new Date(io.now()).toISOString(),
95
+ ...(connection ? { kind: 'connection' } : {}),
49
96
  }));
97
+ if (connection)
98
+ await saveConnectionClient(io, options.origin, clientId, machine);
50
99
  // The sign-in this one replaces is revoked, so only one is live per computer.
51
100
  if (previous)
52
101
  await revoke(io, options.origin, previous.clientId, previous.refreshToken);
53
- io.out('Signed in. Claude Code on this computer can now hear your answers from Pending You, even while it’s idle.\n');
102
+ if (connection) {
103
+ // A connection's sign-in hears for that connection, by name. A Pending You from before connections by code
104
+ // answers with the command line's own sign-in instead, whose token /mcp refuses: say so, rather than hand it to
105
+ // Claude Code.
106
+ const check = await checkSignIn(io, options.origin);
107
+ if (check.state === 'ok' && !check.for) {
108
+ await updateCredential(io, options.origin, (current) => current ? { ...current, kind: undefined } : null);
109
+ throw new PlainError(`Pending You at ${options.origin} doesn’t connect Claude Code on a computer with no browser yet, so this computer is signed in only to hear answers. Try again once it’s updated.`);
110
+ }
111
+ }
112
+ io.out(connection
113
+ ? `Signed in: ${titleOf(machine)} can ask you questions and hear your answers, with no browser here.\n`
114
+ : 'Signed in. Claude Code on this computer can now hear your answers from Pending You, even while it’s idle.\n');
115
+ // A cloud's own name ("ip-10-42-1-252"): kept, since there's nothing better, with how to change it.
116
+ if (connection && named?.unfriendly)
117
+ io.out(`It’s named after this computer’s hostname. ${renameAdvice(options.origin)}`);
54
118
  return 0;
55
119
  }
56
120
  async function logout(io, options) {
@@ -72,64 +136,150 @@ async function logout(io, options) {
72
136
  return 0;
73
137
  }
74
138
  async function status(io, origin) {
75
- const [signIn, claude, here] = await Promise.all([
139
+ const [signIn, claude, here, credential, manifest] = await Promise.all([
76
140
  checkSignIn(io, origin),
77
141
  claudeState(io),
78
142
  serverForFolder(io, io.cwd),
143
+ readCredential(io, origin),
144
+ readManifest(io),
79
145
  ]);
80
- const lines = [`Pending You for Claude Code · ${origin}`];
146
+ const flag = originFlag(origin);
147
+ const mcp = claude.mcp;
148
+ // A computer with no browser (remote.ts): Claude Code's server signs in through the helper, and the sign-in is its
149
+ // connection. What's set up and what's missing is said for that, with the command that fixes it.
150
+ const helped = usesHelper(mcp, origin);
151
+ const remote = isRemoteSetup(io, null, helped || Boolean(manifest?.helper && manifest.origin === origin));
152
+ const why = remote ? headlessReason(io) : null;
153
+ const lines = [
154
+ `Pending You for Claude Code · ${origin}${remote ? (why ? ` · no browser here (${why})` : ' · signs in without a browser') : ''}`,
155
+ ];
81
156
  const mark = (ok) => (ok ? 'ok ' : 'missing');
82
157
  const url = `${origin}/mcp`;
83
- const mcp = claude.mcp;
84
- const mcpOk = mcp !== false && mcp !== 'unknown' && (!mcp.url || mcp.url.replace(/\/+$/, '') === url);
158
+ const sameServer = mcp !== false && mcp !== 'unknown' && (!mcp.url || mcp.url.replace(/\/+$/, '') === url);
159
+ const mcpOk = remote ? sameServer && helped : sameServer;
85
160
  const mcpText = mcp === 'unknown'
86
161
  ? 'Claude Code didn’t say in time; check with claude mcp get pendingyou'
87
162
  : 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`;
163
+ ? `not added; run npx pendingyou init${flag}`
164
+ : !sameServer
165
+ ? `points at ${mcp.url}, not ${url}, so sessions post cards where these hooks don’t hear them; run npx pendingyou init${flag} to replace it`
166
+ : !remote
167
+ ? `pendingyou${mcp.url ? ` (${mcp.url})` : ''}`
168
+ : helped
169
+ ? `pendingyou (${mcp.url}), signed in through ${helperPath(io)}`
170
+ : mcp.helper
171
+ ? `pendingyou (${mcp.url}) signs in through a headersHelper of its own (${mcp.helper}); run npx pendingyou init${flag} to use this computer’s sign-in`
172
+ : `pendingyou (${mcp.url}) signs in through a browser, which can’t finish on this computer; run npx pendingyou init${flag}`;
92
173
  const form = claude.form;
93
174
  const hooksRun = form?.runs ?? true;
175
+ // The wake mod (0.10.0): Claude Code 2.1.287 or later loads it from the plugin or from CLAUDE_CODE_PLUGIN_DIRS.
176
+ const mod = claude.mod;
177
+ const modOk = mod.loads && (mod.plugin || mod.present);
178
+ const modText = !claude.claude
179
+ ? `needs Claude Code ${MOD_CLAUDE} or later`
180
+ : !mod.loads
181
+ ? `needs Claude Code ${MOD_CLAUDE} or later (this is ${claude.claude}); until then, a background hold wakes it`
182
+ : mod.present
183
+ ? `in ${PLUGIN_DIRS} (${mod.dirs.join(', ')}); a session is woken when you answer, with no hold running`
184
+ : mod.plugin
185
+ ? 'in the Pending You plugin; a session is woken when you answer, with no hold running'
186
+ : mod.dirs.length
187
+ ? `${mod.dirs.join(', ')} is gone; run npx pendingyou@latest init${flag}`
188
+ : `not installed; run npx pendingyou@latest init${flag}`;
94
189
  const formText = !form
95
190
  ? ''
96
191
  : form.form === 'direct'
97
192
  ? form.runs
98
193
  ? `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`
194
+ : `the Node or the copy of pendingyou they run is gone (${form.node}, ${form.script}); run npx pendingyou@latest init${flag}`
100
195
  : form.form === 'npx'
101
- ? 'they run through npx, which is slower; run npx pendingyou@latest init to run them directly'
196
+ ? `they run through npx, which is slower; run npx pendingyou@latest init${flag} to run them directly`
102
197
  : 'they run pendingyou from PATH or a file of your own';
103
- lines.push(` ${mark(signIn.state === 'ok')} Sign-in: ${signIn.state === 'ok'
104
- ? signIn.for
105
- ? `hears for ${signIn.for.name} only (for pendingyou watch)`
106
- : `hears for ${signIn.connections} Claude Code connection${signIn.connections === 1 ? '' : 's'}`
107
- : signIn.state === 'ended'
108
- ? 'ended; run npx pendingyou login'
109
- : signIn.state === 'none'
110
- ? 'not signed in; run npx pendingyou login'
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'}`);
198
+ // Remote: login makes a connection only once Claude Code's server signs in through the helper; init does both.
199
+ const fix = `npx pendingyou ${helped ? 'login' : 'init'}${flag}`;
200
+ // The connection's name as Pending You has it, so status never drifts from it; else this computer's own record.
201
+ const title = signIn.state === 'ok' && signIn.for
202
+ ? serverTitle(signIn.for)
203
+ : await connectionTitle(io, origin);
204
+ const connected = credential?.kind === 'connection';
205
+ const signOk = signIn.state === 'ok' && (!remote || connected);
206
+ const signText = remote
207
+ ? signIn.state === 'ok'
208
+ ? connected
209
+ ? `this computer’s own connection, ${title}; Claude Code here asks and hears through it`
210
+ : `this computer’s sign-in only hears answers, so Claude Code here can’t use it; run ${fix}`
211
+ : signIn.state === 'ended'
212
+ ? `ended; run ${fix}`
213
+ : signIn.state === 'none'
214
+ ? `not signed in; run ${fix} (you approve a code on your phone)`
215
+ : 'Pending You couldn’t be reached'
216
+ : signIn.state === 'ok'
217
+ ? signIn.for
218
+ ? `hears for ${signIn.for.name} only (for pendingyou watch)`
219
+ : `hears for ${signIn.connections} Claude Code connection${signIn.connections === 1 ? '' : 's'}`
220
+ : signIn.state === 'ended'
221
+ ? `ended; run npx pendingyou login${flag}`
222
+ : signIn.state === 'none'
223
+ ? `not signed in; run npx pendingyou login${flag}`
224
+ : 'Pending You couldn’t be reached';
225
+ // The helper as Claude Code runs it at its barest: found Node, and runs pendingyou's copy.
226
+ const helper = remote ? await checkHelper(io, origin) : null;
227
+ lines.push(` ${mark(signOk)} Sign-in: ${signText}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(mcpOk)} MCP server: ${mcpText}`, ...(helper
228
+ ? [
229
+ ` ${mark(helper.ok)} Sign-in helper: ${helper.ok
230
+ ? `${helper.node} (${foundBy(helper.from)}) runs pendingyou ${helper.version}, even for a Claude Code started without your shell’s PATH`
231
+ : `${helper.why}; run npx pendingyou init${flag}`}`,
232
+ ]
233
+ : []), ` ${mark(claude.skill)} Skill: ${claude.skill ? 'saved' : `not saved; run npx pendingyou init${flag}`}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(modOk)} Wake mod: ${modText}`);
112
234
  // A folder's own server (local or project scope) wins over the user one in Claude Code: say when it's elsewhere. (The
113
235
  // plugin's server counts only when no server was added by hand, and is production's.)
114
236
  if (here && (here.scope === 'local' || here.scope === 'project') && differs(here, origin))
115
237
  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
238
  const user = mcp !== false && mcp !== 'unknown' && mcp.url ? { url: mcp.url, scope: 'user' } : null;
117
239
  const other = differs(here, origin) ? here : differs(user, origin) ? user : null;
118
- const ready = signIn.state === 'ok' && claude.hooks && hooksRun && !other;
240
+ // Remote, Claude Code reaches Pending You only through the helper, the connection and the server together.
241
+ const reaches = !remote || (signOk && mcpOk && helper?.ok === true);
242
+ const ready = reaches && signIn.state === 'ok' && claude.hooks && hooksRun && !other;
119
243
  if (other)
120
244
  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.`);
121
245
  lines.push(ready
122
- ? 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
123
- : 'Not ready: Claude Code hears answers while it’s working (report_setup hears "while-working").');
246
+ ? remote
247
+ ? 'Ready: Claude Code here signs in with no browser, and hears answers right away (report_setup hears "instant").'
248
+ : 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
249
+ : reaches
250
+ ? 'Not ready: Claude Code hears answers while it’s working (report_setup hears "while-working").'
251
+ : 'Not ready: Claude Code here can’t reach Pending You until the lines marked missing are fixed.');
124
252
  io.out(`${lines.join('\n')}\n`);
125
253
  return ready ? 0 : 1;
126
254
  }
127
255
  async function init(io, options) {
128
256
  io.out(`Setting up Pending You for Claude Code (${options.origin}).\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
- });
257
+ const name = options.machine ? { machine: options.machine } : {};
258
+ const report = (step) => io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`);
259
+ // A computer with no browser (remote.ts): one code approved on the phone signs Claude Code in, then the rest.
260
+ if (isRemoteSetup(io, options.device, await signsInThroughHelper(io, options.origin))) {
261
+ const why = options.device ? null : headlessReason(io);
262
+ io.out(`${why ? `No browser here (${why}), so ` : ''}Claude Code here signs in through this computer’s own sign-in, which you approve once with a code on your phone.\n`);
263
+ if (options.login)
264
+ await login(io, {
265
+ origin: options.origin,
266
+ browser: false,
267
+ force: false,
268
+ device: true,
269
+ connection: true,
270
+ ...name,
271
+ });
272
+ else
273
+ io.out(`Skipped signing in. Run npx pendingyou login${originFlag(options.origin)} when you’re ready: you approve a code on your phone.\n`);
274
+ const steps = await installClaude(io, options.origin, {
275
+ yes: options.yes,
276
+ report,
277
+ remote: true,
278
+ });
279
+ io.out('Next: start Claude Code here (claude, in the folder you work in) and ask it to finish setting up Pending You. It’s signed in already, so there’s nothing to Authenticate in /mcp. If it’s running, restart it first (/exit, then claude --continue).\n');
280
+ return steps.every((step) => step.ok) ? 0 : 1;
281
+ }
282
+ const steps = await installClaude(io, options.origin, { yes: options.yes, report });
133
283
  if (options.login) {
134
284
  io.out('Signing this computer in, so answers can wake Claude Code:\n');
135
285
  await login(io, {
@@ -137,18 +287,25 @@ async function init(io, options) {
137
287
  browser: options.browser,
138
288
  device: options.device,
139
289
  force: false,
290
+ ...name,
140
291
  });
141
292
  }
142
293
  else
143
- io.out('Skipped signing in. Run npx pendingyou login when you’re ready.\n');
294
+ io.out(`Skipped signing in. Run npx pendingyou login${originFlag(options.origin)} when you’re ready.\n`);
144
295
  io.out('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.\n');
145
296
  return steps.every((step) => step.ok) ? 0 : 1;
146
297
  }
147
298
  async function uninstall(io, origin) {
299
+ const credential = await readCredential(io, origin);
300
+ const title = await connectionTitle(io, origin);
148
301
  const steps = await uninstallClaude(io);
149
302
  for (const step of steps)
150
303
  io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`);
151
304
  await logout(io, { origin, all: false });
305
+ await saveConnectionClient(io, origin, null);
306
+ // Its connection outlives the sign-in on Pending You's side: only the person removes it.
307
+ if (credential?.kind === 'connection')
308
+ io.out(`${title} is still on your Assistants page (${origin}/app/assistants): remove it there if you won’t use it again.\n`);
152
309
  return steps.every((step) => step.ok) ? 0 : 1;
153
310
  }
154
311
  async function run(io, command, hook = { fallback: '' }) {
@@ -186,6 +343,9 @@ async function run(io, command, hook = { fallback: '' }) {
186
343
  }
187
344
  catch { }
188
345
  return 0;
346
+ case 'mcp-headers':
347
+ // Claude Code's headersHelper on a computer with no browser: stdout is the headers and nothing else.
348
+ return mcpHeaders(io, command);
189
349
  }
190
350
  }
191
351
  /**
package/dist/mod.js ADDED
@@ -0,0 +1,87 @@
1
+ // The wake mod (0.10.0, guide 2.24): the Pending You plugin's mod, which this package carries in mod/ for Claude Code
2
+ // set up with `npx pendingyou init` and no plugin. Claude Code 2.1.287 and later load it from CLAUDE_CODE_PLUGIN_DIRS
3
+ // (`env` in ~/.claude/settings.json). Every session then learns the cards it posts, checks them through its own
4
+ // Pending You connection, and is woken when the person answers, with no hold running; a hold an agent still starts is
5
+ // answered by the mod. `init` adds the copy inside the hooks' private copy (install.ts), keeping every other entry and
6
+ // replacing an older version's; `uninstall` takes it out; `status` says whether it's there. With the Pending You plugin
7
+ // installed too, the two copies agree that one acts.
8
+ import { join } from 'node:path';
9
+ /** The first Claude Code that loads mods. */
10
+ export const MOD_CLAUDE = '2.1.287';
11
+ /** Where Claude Code finds plugin folders to load as --plugin-dir does. */
12
+ export const PLUGIN_DIRS = 'CLAUDE_CODE_PLUGIN_DIRS';
13
+ /** The Pending You plugins, which carry the same mod: production's, and staging's for our own testing. */
14
+ export const PLUGINS_WITH_MOD = ['pending-you@pendingyou', 'pending-you-staging@pendingyou-staging'];
15
+ /** The mod in a copy of this package (its folder in node_modules). */
16
+ export const modDir = (packageRoot) => join(packageRoot, 'mod');
17
+ /** Claude Code's version from `claude --version` ("2.1.289 (Claude Code)"); null when it can't be read. */
18
+ export function claudeVersion(output) {
19
+ return /^\s*(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
20
+ }
21
+ /** Whether that Claude Code loads mods (2.1.287 or later). */
22
+ export function loadsMods(version) {
23
+ const have = version.split('.').map(Number);
24
+ const want = MOD_CLAUDE.split('.').map(Number);
25
+ for (let index = 0; index < want.length; index++) {
26
+ const a = have[index] ?? 0;
27
+ const b = want[index] ?? 0;
28
+ if (a !== b)
29
+ return a > b;
30
+ }
31
+ return true;
32
+ }
33
+ /** Whether a CLAUDE_CODE_PLUGIN_DIRS entry is a copy of this mod, from any version of this package. */
34
+ export const isOurModDir = (entry) => /(^|[\\/])node_modules[\\/]pendingyou[\\/]mod[\\/]?$/.test(entry.trim());
35
+ /** What Claude Code splits CLAUDE_CODE_PLUGIN_DIRS on. */
36
+ export const separatorFor = (platform) => (platform === 'win32' ? ';' : ':');
37
+ const objectAt = (parent, key) => {
38
+ const value = parent[key];
39
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
40
+ ? value
41
+ : null;
42
+ };
43
+ /** The entries of CLAUDE_CODE_PLUGIN_DIRS in settings' `env`; null when it's there but isn't text. */
44
+ export function pluginDirs(settings, separator) {
45
+ const env = objectAt(settings, 'env');
46
+ if (settings.env !== undefined && env === null)
47
+ return null;
48
+ const value = env?.[PLUGIN_DIRS];
49
+ if (value === undefined)
50
+ return [];
51
+ if (typeof value !== 'string')
52
+ return null;
53
+ return value.split(separator).filter((entry) => entry.trim() !== '');
54
+ }
55
+ /**
56
+ * Settings with the mod at `dir` in CLAUDE_CODE_PLUGIN_DIRS, or with none of ours when `dir` is null: every other entry
57
+ * kept as it was, in its place, and any copy of ours (an older version's) replaced. `changed` is false when there was
58
+ * nothing to do; `invalid` says why the settings were left alone.
59
+ */
60
+ export function withModDir(settings, dir, separator) {
61
+ const entries = pluginDirs(settings, separator);
62
+ if (entries === null)
63
+ return {
64
+ invalid: `Claude Code’s settings have an “env” or ${PLUGIN_DIRS} that I can’t read, so I left them alone.`,
65
+ };
66
+ if (dir?.includes(separator))
67
+ return { invalid: `The wake mod’s folder has a “${separator}” in its path: ${dir}` };
68
+ const wanted = [...entries.filter((entry) => !isOurModDir(entry)), ...(dir ? [dir] : [])];
69
+ if (wanted.join(separator) === entries.join(separator))
70
+ return { settings, changed: false };
71
+ const next = structuredClone(settings);
72
+ const env = objectAt(next, 'env') ?? {};
73
+ if (wanted.length)
74
+ env[PLUGIN_DIRS] = wanted.join(separator);
75
+ else
76
+ delete env[PLUGIN_DIRS];
77
+ if (Object.keys(env).length)
78
+ next.env = env;
79
+ else
80
+ delete next.env;
81
+ return { settings: next, changed: true };
82
+ }
83
+ /** Whether settings enable a Pending You plugin, which carries the mod itself. */
84
+ export function pluginEnabled(settings) {
85
+ const enabled = objectAt(settings, 'enabledPlugins');
86
+ return PLUGINS_WITH_MOD.some((id) => enabled?.[id] === true);
87
+ }
package/dist/oauth.js CHANGED
@@ -3,11 +3,14 @@
3
3
  // consent page with PKCE (S256), and hears the code back on a one-time loopback address (127.0.0.1, any free port).
4
4
  // The consent page recognises the command line by that name and the loopback address, and gives it a grant that only
5
5
  // hears answers to your Claude Code assistants' questions: it isn't an assistant, and can't ask or answer anything.
6
+ // On a computer with no browser it signs in with a code instead (RFC 8628, signInWithDevice), and there `init` asks
7
+ // for more: a Claude Code connection of this computer's own, whose token Claude Code's MCP server uses too (remote.ts).
6
8
  //
7
9
  // Access tokens last 15 minutes and refresh on their own; refresh tokens rotate on every use. Nothing here prints or
8
10
  // logs a token, a code or a verifier.
9
11
  import { createHash, randomBytes } from 'node:crypto';
10
12
  import { createServer } from 'node:http';
13
+ import { qrLines } from "./qr.js";
11
14
  import { VERSION } from "./version.js";
12
15
  /** The name the consent page knows the command line by (apps/pendingyou/worker/oauth/authorize.ts). */
13
16
  export const CLIENT_NAME = 'Pending You CLI';
@@ -237,18 +240,31 @@ export async function revoke(io, origin, clientId, refreshToken) {
237
240
  }
238
241
  }
239
242
  /**
240
- * Device sign-in (RFC 8628) for a computer with no browser: shows a short code and where to enter it, then polls until
241
- * the person presses Allow on their phone (or Cancel, or the code runs out).
243
+ * Device sign-in (RFC 8628) for a computer with no browser: shows a short code, where to enter it and, at a terminal, a
244
+ * QR code that opens that page with the code filled in; then polls until the person presses Allow on their phone (or
245
+ * Cancel, or the code runs out).
242
246
  */
243
247
  export async function signInWithDevice(io, options) {
244
248
  const metadata = await discover(io, options.origin);
245
- const clientId = await register(io, metadata);
246
- const started = await io.fetch(`${options.origin}/oauth/device`, {
249
+ const begin = (client) => io.fetch(`${options.origin}/oauth/device`, {
247
250
  method: 'POST',
248
251
  headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
249
- body: new URLSearchParams({ client_id: clientId, scope: SCOPE }).toString(),
252
+ body: new URLSearchParams({
253
+ client_id: client,
254
+ scope: SCOPE,
255
+ ...(options.machine ? { machine: options.machine } : {}),
256
+ ...(options.connection ? { assistant: 'claude-code' } : {}),
257
+ }).toString(),
250
258
  signal: AbortSignal.timeout(15_000),
251
259
  });
260
+ let clientId = options.clientId || (await register(io, metadata));
261
+ let started = await begin(clientId);
262
+ if (started.status === 401 && options.clientId) {
263
+ // Pending You no longer knows that client (a registration lapses after 90 days unused): register again.
264
+ await started.body?.cancel();
265
+ clientId = await register(io, metadata);
266
+ started = await begin(clientId);
267
+ }
252
268
  const start = (await started.json().catch(() => ({})));
253
269
  if (!started.ok || typeof start.device_code !== 'string' || typeof start.user_code !== 'string')
254
270
  throw new OAuthError(start.error ?? `http_${started.status}`, start.error === 'slow_down'
@@ -257,8 +273,13 @@ export async function signInWithDevice(io, options) {
257
273
  const page = start.verification_uri ?? `${options.origin}/device`;
258
274
  if (new URL(page).origin !== options.origin)
259
275
  throw new OAuthError('discovery', 'Pending You sent the sign-in page somewhere else.');
276
+ const direct = start.verification_uri_complete ?? `${page}?code=${start.user_code}`;
277
+ const qr = io.interactive && new URL(direct).origin === options.origin ? qrLines(direct, io.env) : null;
260
278
  io.err(`On your phone or any browser, open ${page} and enter this code:\n\n ${start.user_code}\n\n` +
261
- `Or go straight to ${start.verification_uri_complete ?? `${page}?code=${start.user_code}`}\nWaiting for you to press Allow…\n`);
279
+ (qr
280
+ ? `Or scan this with your phone’s camera, which opens it with the code filled in:\n\n${qr.join('\n')}\n\nOr go straight to ${direct}\n`
281
+ : `Or go straight to ${direct}\n`) +
282
+ 'Waiting for you to press Allow…\n');
262
283
  let interval = Math.max(1, start.interval ?? 5) * 1000;
263
284
  const deadline = io.now() + Math.max(60, start.expires_in ?? 600) * 1000;
264
285
  for (;;) {
package/dist/pickup.js CHANGED
@@ -6,8 +6,10 @@
6
6
  // Pending You: …"). While signed in, it also adds one short line (REMINDER) to every message, so an assistant
7
7
  // deep in a long conversation still puts what it needs from the person on a card, and that line names this folder's
8
8
  // cards still waiting on the person (openLine), so one they answer in chat gets cancelled with answeredHere. That's
9
- // one small request per message (the open cards, and answers too while a hold from this computer is outstanding),
10
- // time-boxed short; on any error, or from a server too old to list open cards, the line is the plain reminder.
9
+ // one small request per message (the open cards and this folder's answers together), time-boxed short; on any
10
+ // error, or from a server too old to list open cards, the line is the plain reminder. Until 0.10.0 it looked for
11
+ // answers only while a hold had left an entry behind, so an answer to a card no hold ever waited on stayed unheard
12
+ // until the next session (2026-10-04).
11
13
  //
12
14
  // Both also add one line when the session's pendingyou MCP server is another Pending You than the hooks' origin
13
15
  // (environment.ts): its cards would go where these hooks, and maybe its person, don't look.
@@ -15,13 +17,14 @@
15
17
  // Whatever they print, Claude Code adds to the conversation. A hook must never get in the way: each is time-boxed,
16
18
  // has a hard deadline (main.ts) after which it prints only its fallback, never refreshes the sign-in itself (api.ts),
17
19
  // prints nothing when there's nothing new, never repeats an answer it handed over, and always exits 0.
18
- import { getJson, SignInNeeded } from "./api.js";
20
+ import { getJson, SESSION_APP, SignInNeeded } from "./api.js";
19
21
  import { readCredential } from "./credentials.js";
20
22
  import { environmentWarning } from "./environment.js";
21
- import { clip, describe, isClosed } from "./format.js";
22
- import { markHanded, momentOf, waitingOn, wasHanded } from "./state.js";
23
+ import { clip, describe } from "./format.js";
24
+ import { holdCommand } from "./hold.js";
25
+ import { markHanded, momentOf, wasHanded } from "./state.js";
23
26
  /** Each request's own limit, inside the hook's 3-second deadline (main.ts). */
24
- const TIME_BOX_MS = { pickup: 2500, handoff: 2000, open: 1500 };
27
+ const TIME_BOX_MS = { pickup: 2500, handoff: 2000 };
25
28
  /** What every message carries while signed in (about 18 tokens). */
26
29
  export const REMINDER = 'Pending You: anything you need from your person also goes on a card.';
27
30
  /** The most open cards the line names, and how much of each title. */
@@ -87,26 +90,25 @@ export async function pickup(io, options, hook = { fallback: '' }) {
87
90
  warning = await environmentWarning(io, cwd, origin);
88
91
  if (warning)
89
92
  hook.fallback = `${warning}\n`;
90
- // The hand-off hears answers only while a hold from this computer is outstanding; it always asks for open cards.
91
- let answers = true;
93
+ // The hand-off asks for this folder's answers and its open cards in one request, whether or not a hold ran.
92
94
  if (mode === 'handoff') {
93
95
  if ((await readCredential(io, origin)) === null)
94
96
  return 0;
95
97
  line = REMINDER;
96
98
  hook.fallback = `${[REMINDER, warning].filter(Boolean).join('\n')}\n`;
97
- answers = (await waitingOn(io, origin)).length > 0;
98
99
  }
99
100
  const query = new URLSearchParams([
100
101
  ...folderForms(cwd, io.home).map((form) => ['cwd', form]),
101
- ['limit', answers ? '5' : '0'],
102
+ ['limit', '5'],
102
103
  ...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
104
+ ['source', SESSION_APP],
103
105
  ]);
104
- const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, answers ? TIME_BOX_MS[mode] : TIME_BOX_MS.open, io.signal, { hook: true });
106
+ const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, TIME_BOX_MS[mode], io.signal, { hook: true });
105
107
  if (status !== 200)
106
108
  return 0;
107
109
  if (mode === 'handoff')
108
110
  line = openLine(body.open);
109
- if (!answers || !Array.isArray(body.requests))
111
+ if (!Array.isArray(body.requests))
110
112
  return 0;
111
113
  const fresh = [];
112
114
  for (const heard of body.requests)
@@ -114,7 +116,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
114
116
  fresh.push(heard);
115
117
  if (fresh.length === 0)
116
118
  return 0;
117
- const again = (id) => `run \`npx pendingyou hold ${id}\` in the background`;
119
+ const again = (id) => `run \`${holdCommand(id, origin)}\` in the background`;
118
120
  // One write, the line included: at the deadline a hook has printed all of this or none of it. Printed before it's
119
121
  // marked as handed over, so a hook cut off in between repeats an answer rather than losing it.
120
122
  io.out(`${[
@@ -129,11 +131,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
129
131
  ].join('\n')}\n`);
130
132
  line = null;
131
133
  warning = null;
132
- await markHanded(io, origin, fresh.map((heard) => ({
133
- requestId: heard.requestId,
134
- moment: momentOf(heard),
135
- closed: isClosed(heard),
136
- })));
134
+ await markHanded(io, origin, fresh.map((heard) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
137
135
  return 0;
138
136
  }
139
137
  catch (error) {