pendingyou 0.8.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 +72 -0
- package/README.md +89 -23
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/confirm.js +231 -0
- 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 +88 -8
- 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 +11 -4
package/dist/credentials.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The command line's sign-in, one per Pending You address (production, staging, a local server), in
|
|
2
2
|
// ~/.config/pendingyou/credentials.json: readable only by you (0600, in a 0700 folder), written whole, never printed
|
|
3
3
|
// and never logged. A file someone loosened is tightened again on the next read.
|
|
4
|
-
import { chmod, stat } from 'node:fs/promises';
|
|
4
|
+
import { chmod, rm, stat } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { configDir, readJson, withLock, writeWhole } from "./files.js";
|
|
7
7
|
export const credentialsPath = (io) => join(configDir(io), 'credentials.json');
|
|
@@ -57,3 +57,41 @@ export async function updateCredential(io, origin, change) {
|
|
|
57
57
|
return next;
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
+
export const clientsPath = (io) => join(configDir(io), 'clients.json');
|
|
61
|
+
async function readClients(io) {
|
|
62
|
+
const file = await readJson(clientsPath(io)).catch(() => null);
|
|
63
|
+
return file && typeof file.origins === 'object' && file.origins !== null
|
|
64
|
+
? file
|
|
65
|
+
: { version: 1, origins: {} };
|
|
66
|
+
}
|
|
67
|
+
/** The client this computer's connection for `origin` signed in with last, if any. */
|
|
68
|
+
export async function connectionClient(io, origin) {
|
|
69
|
+
const entry = (await readClients(io)).origins[origin];
|
|
70
|
+
return typeof entry?.clientId === 'string' ? entry.clientId : null;
|
|
71
|
+
}
|
|
72
|
+
/** The name this computer's connection for `origin` signed in with ("devbox"), if it's known. */
|
|
73
|
+
export async function connectionMachine(io, origin) {
|
|
74
|
+
const entry = (await readClients(io)).origins[origin];
|
|
75
|
+
return typeof entry?.machine === 'string' && entry.machine ? entry.machine : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Remembers the client this computer's connection for `origin` signed in with, or with null forgets it (uninstall),
|
|
79
|
+
* removing the file once it holds none.
|
|
80
|
+
*/
|
|
81
|
+
export async function saveConnectionClient(io, origin, clientId, machine) {
|
|
82
|
+
const path = clientsPath(io);
|
|
83
|
+
if (!clientId && !(await connectionClient(io, origin)))
|
|
84
|
+
return;
|
|
85
|
+
await withLock(path, io, async () => {
|
|
86
|
+
const file = await readClients(io);
|
|
87
|
+
if (clientId)
|
|
88
|
+
file.origins[origin] = { clientId, ...(machine ? { machine } : {}) };
|
|
89
|
+
else
|
|
90
|
+
delete file.origins[origin];
|
|
91
|
+
const text = `${JSON.stringify({ version: 1, origins: file.origins }, null, 2)}\n`;
|
|
92
|
+
if (Object.keys(file.origins).length === 0)
|
|
93
|
+
await rm(path, { force: true });
|
|
94
|
+
else
|
|
95
|
+
await writeWhole(path, text, { secret: true });
|
|
96
|
+
});
|
|
97
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -34,15 +34,15 @@ export async function readJson(path) {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* Writes a whole file at once. `secret` files and their folder are private to you (0600 and 0700), however they were
|
|
37
|
-
* before; other files keep the mode they had.
|
|
37
|
+
* before; a `mode` given is the file's (a script's 0755); other files keep the mode they had.
|
|
38
38
|
*/
|
|
39
39
|
export async function writeWhole(path, text, options = {}) {
|
|
40
40
|
const folder = dirname(path);
|
|
41
41
|
await mkdir(folder, { recursive: true, ...(options.secret ? { mode: 0o700 } : {}) });
|
|
42
42
|
if (options.secret)
|
|
43
43
|
await chmod(folder, 0o700);
|
|
44
|
-
let mode = options.secret ? 0o600 : 0o644;
|
|
45
|
-
if (!options.secret) {
|
|
44
|
+
let mode = options.mode ?? (options.secret ? 0o600 : 0o644);
|
|
45
|
+
if (!options.secret && options.mode === undefined) {
|
|
46
46
|
try {
|
|
47
47
|
mode = (await stat(path)).mode & 0o777;
|
|
48
48
|
}
|
package/dist/hold.js
CHANGED
|
@@ -4,20 +4,41 @@
|
|
|
4
4
|
// person; a fallback that ran; or it closed. Then it prints a few lines and exits, and Claude Code, which re-invokes
|
|
5
5
|
// the model when a background command ends, wakes up with the answer, even if it was idle at the prompt.
|
|
6
6
|
//
|
|
7
|
-
// It lets go cleanly after --timeout (4 hours by default), on Ctrl-C or when Claude Code stops it
|
|
8
|
-
//
|
|
9
|
-
// (
|
|
10
|
-
|
|
7
|
+
// It lets go cleanly after --timeout (4 hours by default), on Ctrl-C or when Claude Code stops it (a background
|
|
8
|
+
// command's own time limit can stop it sooner). Since 0.10.0 it then says to start it again in the background, with the
|
|
9
|
+
// exact command: a session without the wake mod (Claude Code before 2.1.287, or no plugin and no init) has nothing
|
|
10
|
+
// else to wake it, and two sessions missed answers overnight when it said not to (2026-10-04). Exit codes: 0 when it
|
|
11
|
+
// heard something or stopped cleanly; 1 when it can't wait at all (not signed in, no such request, bad arguments).
|
|
12
|
+
import { getJson, SESSION_APP, SignInNeeded, Unavailable } from "./api.js";
|
|
13
|
+
import { DEFAULT_ORIGIN, DEFAULT_TIMEOUT_MS } from "./args.js";
|
|
11
14
|
import { readCredential } from "./credentials.js";
|
|
12
|
-
import { describe
|
|
13
|
-
import { markHanded, momentOf
|
|
15
|
+
import { describe } from "./format.js";
|
|
16
|
+
import { markHanded, momentOf } from "./state.js";
|
|
14
17
|
/** The longest one long poll asks the server to wait. */
|
|
15
18
|
export const LONG_POLL_SECONDS = 25;
|
|
16
19
|
/** After this long, a pause between long polls: an answer hours later needn't arrive within seconds. */
|
|
17
20
|
const PATIENT_AFTER_MS = 30 * 60 * 1000;
|
|
18
21
|
const PATIENT_PAUSE_MS = 30_000;
|
|
19
22
|
const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
|
|
20
|
-
|
|
23
|
+
/** A duration as `--timeout` takes it: 4h, 90m or 45s. */
|
|
24
|
+
export function durationText(ms) {
|
|
25
|
+
if (ms % 3_600_000 === 0)
|
|
26
|
+
return `${ms / 3_600_000}h`;
|
|
27
|
+
if (ms % 60_000 === 0)
|
|
28
|
+
return `${ms / 60_000}m`;
|
|
29
|
+
return `${Math.round(ms / 1000)}s`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The command that holds for this request again, as it was started: its origin and timeout when they aren't the
|
|
33
|
+
* defaults (`--origin` rather than PENDINGYOU_ORIGIN=…, so init's `Bash(npx pendingyou hold:*)` still allows it).
|
|
34
|
+
*/
|
|
35
|
+
export function holdCommand(requestId, origin = DEFAULT_ORIGIN, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
36
|
+
return [
|
|
37
|
+
`npx pendingyou hold ${requestId}`,
|
|
38
|
+
...(origin === DEFAULT_ORIGIN ? [] : [`--origin ${origin}`]),
|
|
39
|
+
...(timeoutMs === DEFAULT_TIMEOUT_MS ? [] : [`--timeout ${durationText(timeoutMs)}`]),
|
|
40
|
+
].join(' ');
|
|
41
|
+
}
|
|
21
42
|
const minutes = (ms) => {
|
|
22
43
|
if (ms < 60_000)
|
|
23
44
|
return `${Math.round(ms / 1000)} seconds`;
|
|
@@ -29,6 +50,7 @@ const minutes = (ms) => {
|
|
|
29
50
|
};
|
|
30
51
|
export async function hold(io, options) {
|
|
31
52
|
const { origin, requestId } = options;
|
|
53
|
+
const again = holdCommand(requestId, origin, options.timeoutMs);
|
|
32
54
|
const started = io.now();
|
|
33
55
|
const deadline = started + options.timeoutMs;
|
|
34
56
|
const print = (lines) => io.out(`${lines.join('\n')}\n`);
|
|
@@ -43,28 +65,27 @@ export async function hold(io, options) {
|
|
|
43
65
|
};
|
|
44
66
|
if (!(await readCredential(io, origin)))
|
|
45
67
|
return signInNeeded(false);
|
|
46
|
-
await startWaiting(io, origin, requestId, io.cwd).catch(() => { });
|
|
47
68
|
let failures = 0;
|
|
48
69
|
for (;;) {
|
|
49
70
|
if (io.signal.aborted) {
|
|
50
71
|
print([
|
|
51
72
|
`Pending You: stopped waiting on ${requestId}.`,
|
|
52
|
-
|
|
73
|
+
`If you’re still waiting on their answer, start it again as a background command: ${again}`,
|
|
53
74
|
]);
|
|
54
75
|
return 0;
|
|
55
76
|
}
|
|
56
77
|
const left = deadline - io.now();
|
|
57
78
|
if (left <= 0) {
|
|
58
79
|
print([
|
|
59
|
-
`Pending You: no answer on ${requestId} after ${minutes(options.timeoutMs)}, so
|
|
60
|
-
|
|
80
|
+
`Pending You: no answer on ${requestId} after ${minutes(options.timeoutMs)}, so this hold stopped.`,
|
|
81
|
+
`Start it again now as a background command, so their answer still wakes you: ${again}`,
|
|
61
82
|
]);
|
|
62
83
|
return 0;
|
|
63
84
|
}
|
|
64
85
|
const wait = Math.max(1, Math.min(LONG_POLL_SECONDS, Math.floor(left / 1000)));
|
|
65
86
|
let result;
|
|
66
87
|
try {
|
|
67
|
-
result = await getJson(io, origin, `/mcp/cli/requests/${encodeURIComponent(requestId)}?wait=${wait}`, (wait + 20) * 1000, io.signal);
|
|
88
|
+
result = await getJson(io, origin, `/mcp/cli/requests/${encodeURIComponent(requestId)}?wait=${wait}&source=${SESSION_APP}`, (wait + 20) * 1000, io.signal);
|
|
68
89
|
}
|
|
69
90
|
catch (error) {
|
|
70
91
|
if (io.signal.aborted)
|
|
@@ -80,7 +101,6 @@ export async function hold(io, options) {
|
|
|
80
101
|
}
|
|
81
102
|
failures = 0;
|
|
82
103
|
if (result.status === 404) {
|
|
83
|
-
await stopWaiting(io, origin, [requestId]).catch(() => { });
|
|
84
104
|
print([
|
|
85
105
|
`Pending You: can’t find ${requestId} among your person’s Claude Code questions on ${origin}.`,
|
|
86
106
|
'Check the id from post_request. If this Claude Code signed in to Pending You as someone else, use get_request instead.',
|
|
@@ -93,10 +113,8 @@ export async function hold(io, options) {
|
|
|
93
113
|
continue;
|
|
94
114
|
}
|
|
95
115
|
if (heard.ready) {
|
|
96
|
-
print(describe(heard, `run \`${
|
|
97
|
-
await markHanded(io, origin, [
|
|
98
|
-
{ requestId, moment: momentOf(heard), closed: isClosed(heard) },
|
|
99
|
-
]).catch(() => { });
|
|
116
|
+
print(describe(heard, `run \`${again}\` in the background again`));
|
|
117
|
+
await markHanded(io, origin, [{ requestId, moment: momentOf(heard) }]).catch(() => { });
|
|
100
118
|
return 0;
|
|
101
119
|
}
|
|
102
120
|
if (io.now() - started > PATIENT_AFTER_MS)
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { DEFAULT_ORIGIN, parseArgs } from "./args.js";
|
|
|
4
4
|
export { readCredential, updateCredential } from "./credentials.js";
|
|
5
5
|
export { main } from "./main.js";
|
|
6
6
|
export { CLIENT_NAME, discover, register, revoke, SCOPE, signIn, signInWithDevice, } from "./oauth.js";
|
|
7
|
+
export { qrCode, qrLines } from "./qr.js";
|
|
7
8
|
export { VERSION } from "./version.js";
|
package/dist/install.js
CHANGED
|
@@ -21,6 +21,8 @@ export const cliRoot = (io) => join(configDir(io), 'cli');
|
|
|
21
21
|
export const cliPrefix = (io, version = VERSION) => join(cliRoot(io), version);
|
|
22
22
|
/** The command line inside a prefix. */
|
|
23
23
|
export const cliScript = (prefix) => join(prefix, 'node_modules', 'pendingyou', 'dist', 'cli.js');
|
|
24
|
+
/** The package inside a prefix, where its mod/ is (0.10.0). */
|
|
25
|
+
export const cliPackage = (prefix) => join(prefix, 'node_modules', 'pendingyou');
|
|
24
26
|
/** How long npm may take to install the package (it usually takes a few seconds). */
|
|
25
27
|
const NPM_TIMEOUT_MS = 180_000;
|
|
26
28
|
const exists = (path) => stat(path).then(() => true, () => false);
|
|
@@ -55,10 +57,10 @@ async function runningPackage(io) {
|
|
|
55
57
|
const manifest = await readJson(join(root, 'package.json')).catch(() => null);
|
|
56
58
|
return manifest?.name === 'pendingyou' && manifest.version === VERSION ? root : null;
|
|
57
59
|
}
|
|
58
|
-
/** Copies the package's published files (no dependencies to bring: it has none). */
|
|
60
|
+
/** Copies the package's published files (no dependencies to bring: it has none), the wake mod included. */
|
|
59
61
|
async function copyPackage(from, to) {
|
|
60
62
|
await mkdir(to, { recursive: true });
|
|
61
|
-
for (const name of ['package.json', 'dist', 'README.md', 'CHANGELOG.md', 'LICENSE'])
|
|
63
|
+
for (const name of ['package.json', 'dist', 'mod', 'README.md', 'CHANGELOG.md', 'LICENSE'])
|
|
62
64
|
if (await exists(join(from, name)))
|
|
63
65
|
await cp(join(from, name), join(to, name), { recursive: true });
|
|
64
66
|
}
|
package/dist/io.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { homedir, hostname } from 'node:os';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
-
export function runProgram(command, args, timeoutMs = 60_000) {
|
|
6
|
+
export function runProgram(command, args, timeoutMs = 60_000, options = {}) {
|
|
7
7
|
return new Promise((resolve) => {
|
|
8
8
|
let stdout = '';
|
|
9
9
|
let stderr = '';
|
|
@@ -12,6 +12,8 @@ export function runProgram(command, args, timeoutMs = 60_000) {
|
|
|
12
12
|
child = spawn(command, [...args], {
|
|
13
13
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
14
14
|
windowsHide: true,
|
|
15
|
+
// Exactly that environment, nothing of this process's.
|
|
16
|
+
...(options.env ? { env: options.env } : {}),
|
|
15
17
|
});
|
|
16
18
|
}
|
|
17
19
|
catch {
|
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
|
/**
|