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.
@@ -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, saying how the
8
- // answer will still arrive. Exit codes: 0 when it heard something or stopped cleanly; 1 when it can't wait at all
9
- // (not signed in, no such request, bad arguments).
10
- import { getJson, SignInNeeded, Unavailable } from "./api.js";
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, isClosed } from "./format.js";
13
- import { markHanded, momentOf, startWaiting, stopWaiting } from "./state.js";
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
- const holdCommand = (requestId) => `npx pendingyou hold ${requestId}`;
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
- 'Their answer still reaches you with your next message or session; or call get_request at a breakpoint.',
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 I stopped waiting.`,
60
- 'Don’t start another hold now. Their answer reaches you with your next message or session, or check list_pending at a breakpoint.',
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 \`${holdCommand(requestId)}\` in the background again`));
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
- 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
  /**