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/CHANGELOG.md +49 -0
- package/README.md +80 -22
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/credentials.js +39 -1
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -17
- package/dist/index.js +1 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +195 -35
- package/dist/mod.js +87 -0
- package/dist/oauth.js +27 -6
- package/dist/pickup.js +16 -18
- package/dist/qr.js +365 -0
- package/dist/remote.js +306 -0
- package/dist/state.js +5 -32
- package/dist/stopcheck.js +2 -1
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +314 -0
- package/mod/hooks/wake.js +599 -0
- package/mod/tests/wake.test.ts +198 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
84
|
-
const mcpOk =
|
|
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
|
-
?
|
|
89
|
-
:
|
|
90
|
-
? `
|
|
91
|
-
:
|
|
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
|
-
?
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
123
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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
|
|
241
|
-
* the person presses Allow on their phone (or
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
|
10
|
-
//
|
|
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
|
|
22
|
-
import {
|
|
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
|
|
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
|
|
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',
|
|
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}`,
|
|
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 (!
|
|
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
|
|
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) {
|