pendingyou 0.9.0 → 0.11.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,10 +1,40 @@
1
- // The command line's sign-in, one per Pending You address (production, staging, a local server), in
2
- // ~/.config/pendingyou/credentials.json: readable only by you (0600, in a 0700 folder), written whole, never printed
3
- // and never logged. A file someone loosened is tightened again on the next read.
4
- import { chmod, stat } from 'node:fs/promises';
1
+ // This computer's sign-ins, one per app per Pending You address (production, staging, a local server), in
2
+ // ~/.config/pendingyou/credentials.json: readable only by you (0600, in a 0700 folder with a .gitignore of `*`, so a
3
+ // dotfiles repository never takes it), written whole, never printed and never logged. A file someone loosened is
4
+ // tightened again on the next read.
5
+ //
6
+ // Since 0.11.0 each app has its own (Codex's beside Claude Code's): each is that app's own connection on Pending You
7
+ // ("Codex on devbox"), or, for an app whose MCP server signs in by itself, a sign-in that only hears its answers.
8
+ // Claude Code's stays under the address itself, where 0.10.0 kept the only one; every other app's is under
9
+ // `<address>#<app>`. A 0.10.0 command line still running (a hold, a refresh it started) rewrites the file with the
10
+ // entries it read, so it keeps the others as they are.
11
+ import { chmod, rm, stat } from 'node:fs/promises';
5
12
  import { join } from 'node:path';
6
- import { configDir, readJson, withLock, writeWhole } from "./files.js";
13
+ import { DEFAULT_APP, isAppId } from "./apps/ids.js";
14
+ import { configDir, readJson, readText, withLock, writeWhole } from "./files.js";
7
15
  export const credentialsPath = (io) => join(configDir(io), 'credentials.json');
16
+ /** Where an app's sign-in for `origin` is kept: Claude Code's under the address itself, another app's beside it. */
17
+ export const slotOf = (origin, app = DEFAULT_APP) => app === DEFAULT_APP ? origin : `${origin}#${app}`;
18
+ /** The address and app a key of the file is for; null for a key no app of this version knows. */
19
+ export function slotParts(key) {
20
+ const at = key.indexOf('#');
21
+ if (at < 0)
22
+ return { origin: key, app: DEFAULT_APP };
23
+ const app = key.slice(at + 1);
24
+ return isAppId(app) && app !== DEFAULT_APP ? { origin: key.slice(0, at), app } : null;
25
+ }
26
+ /**
27
+ * Keeps the config folder out of version control: a `.gitignore` of `*`, for anyone whose dotfiles repository holds
28
+ * ~/.config. Written once; a file of the person's own there is left as it is.
29
+ */
30
+ export async function ignoreFolder(io) {
31
+ const path = join(configDir(io), '.gitignore');
32
+ if ((await readText(path).catch(() => null)) !== null)
33
+ return;
34
+ await writeWhole(path, '# Pending You keeps sign-ins here: never commit them.\n*\n', {
35
+ mode: 0o600,
36
+ });
37
+ }
8
38
  function valid(value) {
9
39
  if (typeof value !== 'object' || value === null)
10
40
  return false;
@@ -31,29 +61,79 @@ async function readAll(io) {
31
61
  await chmod(path, 0o600);
32
62
  return file;
33
63
  }
34
- export async function readCredential(io, origin) {
35
- const entry = (await readAll(io)).origins[origin];
64
+ /** An app's sign-in for `origin` (Claude Code's when none is named). */
65
+ export async function readCredential(io, origin, app = DEFAULT_APP) {
66
+ const entry = (await readAll(io)).origins[slotOf(origin, app)];
36
67
  return valid(entry) ? entry : null;
37
68
  }
38
- /** Every origin with a sign-in. */
39
- export async function signedInOrigins(io) {
69
+ /** Every sign-in this computer has: its address and app. */
70
+ export async function signedInSlots(io) {
40
71
  const all = await readAll(io);
41
- return Object.keys(all.origins).filter((origin) => valid(all.origins[origin]));
72
+ return Object.keys(all.origins).flatMap((key) => {
73
+ const slot = slotParts(key);
74
+ return slot && valid(all.origins[key]) ? [slot] : [];
75
+ });
76
+ }
77
+ /** Every address with a sign-in, for any app. */
78
+ export async function signedInOrigins(io) {
79
+ return [...new Set((await signedInSlots(io)).map((slot) => slot.origin))];
42
80
  }
43
- /** Changes one origin's sign-in (null removes it), under the lock, from the file as it is now. */
44
- export async function updateCredential(io, origin, change) {
81
+ /** Changes one app's sign-in for `origin` (null removes it), under the lock, from the file as it is now. */
82
+ export async function updateCredential(io, origin, change, app = DEFAULT_APP) {
45
83
  const path = credentialsPath(io);
46
- return withLock(path, io, async () => {
84
+ const key = slotOf(origin, app);
85
+ const next = await withLock(path, io, async () => {
47
86
  const all = await readAll(io);
48
- const current = all.origins[origin];
49
- const next = await change(valid(current) ? current : null);
50
- if (next)
51
- all.origins[origin] = next;
87
+ const current = all.origins[key];
88
+ const changed = await change(valid(current) ? current : null);
89
+ if (changed)
90
+ all.origins[key] = changed;
52
91
  else
53
- delete all.origins[origin];
92
+ delete all.origins[key];
54
93
  await writeWhole(path, `${JSON.stringify({ version: 1, origins: all.origins }, null, 2)}\n`, {
55
94
  secret: true,
56
95
  });
57
- return next;
96
+ return changed;
97
+ });
98
+ await ignoreFolder(io).catch(() => { });
99
+ return next;
100
+ }
101
+ export const clientsPath = (io) => join(configDir(io), 'clients.json');
102
+ async function readClients(io) {
103
+ const file = await readJson(clientsPath(io)).catch(() => null);
104
+ return file && typeof file.origins === 'object' && file.origins !== null
105
+ ? file
106
+ : { version: 1, origins: {} };
107
+ }
108
+ /** The client this computer's connection for `origin` signed in with last, if any. */
109
+ export async function connectionClient(io, origin, app = DEFAULT_APP) {
110
+ const entry = (await readClients(io)).origins[slotOf(origin, app)];
111
+ return typeof entry?.clientId === 'string' ? entry.clientId : null;
112
+ }
113
+ /** The name this computer's connection for `origin` signed in with ("devbox"), if it's known. */
114
+ export async function connectionMachine(io, origin, app = DEFAULT_APP) {
115
+ const entry = (await readClients(io)).origins[slotOf(origin, app)];
116
+ return typeof entry?.machine === 'string' && entry.machine ? entry.machine : null;
117
+ }
118
+ /**
119
+ * Remembers the client this computer's connection for `origin` signed in with, or with null forgets it (uninstall),
120
+ * removing the file once it holds none.
121
+ */
122
+ export async function saveConnectionClient(io, origin, clientId, machine, app = DEFAULT_APP) {
123
+ const path = clientsPath(io);
124
+ const key = slotOf(origin, app);
125
+ if (!clientId && !(await connectionClient(io, origin, app)))
126
+ return;
127
+ await withLock(path, io, async () => {
128
+ const file = await readClients(io);
129
+ if (clientId)
130
+ file.origins[key] = { clientId, ...(machine ? { machine } : {}) };
131
+ else
132
+ delete file.origins[key];
133
+ const text = `${JSON.stringify({ version: 1, origins: file.origins }, null, 2)}\n`;
134
+ if (Object.keys(file.origins).length === 0)
135
+ await rm(path, { force: true });
136
+ else
137
+ await writeWhole(path, text, { secret: true });
58
138
  });
59
139
  }
@@ -7,6 +7,8 @@
7
7
  // Only the pendingyou server's url is read, never anything else from those files; nothing is written, nothing is sent.
8
8
  // A server Claude Code can't be read for (no file, another shape, a plugin's server) is never called a mismatch.
9
9
  import { join } from 'node:path';
10
+ import { codexServer } from "./apps/codex.js";
11
+ import { DEFAULT_APP } from "./apps/ids.js";
10
12
  import { claudeJsonPath, MCP_NAME } from "./claude.js";
11
13
  import { claudeDir, readText } from "./files.js";
12
14
  /**
@@ -86,10 +88,15 @@ export function differs(server, origin) {
86
88
  }
87
89
  /** The line a hook adds when they differ (about 45 tokens). */
88
90
  export const mismatchLine = (server, origin) => `Pending You: your MCP server is ${siteOf(server.url)} but your hooks and sign-in are ${siteOf(origin)}, so your cards and their answers may not meet. Ask your person which one they use, and restart the session after fixing it (npx pendingyou init --origin <that one>).`;
89
- /** The hook's warning for this folder, or null when the two agree or can't be told. Never throws. */
90
- export async function environmentWarning(io, folder, origin) {
91
+ /**
92
+ * The hook's warning for this folder, or null when the two agree or can't be told. Never throws. Codex's server is the
93
+ * one in its own config (~/.codex/config.toml), for every folder.
94
+ */
95
+ export async function environmentWarning(io, folder, origin, app = DEFAULT_APP) {
91
96
  try {
92
- const server = await serverForFolder(io, folder);
97
+ const server = app === 'codex'
98
+ ? await codexServer(io).then((found) => found?.url ? { url: found.url, scope: 'user' } : null)
99
+ : await serverForFolder(io, folder);
93
100
  return differs(server, origin) ? mismatchLine(server, origin) : null;
94
101
  }
95
102
  catch {
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,42 @@
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).
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).
10
12
  import { getJson, SignInNeeded, Unavailable } from "./api.js";
13
+ import { DEFAULT_APP } from "./apps/ids.js";
14
+ import { DEFAULT_ORIGIN, DEFAULT_TIMEOUT_MS } from "./args.js";
11
15
  import { readCredential } from "./credentials.js";
12
- import { describe, isClosed } from "./format.js";
13
- import { markHanded, momentOf, startWaiting, stopWaiting } from "./state.js";
16
+ import { describe } from "./format.js";
17
+ import { markHanded, momentOf } from "./state.js";
14
18
  /** The longest one long poll asks the server to wait. */
15
19
  export const LONG_POLL_SECONDS = 25;
16
20
  /** After this long, a pause between long polls: an answer hours later needn't arrive within seconds. */
17
21
  const PATIENT_AFTER_MS = 30 * 60 * 1000;
18
22
  const PATIENT_PAUSE_MS = 30_000;
19
23
  const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
20
- const holdCommand = (requestId) => `npx pendingyou hold ${requestId}`;
24
+ /** A duration as `--timeout` takes it: 4h, 90m or 45s. */
25
+ export function durationText(ms) {
26
+ if (ms % 3_600_000 === 0)
27
+ return `${ms / 3_600_000}h`;
28
+ if (ms % 60_000 === 0)
29
+ return `${ms / 60_000}m`;
30
+ return `${Math.round(ms / 1000)}s`;
31
+ }
32
+ /**
33
+ * The command that holds for this request again, as it was started: its origin and timeout when they aren't the
34
+ * defaults (`--origin` rather than PENDINGYOU_ORIGIN=…, so init's `Bash(npx pendingyou hold:*)` still allows it).
35
+ */
36
+ export function holdCommand(requestId, origin = DEFAULT_ORIGIN, timeoutMs = DEFAULT_TIMEOUT_MS) {
37
+ return [
38
+ `npx pendingyou hold ${requestId}`,
39
+ ...(origin === DEFAULT_ORIGIN ? [] : [`--origin ${origin}`]),
40
+ ...(timeoutMs === DEFAULT_TIMEOUT_MS ? [] : [`--timeout ${durationText(timeoutMs)}`]),
41
+ ].join(' ');
42
+ }
21
43
  const minutes = (ms) => {
22
44
  if (ms < 60_000)
23
45
  return `${Math.round(ms / 1000)} seconds`;
@@ -29,6 +51,7 @@ const minutes = (ms) => {
29
51
  };
30
52
  export async function hold(io, options) {
31
53
  const { origin, requestId } = options;
54
+ const again = holdCommand(requestId, origin, options.timeoutMs);
32
55
  const started = io.now();
33
56
  const deadline = started + options.timeoutMs;
34
57
  const print = (lines) => io.out(`${lines.join('\n')}\n`);
@@ -43,28 +66,27 @@ export async function hold(io, options) {
43
66
  };
44
67
  if (!(await readCredential(io, origin)))
45
68
  return signInNeeded(false);
46
- await startWaiting(io, origin, requestId, io.cwd).catch(() => { });
47
69
  let failures = 0;
48
70
  for (;;) {
49
71
  if (io.signal.aborted) {
50
72
  print([
51
73
  `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.',
74
+ `If you’re still waiting on their answer, start it again as a background command: ${again}`,
53
75
  ]);
54
76
  return 0;
55
77
  }
56
78
  const left = deadline - io.now();
57
79
  if (left <= 0) {
58
80
  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.',
81
+ `Pending You: no answer on ${requestId} after ${minutes(options.timeoutMs)}, so this hold stopped.`,
82
+ `Start it again now as a background command, so their answer still wakes you: ${again}`,
61
83
  ]);
62
84
  return 0;
63
85
  }
64
86
  const wait = Math.max(1, Math.min(LONG_POLL_SECONDS, Math.floor(left / 1000)));
65
87
  let result;
66
88
  try {
67
- result = await getJson(io, origin, `/mcp/cli/requests/${encodeURIComponent(requestId)}?wait=${wait}`, (wait + 20) * 1000, io.signal);
89
+ result = await getJson(io, origin, `/mcp/cli/requests/${encodeURIComponent(requestId)}?wait=${wait}&source=${DEFAULT_APP}`, (wait + 20) * 1000, io.signal);
68
90
  }
69
91
  catch (error) {
70
92
  if (io.signal.aborted)
@@ -80,7 +102,6 @@ export async function hold(io, options) {
80
102
  }
81
103
  failures = 0;
82
104
  if (result.status === 404) {
83
- await stopWaiting(io, origin, [requestId]).catch(() => { });
84
105
  print([
85
106
  `Pending You: can’t find ${requestId} among your person’s Claude Code questions on ${origin}.`,
86
107
  'Check the id from post_request. If this Claude Code signed in to Pending You as someone else, use get_request instead.',
@@ -93,10 +114,8 @@ export async function hold(io, options) {
93
114
  continue;
94
115
  }
95
116
  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(() => { });
117
+ print(describe(heard, `run \`${again}\` in the background again`));
118
+ await markHanded(io, origin, [{ requestId, moment: momentOf(heard) }]).catch(() => { });
100
119
  return 0;
101
120
  }
102
121
  if (io.now() - started > PATIENT_AFTER_MS)
package/dist/hooks.js ADDED
@@ -0,0 +1,117 @@
1
+ // Agents' hook definitions, as init writes them (0.11.0). Claude Code's settings.json `hooks` and Codex's hooks.json
2
+ // share one shape:
3
+ //
4
+ // { "<Event>": [ { "matcher": "…", "hooks": [ { "type": "command", "command": "…", "timeout": 10 } ] } ] }
5
+ //
6
+ // Ours are recognised by what they run (pendingyou and the command: pickup, handoff, stopcheck, posted), whatever
7
+ // version or path wrote them, so init updates them in place and uninstall takes out exactly them. Every other entry is
8
+ // kept as it was, in its place. Each line runs the shim (shim.ts) by its fixed path, with the app it's for:
9
+ //
10
+ // "<config>/bin/pendingyou-hook" pickup --app claude-code || true
11
+ //
12
+ // `|| true` keeps a hook from ever blocking the person, even when the shim can't start (Claude Code blocks a message
13
+ // when its UserPromptSubmit hook exits 2, and flags any other failure).
14
+ import { DEFAULT_ORIGIN } from "./args.js";
15
+ import { PlainError } from "./errors.js";
16
+ import { shellQuote } from "./install.js";
17
+ /** Seconds an agent gives each hook; they time-box themselves well inside it (main.ts's 3-second deadline). */
18
+ export const HOOK_TIMEOUT = 10;
19
+ /** One word for sh: as it is when it's plain, otherwise in single quotes. */
20
+ export const shWord = (text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", `'\\''`)}'`;
21
+ /** `--origin <it>`, for a command line, only when it isn't production. */
22
+ export const originArgs = (origin) => origin === DEFAULT_ORIGIN ? '' : ` --origin ${shWord(origin)}`;
23
+ /** A hook's command line through the shim: the command first, then the app, then `--origin` off production. */
24
+ export function hookLine(shim, origin, app, sub) {
25
+ return `${shellQuote(shim)} ${sub} --app ${app}${originArgs(origin)} || true`;
26
+ }
27
+ /** Whether a hook command is one of ours: it runs pendingyou's `sub` (pickup, handoff, stopcheck, posted). */
28
+ export function isOurHook(command, sub) {
29
+ if (typeof command !== 'string' || !command.includes('pendingyou'))
30
+ return false;
31
+ return new RegExp(`\\s${sub}(\\s|$)`).test(command);
32
+ }
33
+ const objectAt = (parent, key) => {
34
+ const value = parent[key];
35
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
36
+ ? value
37
+ : null;
38
+ };
39
+ /** The hooks object of a settings file; an error (and no changes) when it isn't an object. */
40
+ export function hooksOf(settings, where) {
41
+ const hooks = objectAt(settings, 'hooks');
42
+ if (settings.hooks !== undefined && hooks === null)
43
+ throw new PlainError(`${where} has a “hooks” that isn’t an object, so I left it alone.`);
44
+ return hooks ?? {};
45
+ }
46
+ /** Our hook for `sub` under `event`, if there is one. */
47
+ export function findHook(hooks, event, sub) {
48
+ const groups = hooks[event];
49
+ if (!Array.isArray(groups))
50
+ return undefined;
51
+ return groups
52
+ .flatMap((group) => group?.hooks ?? [])
53
+ .find((hook) => isOurHook(hook?.command, sub));
54
+ }
55
+ /**
56
+ * Adds our hooks, or updates them in place (a new command, a line an older version wrote, one copied from another
57
+ * app): `lines` holds each event's command line. Returns the hooks (a copy) and whether anything changed.
58
+ */
59
+ export function mergeHooks(hooks, specs, lines, where) {
60
+ const next = structuredClone(hooks);
61
+ let changed = false;
62
+ for (const spec of specs) {
63
+ const wanted = lines[spec.event];
64
+ if (wanted === undefined)
65
+ continue;
66
+ if (next[spec.event] !== undefined && !Array.isArray(next[spec.event]))
67
+ throw new PlainError(`${where}’s ${spec.event} hooks aren’t a list, so I left them alone.`);
68
+ const groups = Array.isArray(next[spec.event]) ? next[spec.event] : [];
69
+ const ours = groups.filter((group) => (group?.hooks ?? []).some((hook) => isOurHook(hook?.command, spec.sub)));
70
+ if (ours.length === 0) {
71
+ groups.push({
72
+ ...(spec.matcher ? { matcher: spec.matcher } : {}),
73
+ hooks: [{ type: 'command', command: wanted, timeout: HOOK_TIMEOUT }],
74
+ });
75
+ changed = true;
76
+ }
77
+ for (const group of ours) {
78
+ for (const hook of group.hooks ?? [])
79
+ if (isOurHook(hook.command, spec.sub) &&
80
+ (hook.command !== wanted || hook.timeout !== HOOK_TIMEOUT)) {
81
+ hook.command = wanted;
82
+ hook.timeout = HOOK_TIMEOUT;
83
+ changed = true;
84
+ }
85
+ // The matcher is ours to set only on a group that holds nothing but our hook.
86
+ const alone = (group.hooks ?? []).every((hook) => isOurHook(hook.command, spec.sub));
87
+ if (alone && spec.matcher && group.matcher !== spec.matcher) {
88
+ group.matcher = spec.matcher;
89
+ changed = true;
90
+ }
91
+ }
92
+ next[spec.event] = groups;
93
+ }
94
+ return { hooks: next, changed };
95
+ }
96
+ /** Takes our hooks out and leaves everything else as it was; an event left with no groups goes. */
97
+ export function removeHooks(hooks, specs) {
98
+ const next = structuredClone(hooks);
99
+ let changed = false;
100
+ for (const spec of specs) {
101
+ if (!Array.isArray(next[spec.event]))
102
+ continue;
103
+ const groups = [];
104
+ for (const group of next[spec.event]) {
105
+ const kept = (group?.hooks ?? []).filter((hook) => !isOurHook(hook?.command, spec.sub));
106
+ if (kept.length !== (group?.hooks ?? []).length)
107
+ changed = true;
108
+ if (kept.length > 0 || !Array.isArray(group?.hooks))
109
+ groups.push({ ...group, hooks: kept });
110
+ }
111
+ if (groups.length)
112
+ next[spec.event] = groups;
113
+ else
114
+ delete next[spec.event];
115
+ }
116
+ return { hooks: next, changed };
117
+ }
package/dist/index.js CHANGED
@@ -3,5 +3,8 @@ export { getJson, SignInNeeded, Unavailable } from "./api.js";
3
3
  export { DEFAULT_ORIGIN, parseArgs } from "./args.js";
4
4
  export { readCredential, updateCredential } from "./credentials.js";
5
5
  export { main } from "./main.js";
6
- export { CLIENT_NAME, discover, register, revoke, SCOPE, signIn, signInWithDevice, } from "./oauth.js";
6
+ export { CLIENT_NAME, deviceLink, discover, register, revoke, SCOPE, signIn, signInWithDevice, startDevice, waitForDevices, } from "./oauth.js";
7
+ export { qrCode, qrLines } from "./qr.js";
8
+ /** What the person says to an agent to finish setting up (0.11.0): the app's setup pages say the same. */
9
+ export { FINISH_SAY } from "./setup.js";
7
10
  export { VERSION } from "./version.js";