pendingyou 0.1.0 → 0.2.1

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 ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1
4
+
5
+ - Fixed: after `init --origin <url>` (any origin but the default), every Claude Code message was blocked with
6
+ `UserPromptSubmit operation blocked by hook: … There's no "--origin" command.` 0.2.0 wrote its hooks with `--origin`
7
+ before the command, which it couldn't read. To repair: `npx pendingyou@latest init --origin <url>` (or, if you ran
8
+ `uninstall` to get unblocked, the same command sets it up again).
9
+ - Hooks now name the command first (`pendingyou@0.2.1 handoff --origin <url>`), add `--origin` only when it isn't the
10
+ default, and end in `|| true`.
11
+ - Options can come before or after the command.
12
+ - `pickup` and `handoff` never block: on any error (bad options, no network, not signed in) they exit 0, print nothing
13
+ for Claude Code, and leave at most a short note on stderr.
14
+ - Running `init` again repairs hook lines an older version wrote.
15
+
16
+ ## 0.2.0
17
+
18
+ - `login --device` for computers with no browser, and `watch` for always-on scripts.
package/README.md CHANGED
@@ -19,6 +19,12 @@ Run it once on each computer. It:
19
19
 
20
20
  Your other settings are kept as they are. `npx pendingyou uninstall` removes exactly what `init` added.
21
21
 
22
+ The hooks never get in your way: if anything goes wrong (no network, not signed in, a bad option), they print nothing
23
+ and let your message through. Running `init` again updates them in place.
24
+
25
+ **On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
26
+ `npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
27
+
22
28
  ## How Claude Code uses it
23
29
 
24
30
  After it posts a question to Pending You, Claude Code runs this in the background and keeps working:
@@ -34,22 +40,49 @@ lines (the question, your answer, the next step) and exits. Claude Code wakes up
34
40
  When you're at the terminal, Claude Code asks you there and puts the same question in Pending You at once; the
35
41
  first answer wins. If you answer in the terminal, it withdraws the card and the hold ends by itself.
36
42
 
43
+ ## A computer with no browser
44
+
45
+ On a server or over SSH, sign in with a code instead:
46
+
47
+ ```sh
48
+ npx pendingyou login --device
49
+ ```
50
+
51
+ It shows a short code. Open pendingyou.com/device on your phone, sign in, enter the code and press Allow. Only enter a
52
+ code your own computer is showing you.
53
+
54
+ ## Always-on scripts
55
+
56
+ `watch` runs a command each time an answer is ready, for a script on your assistant's own machine:
57
+
58
+ ```sh
59
+ npx pendingyou watch -- ./on-answer.sh
60
+ ```
61
+
62
+ When you sign in, pick which assistant it hears for ("Hear answers for: Only Muse"); by default it's your Claude Code
63
+ assistants. The command runs without a shell, once per answer, message or fallback that comes due, oldest first. It
64
+ gets the request as JSON on stdin, and `PENDINGYOU_REQUEST_ID`, `PENDINGYOU_EVENT` (`answer_ready`, `message_ready` or
65
+ `fallback_due`), `PENDINGYOU_VERSION` and `PENDINGYOU_ORIGIN` in its environment. A failing command is tried twice
66
+ more, then skipped. `--once` stops after the first.
67
+
37
68
  ## Commands
38
69
 
39
70
  | Command | What it does |
40
71
  | --- | --- |
41
- | `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`) |
72
+ | `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`, `--device`) |
42
73
  | `hold <requestId>` | Wait for the answer, print it, exit (`--timeout 4h`) |
43
74
  | `status` | What's set up; exits 0 when Claude Code hears answers right away |
44
- | `login` / `logout` | Sign this computer in or out (`--force`, `--all`) |
75
+ | `login` / `logout` | Sign this computer in or out (`--device`, `--force`, `--all`) |
76
+ | `watch -- <command>` | Run a command each time an answer is ready (`--once`) |
45
77
  | `pickup` / `handoff` | The session-start and next-message hooks |
46
78
  | `uninstall` | Remove what `init` added, and sign out |
47
79
 
48
- Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`).
80
+ Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`). Options can come
81
+ before or after the command.
49
82
 
50
83
  ## What it can see, and where it keeps things
51
84
 
52
- The sign-in only hears answers to your own Claude Code assistants' questions. It isn't an assistant: it can't ask,
85
+ The sign-in only hears answers to your own Claude Code assistants' questions, or to the one assistant you picked. It isn't an assistant: it can't ask,
53
86
  answer or change anything, and it doesn't show up as one. It's stored in `~/.config/pendingyou/credentials.json`
54
87
  (readable only by you), refreshes itself, and ends after 30 days or on `logout`. `state.json` beside it remembers
55
88
  which answers were already handed over (ids only). Nothing is sent anywhere but Pending You.
package/dist/args.js CHANGED
@@ -1,5 +1,6 @@
1
- // The command line's arguments: one command, then its flags. No dependencies, no surprises: an unknown flag or a
2
- // stray argument is an error, never ignored.
1
+ // The command line's arguments: one command and its flags, which may come before or after it (`pendingyou handoff
2
+ // --origin <url>` and `pendingyou --origin <url> handoff` are the same; 0.2.0 wrote its hooks the second way). No
3
+ // dependencies, no surprises: an unknown flag or a stray argument is an error, never ignored.
3
4
  export const DEFAULT_ORIGIN = 'https://www.pendingyou.com';
4
5
  /** How long `hold` waits for an answer before it lets go (4 hours). */
5
6
  export const DEFAULT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
@@ -46,33 +47,70 @@ const COMMANDS = new Set([
46
47
  'handoff',
47
48
  'init',
48
49
  'uninstall',
50
+ 'watch',
49
51
  ]);
50
52
  /** Flags each command takes; `true` when the flag takes a value. */
51
53
  const FLAGS = {
52
54
  help: {},
53
55
  version: {},
54
- login: { origin: true, 'no-browser': false, force: false },
56
+ login: { origin: true, 'no-browser': false, force: false, device: false },
55
57
  logout: { origin: true, all: false },
56
58
  status: { origin: true },
57
59
  hold: { origin: true, timeout: true },
58
60
  pickup: { origin: true },
59
61
  handoff: { origin: true },
60
- init: { origin: true, 'no-login': false, 'no-browser': false },
62
+ init: { origin: true, 'no-login': false, 'no-browser': false, device: false },
61
63
  uninstall: { origin: true },
64
+ watch: { origin: true, once: false },
62
65
  };
66
+ /** Options that take a value, whichever command they belong to: needed to find the command behind them. */
67
+ const VALUE_OPTIONS = new Set(['origin', 'timeout']);
68
+ /** Splits off the options given before the command: [those options, the command, everything after it]. */
69
+ function splitCommand(argv) {
70
+ let index = 0;
71
+ while (index < argv.length) {
72
+ const arg = argv[index];
73
+ if (arg === '--' || !arg.startsWith('-'))
74
+ break;
75
+ index += arg.startsWith('--') && !arg.includes('=') && VALUE_OPTIONS.has(arg.slice(2)) ? 2 : 1;
76
+ }
77
+ return [argv.slice(0, index), argv[index], argv.slice(index + 1)];
78
+ }
79
+ /** The command the arguments name, wherever its options are; undefined when there isn't one. */
80
+ export function commandOf(argv) {
81
+ return splitCommand(argv)[1];
82
+ }
83
+ /** The commands Claude Code's hooks run. Whatever goes wrong, they exit 0 and never block the person. */
84
+ export const HOOK_COMMANDS = new Set(['pickup', 'handoff']);
63
85
  export function parseArgs(argv, env = {}) {
64
- const [first, ...rest] = argv;
65
- if (first === undefined || first === '-h' || first === '--help')
86
+ const [leading, first, after] = splitCommand(argv);
87
+ const helps = (arg) => arg === '-h' || arg === '--help';
88
+ const versions = (arg) => arg === '-v' || arg === '--version';
89
+ if (leading.some(helps))
66
90
  return { name: 'help' };
67
- if (first === '-v' || first === '--version')
91
+ if (first === undefined) {
92
+ if (leading.some(versions))
93
+ return { name: 'version' };
94
+ if (leading.length === 0)
95
+ return { name: 'help' };
96
+ throw new UsageError('Which command? Like: pendingyou status');
97
+ }
98
+ if (leading.some(versions))
68
99
  return { name: 'version' };
69
100
  if (!COMMANDS.has(first))
70
101
  throw new UsageError(`There’s no “${first}” command.`);
102
+ const rest = [...leading, ...after];
71
103
  const allowed = FLAGS[first] ?? {};
72
104
  const flags = new Map();
73
105
  const positional = [];
106
+ // `watch -- <command> [args…]`: everything after `--` is the command, taken as it is.
107
+ let command;
74
108
  for (let index = 0; index < rest.length; index++) {
75
109
  const arg = rest[index];
110
+ if (arg === '--' && first === 'watch') {
111
+ command = rest.slice(index + 1);
112
+ break;
113
+ }
76
114
  if (arg === '-h' || arg === '--help')
77
115
  return { name: 'help' };
78
116
  if (!arg.startsWith('--')) {
@@ -112,7 +150,13 @@ export function parseArgs(argv, env = {}) {
112
150
  return { name: 'version' };
113
151
  case 'login':
114
152
  none();
115
- return { name: 'login', origin, browser: !flags.has('no-browser'), force: flags.has('force') };
153
+ return {
154
+ name: 'login',
155
+ origin,
156
+ browser: !flags.has('no-browser'),
157
+ force: flags.has('force'),
158
+ device: flags.has('device'),
159
+ };
116
160
  case 'logout':
117
161
  none();
118
162
  return { name: 'logout', origin, all: flags.has('all') };
@@ -135,7 +179,13 @@ export function parseArgs(argv, env = {}) {
135
179
  origin,
136
180
  login: !flags.has('no-login'),
137
181
  browser: !flags.has('no-browser'),
182
+ device: flags.has('device'),
138
183
  };
184
+ case 'watch':
185
+ none();
186
+ if (!command?.length || !command[0])
187
+ throw new UsageError('watch needs a command after --, like: pendingyou watch -- ./on-answer.sh');
188
+ return { name: 'watch', origin, command, once: flags.has('once') };
139
189
  case 'hold': {
140
190
  if (positional.length !== 1)
141
191
  throw new UsageError('hold needs one request id, like: pendingyou hold req_0123…');
@@ -158,11 +208,14 @@ export const USAGE = `pendingyou: Pending You for Claude Code
158
208
  npx pendingyou hold <req_…> Wait in the background until the answer to a request is ready,
159
209
  print it, and exit (which wakes Claude Code)
160
210
  npx pendingyou status What's set up, and whether Claude Code hears answers right away
161
- npx pendingyou login | logout
211
+ npx pendingyou login [--device] | logout
212
+ --device: on a computer with no browser, approve a short code on your phone
213
+ npx pendingyou watch -- <command> [args…]
214
+ Run a command each time an answer is ready (always-on scripts)
162
215
  npx pendingyou uninstall Remove exactly what init added
163
216
  npx pendingyou pickup Answers waiting for this folder (the session-start hook)
164
217
  npx pendingyou handoff The same, only while a hold is outstanding (the next-message hook)
165
218
 
166
- Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser (login, init),
167
- --no-login (init), --all (logout), --force (login).
219
+ Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser and --device (login,
220
+ init), --no-login (init), --all (logout), --force (login), --once (watch).
168
221
  `;
package/dist/claude.js CHANGED
@@ -31,16 +31,24 @@ const quote = (text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceA
31
31
  * How a hook runs this command line: through npx when that's how it was started (pinned to this version, from npx's
32
32
  * cache), the installed `pendingyou` when it's installed, or this very file.
33
33
  */
34
- export function selfCommand(io, origin) {
34
+ export function selfCommand(io) {
35
35
  const script = io.script.replaceAll('\\', '/');
36
- const base = io.env.PENDINGYOU_SELF
36
+ return io.env.PENDINGYOU_SELF
37
37
  ? io.env.PENDINGYOU_SELF
38
38
  : !script || script.includes('/_npx/')
39
39
  ? `npx -y --prefer-offline pendingyou@${VERSION}`
40
40
  : script.includes('/node_modules/pendingyou/')
41
41
  ? 'pendingyou'
42
42
  : `node ${quote(io.script)}`;
43
- return origin === DEFAULT_ORIGIN ? base : `${base} --origin ${quote(origin)}`;
43
+ }
44
+ /**
45
+ * A hook's command line: the command first, then `--origin` only when it isn't the default. `|| true` keeps the hook
46
+ * from ever blocking the person, even when npx can't fetch the package or the command fails before it starts (Claude
47
+ * Code blocks a message when its UserPromptSubmit hook exits 2, and flags any other non-zero exit).
48
+ */
49
+ export function hookCommand(io, origin, sub) {
50
+ const flag = origin === DEFAULT_ORIGIN ? '' : ` --origin ${quote(origin)}`;
51
+ return `${selfCommand(io)} ${sub}${flag} || true`;
44
52
  }
45
53
  /** Whether a hook command is one of ours: it runs pendingyou's `pickup` or `handoff`. */
46
54
  export function isOurHook(command, sub) {
@@ -71,7 +79,10 @@ const objectAt = (parent, key) => {
71
79
  ? value
72
80
  : null;
73
81
  };
74
- /** Adds (or updates) our hooks and permissions. Returns the permissions it added. */
82
+ /**
83
+ * Adds our hooks and permissions, or updates our hooks in place (a new version, or a line an older version wrote, like
84
+ * 0.2.0's `… --origin <url> handoff`, which 0.2.0 itself can't read). Returns the permissions it added.
85
+ */
75
86
  export function mergeSettings(settings, commands) {
76
87
  const next = structuredClone(settings);
77
88
  let changed = false;
@@ -80,7 +91,7 @@ export function mergeSettings(settings, commands) {
80
91
  throw new PlainError('Claude Code’s settings have a “hooks” that isn’t an object, so I left them alone.');
81
92
  for (const event of Object.keys(HOOKS)) {
82
93
  const sub = HOOKS[event];
83
- const wanted = `${commands[event]} ${sub}`;
94
+ const wanted = commands[event];
84
95
  const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
85
96
  if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
86
97
  throw new PlainError(`Claude Code’s ${event} hooks aren’t a list, so I left them alone.`);
@@ -231,15 +242,17 @@ export async function installClaude(io, origin) {
231
242
  steps.push(mcp.step);
232
243
  const skill = await saveSkill(io, origin, before?.skill ?? null);
233
244
  steps.push(skill.step);
234
- const self = selfCommand(io, origin);
235
- const commands = { SessionStart: self, UserPromptSubmit: self };
245
+ const commands = {
246
+ SessionStart: hookCommand(io, origin, HOOKS.SessionStart),
247
+ UserPromptSubmit: hookCommand(io, origin, HOOKS.UserPromptSubmit),
248
+ };
236
249
  const merged = mergeSettings(await readSettings(io), commands);
237
250
  if (merged.changed)
238
251
  await writeSettings(io, merged.settings);
239
252
  steps.push({
240
253
  ok: true,
241
254
  text: merged.changed
242
- ? `Installed the session-start pickup and next-message hand-off hooks in ${settingsPath(io)}.`
255
+ ? `Installed or updated the session-start pickup and next-message hand-off hooks in ${settingsPath(io)}.`
243
256
  : 'The hooks were already installed.',
244
257
  });
245
258
  const manifest = {
@@ -248,10 +261,7 @@ export async function installClaude(io, origin) {
248
261
  claudeDir: claudeDir(io),
249
262
  mcpAdded: mcp.added || (before?.mcpAdded ?? false),
250
263
  skill: skill.skill,
251
- hooks: {
252
- SessionStart: `${self} ${HOOKS.SessionStart}`,
253
- UserPromptSubmit: `${self} ${HOOKS.UserPromptSubmit}`,
254
- },
264
+ hooks: commands,
255
265
  permissions: [...new Set([...(before?.permissions ?? []), ...merged.permissionsAdded])],
256
266
  };
257
267
  await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
package/dist/cli.js CHANGED
@@ -1,8 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  // `npx pendingyou …`: runs the command line with the real computer (realIo) and exits with its code.
3
3
  import { realIo } from "./io.js";
4
- import { main } from "./main.js";
5
- main(process.argv.slice(2), realIo()).then((code) => process.exit(code), () => {
4
+ import { isHook, main } from "./main.js";
5
+ const argv = process.argv.slice(2);
6
+ // A hook (pickup, handoff) exits 0 even here: it must never block the person's message.
7
+ const failed = isHook(argv) ? 0 : 1;
8
+ try {
9
+ main(argv, realIo()).then((code) => process.exit(code), () => {
10
+ process.stderr.write('pendingyou: Something went wrong.\n');
11
+ process.exit(failed);
12
+ });
13
+ }
14
+ catch {
6
15
  process.stderr.write('pendingyou: Something went wrong.\n');
7
- process.exit(1);
8
- });
16
+ process.exit(failed);
17
+ }
package/dist/index.js CHANGED
@@ -3,5 +3,5 @@ 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 } from "./oauth.js";
6
+ export { CLIENT_NAME, discover, register, revoke, SCOPE, signIn, signInWithDevice, } from "./oauth.js";
7
7
  export { VERSION } from "./version.js";
package/dist/io.js CHANGED
@@ -28,6 +28,38 @@ export function runProgram(command, args, timeoutMs = 60_000) {
28
28
  child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
29
29
  });
30
30
  }
31
+ export function execProgram(command, args, options) {
32
+ return new Promise((resolve) => {
33
+ let child;
34
+ try {
35
+ child = spawn(command, [...args], {
36
+ stdio: ['pipe', 'inherit', 'inherit'],
37
+ env: { ...process.env, ...options.env },
38
+ windowsHide: true,
39
+ });
40
+ }
41
+ catch {
42
+ resolve(127);
43
+ return;
44
+ }
45
+ let timedOut = false;
46
+ const stop = () => child.kill('SIGTERM');
47
+ const timer = setTimeout(() => {
48
+ timedOut = true;
49
+ stop();
50
+ }, options.timeoutMs);
51
+ options.signal?.addEventListener('abort', stop, { once: true });
52
+ const done = (code) => {
53
+ clearTimeout(timer);
54
+ options.signal?.removeEventListener('abort', stop);
55
+ resolve(code);
56
+ };
57
+ child.on('error', (error) => done(error.code === 'ENOENT' ? 127 : 1));
58
+ child.on('close', (code) => done(timedOut ? 124 : (code ?? 1)));
59
+ child.stdin?.on('error', () => { });
60
+ child.stdin?.end(options.stdin);
61
+ });
62
+ }
31
63
  export function sleep(ms, signal) {
32
64
  return new Promise((resolve) => {
33
65
  if (signal?.aborted || ms <= 0) {
@@ -93,6 +125,7 @@ export function realIo() {
93
125
  err: (text) => process.stderr.write(text),
94
126
  fetch: (input, init) => fetch(input, init),
95
127
  run: runProgram,
128
+ exec: execProgram,
96
129
  openBrowser,
97
130
  readStdin,
98
131
  now: () => Date.now(),
package/dist/main.js CHANGED
@@ -1,21 +1,26 @@
1
1
  // The command line: parse the arguments, run the command, return the exit code. `cli.ts` runs it for real; tests run
2
2
  // it with their own Io.
3
3
  import { getJson, SignInNeeded, Unavailable } from "./api.js";
4
- import { parseArgs, USAGE, UsageError } from "./args.js";
4
+ import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
5
5
  import { claudeState, installClaude, uninstallClaude } from "./claude.js";
6
6
  import { readCredential, signedInOrigins, updateCredential } from "./credentials.js";
7
7
  import { PlainError } from "./errors.js";
8
8
  import { hold } from "./hold.js";
9
- import { OAuthError, revoke, signIn } from "./oauth.js";
9
+ import { OAuthError, revoke, signIn, signInWithDevice } from "./oauth.js";
10
10
  import { pickup } from "./pickup.js";
11
11
  import { VERSION } from "./version.js";
12
+ import { watch } from "./watch.js";
12
13
  /** Whether this computer's sign-in works, and how many Claude Code connections it hears for. */
13
14
  async function checkSignIn(io, origin) {
14
15
  try {
15
16
  const { status, body } = await getJson(io, origin, '/mcp/cli/answers?limit=0', 15_000, io.signal);
16
17
  if (status !== 200)
17
18
  return { state: 'unreachable' };
18
- return { state: 'ok', connections: body.connections ?? 0 };
19
+ return {
20
+ state: 'ok',
21
+ connections: body.connections ?? 0,
22
+ ...(body.for ? { for: body.for } : {}),
23
+ };
19
24
  }
20
25
  catch (error) {
21
26
  if (error instanceof SignInNeeded)
@@ -32,10 +37,9 @@ async function login(io, options) {
32
37
  }
33
38
  }
34
39
  const previous = await readCredential(io, options.origin);
35
- const { clientId, tokens } = await signIn(io, {
36
- origin: options.origin,
37
- browser: options.browser,
38
- });
40
+ const { clientId, tokens } = options.device
41
+ ? await signInWithDevice(io, { origin: options.origin })
42
+ : await signIn(io, { origin: options.origin, browser: options.browser });
39
43
  await updateCredential(io, options.origin, () => ({
40
44
  clientId,
41
45
  ...tokens,
@@ -70,7 +74,9 @@ async function status(io, origin) {
70
74
  const lines = [`Pending You for Claude Code · ${origin}`];
71
75
  const mark = (ok) => (ok ? 'ok ' : 'missing');
72
76
  lines.push(` ${mark(signIn.state === 'ok')} Sign-in: ${signIn.state === 'ok'
73
- ? `hears for ${signIn.connections} Claude Code connection${signIn.connections === 1 ? '' : 's'}`
77
+ ? signIn.for
78
+ ? `hears for ${signIn.for.name} only (for pendingyou watch)`
79
+ : `hears for ${signIn.connections} Claude Code connection${signIn.connections === 1 ? '' : 's'}`
74
80
  : signIn.state === 'ended'
75
81
  ? 'ended; run npx pendingyou login'
76
82
  : signIn.state === 'none'
@@ -90,7 +96,12 @@ async function init(io, options) {
90
96
  io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`);
91
97
  if (options.login) {
92
98
  io.out('Signing this computer in, so answers can wake Claude Code:\n');
93
- await login(io, { origin: options.origin, browser: options.browser, force: false });
99
+ await login(io, {
100
+ origin: options.origin,
101
+ browser: options.browser,
102
+ device: options.device,
103
+ force: false,
104
+ });
94
105
  }
95
106
  else
96
107
  io.out('Skipped signing in. Run npx pendingyou login when you’re ready.\n');
@@ -128,20 +139,37 @@ async function run(io, command) {
128
139
  return init(io, command);
129
140
  case 'uninstall':
130
141
  return uninstall(io, command.origin);
142
+ case 'watch':
143
+ return watch(io, command);
131
144
  }
132
145
  }
146
+ /**
147
+ * Whether these arguments run a Claude Code hook (pickup or handoff). A hook must never get in the way of the person:
148
+ * Claude Code blocks their message when a UserPromptSubmit hook exits 2 and flags any other failure, so a hook exits 0
149
+ * whatever goes wrong, with at most a short note on stderr (which Claude Code doesn't add to the conversation).
150
+ */
151
+ export const isHook = (argv) => HOOK_COMMANDS.has(commandOf(argv) ?? '');
133
152
  export async function main(argv, io) {
153
+ const hook = isHook(argv);
134
154
  let command;
135
155
  try {
136
156
  command = parseArgs(argv, io.env);
137
157
  }
138
158
  catch (error) {
159
+ if (hook) {
160
+ const message = error instanceof UsageError ? error.message : 'Something went wrong.';
161
+ io.err(`pendingyou: ${message} Skipped this hook; run npx pendingyou@latest init to repair it.\n`);
162
+ return 0;
163
+ }
139
164
  if (error instanceof UsageError) {
140
165
  io.err(`${error.message}\n\n${USAGE}`);
141
166
  return 2;
142
167
  }
143
168
  throw error;
144
169
  }
170
+ // `pickup --help` and the like: nothing for Claude Code to add to the conversation.
171
+ if (hook && !HOOK_COMMANDS.has(command.name))
172
+ return 0;
145
173
  try {
146
174
  return await run(io, command);
147
175
  }
@@ -154,6 +182,6 @@ export async function main(argv, io) {
154
182
  ? error.message
155
183
  : 'Something went wrong.';
156
184
  io.err(`pendingyou: ${message}\n`);
157
- return 1;
185
+ return hook ? 0 : 1;
158
186
  }
159
187
  }
package/dist/oauth.js CHANGED
@@ -233,3 +233,72 @@ export async function revoke(io, origin, clientId, refreshToken) {
233
233
  return false;
234
234
  }
235
235
  }
236
+ /**
237
+ * Device sign-in (RFC 8628) for a computer with no browser: shows a short code and where to enter it, then polls until
238
+ * the person presses Allow on their phone (or Cancel, or the code runs out).
239
+ */
240
+ export async function signInWithDevice(io, options) {
241
+ const metadata = await discover(io, options.origin);
242
+ const clientId = await register(io, metadata);
243
+ const started = await io.fetch(`${options.origin}/oauth/device`, {
244
+ method: 'POST',
245
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
246
+ body: new URLSearchParams({ client_id: clientId, scope: SCOPE }).toString(),
247
+ signal: AbortSignal.timeout(15_000),
248
+ });
249
+ const start = (await started.json().catch(() => ({})));
250
+ if (!started.ok || typeof start.device_code !== 'string' || typeof start.user_code !== 'string')
251
+ throw new OAuthError(start.error ?? `http_${started.status}`, start.error === 'slow_down'
252
+ ? 'Too many sign-ins from here. Wait 10 minutes, then try again.'
253
+ : `Pending You didn’t start a device sign-in (${started.status}).`);
254
+ const page = start.verification_uri ?? `${options.origin}/device`;
255
+ if (new URL(page).origin !== options.origin)
256
+ throw new OAuthError('discovery', 'Pending You sent the sign-in page somewhere else.');
257
+ io.err(`On your phone or any browser, open ${page} and enter this code:\n\n ${start.user_code}\n\n` +
258
+ `Or go straight to ${start.verification_uri_complete ?? `${page}?code=${start.user_code}`}\nWaiting for you to press Allow…\n`);
259
+ let interval = Math.max(1, start.interval ?? 5) * 1000;
260
+ const deadline = io.now() + Math.max(60, start.expires_in ?? 600) * 1000;
261
+ for (;;) {
262
+ await io.sleep(interval, io.signal);
263
+ if (io.signal.aborted)
264
+ throw new OAuthError('interrupted', 'Signing in was stopped.');
265
+ if (io.now() > deadline)
266
+ throw new OAuthError('expired_token', 'The code ran out. Run the command again for a new one.');
267
+ const response = await io.fetch(`${options.origin}/oauth/device/token`, {
268
+ method: 'POST',
269
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
270
+ body: new URLSearchParams({
271
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
272
+ device_code: start.device_code,
273
+ client_id: clientId,
274
+ }).toString(),
275
+ signal: AbortSignal.timeout(20_000),
276
+ });
277
+ const body = (await response.json().catch(() => ({})));
278
+ if (response.ok &&
279
+ typeof body.access_token === 'string' &&
280
+ typeof body.refresh_token === 'string') {
281
+ const seconds = typeof body.expires_in === 'number' ? body.expires_in : 900;
282
+ return {
283
+ clientId,
284
+ tokens: {
285
+ accessToken: body.access_token,
286
+ refreshToken: body.refresh_token,
287
+ expiresAt: io.now() + seconds * 1000,
288
+ },
289
+ };
290
+ }
291
+ const error = typeof body.error === 'string' ? body.error : `http_${response.status}`;
292
+ if (error === 'authorization_pending')
293
+ continue;
294
+ if (error === 'slow_down') {
295
+ interval += 5000;
296
+ continue;
297
+ }
298
+ if (error === 'access_denied')
299
+ throw new OAuthError(error, 'You pressed Cancel, so this computer isn’t signed in.');
300
+ if (error === 'expired_token')
301
+ throw new OAuthError(error, 'The code ran out. Run the command again for a new one.');
302
+ throw new OAuthError(error, `Pending You refused the sign-in (${error}).`);
303
+ }
304
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The command line's version, as published (package.json). A test keeps them the same.
2
- export const VERSION = '0.1.0';
2
+ export const VERSION = '0.2.1';
package/dist/watch.js ADDED
@@ -0,0 +1,132 @@
1
+ // `pendingyou watch -- <command> [args…]`: for an always-on script on an assistant's own machine (a Muse job, a cron
2
+ // box, a home server). It waits on Pending You for any of the sign-in's requests to become the assistant's move, and
3
+ // runs the command once for each: the answer after its 5-second hold, a message from the person, or a fallback that
4
+ // came due. Point the sign-in at one assistant on the consent page ("Hear answers for: Only Muse"); without that it
5
+ // watches the person's Claude Code assistants.
6
+ //
7
+ // The command runs without a shell, one at a time, with its output passed through. It gets the request on stdin as
8
+ // JSON (the same fields as /mcp/cli/answers: id, title, status, the answer in words, new messages, fallback) and, in
9
+ // the environment, PENDINGYOU_REQUEST_ID, PENDINGYOU_EVENT (answer_ready, message_ready or fallback_due),
10
+ // PENDINGYOU_VERSION (for ack_answer's expectedVersion) and PENDINGYOU_ORIGIN. Nothing about the answer goes in the
11
+ // environment or the arguments, where other programs could see it. The script then acts through its own assistant
12
+ // (get_request, ack_answer): watch only reads.
13
+ //
14
+ // A command that fails (a non-zero exit, or 5 minutes without finishing) is tried twice more, 5 and 15 seconds
15
+ // apart, then left: watch says so and moves on, so one bad answer can't wedge it. A command that can't be started at
16
+ // all stops watch (exit 127) without handing anything over. What was handed over is remembered
17
+ // (state.json), so a restart doesn't run anything twice. --once exits after the first, with the command's exit code.
18
+ import { getJson, SignInNeeded, Unavailable } from "./api.js";
19
+ import { readCredential } from "./credentials.js";
20
+ import { markHanded, momentOf, wasHanded } from "./state.js";
21
+ const LONG_POLL_SECONDS = 25;
22
+ const COMMAND_MS = 5 * 60 * 1000;
23
+ const RETRY_MS = [5000, 15_000];
24
+ const PATIENT_AFTER_MS = 30 * 60 * 1000;
25
+ const PATIENT_PAUSE_MS = 30_000;
26
+ const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
27
+ export function eventOf(heard) {
28
+ if (heard.status === 'answered')
29
+ return 'answer_ready';
30
+ if (heard.status === 'expired')
31
+ return 'fallback_due';
32
+ return 'message_ready';
33
+ }
34
+ export async function watch(io, options) {
35
+ const { origin } = options;
36
+ const [program, ...args] = options.command;
37
+ const say = (text) => io.err(`pendingyou watch: ${text}\n`);
38
+ if (!(await readCredential(io, origin))) {
39
+ say('this computer isn’t signed in. Run `npx pendingyou login` (or `login --device` without a browser).');
40
+ return 1;
41
+ }
42
+ say(`watching ${origin}. Ctrl-C stops.`);
43
+ let since;
44
+ let quietSince = io.now();
45
+ let failures = 0;
46
+ for (;;) {
47
+ if (io.signal.aborted) {
48
+ say('stopped.');
49
+ return 0;
50
+ }
51
+ const query = new URLSearchParams({ limit: '20', wait: String(LONG_POLL_SECONDS) });
52
+ if (since)
53
+ query.set('since', since);
54
+ let result;
55
+ try {
56
+ result = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, io.signal);
57
+ }
58
+ catch (error) {
59
+ if (io.signal.aborted)
60
+ continue;
61
+ if (error instanceof SignInNeeded) {
62
+ say(error.ended
63
+ ? 'the sign-in has ended. Run `npx pendingyou login` again.'
64
+ : 'this computer isn’t signed in. Run `npx pendingyou login`.');
65
+ return 1;
66
+ }
67
+ const pause = error instanceof Unavailable && error.retryAfter
68
+ ? error.retryAfter * 1000
69
+ : BACKOFF_MS[Math.min(failures, BACKOFF_MS.length - 1)];
70
+ failures++;
71
+ await io.sleep(pause, io.signal);
72
+ continue;
73
+ }
74
+ failures = 0;
75
+ const heard = Array.isArray(result.body.requests) ? result.body.requests : [];
76
+ if (result.status !== 200) {
77
+ await io.sleep(BACKOFF_MS[1], io.signal);
78
+ continue;
79
+ }
80
+ for (const request of heard)
81
+ if (!since || request.changedAt > since)
82
+ since = request.changedAt;
83
+ // Oldest first, and only what this computer hasn't handed over yet.
84
+ const fresh = [];
85
+ for (const request of [...heard].sort((a, b) => a.changedAt.localeCompare(b.changedAt)))
86
+ if (!(await wasHanded(io, origin, request.requestId, momentOf(request))))
87
+ fresh.push(request);
88
+ for (const request of fresh) {
89
+ if (io.signal.aborted)
90
+ break;
91
+ const code = await runFor(io, program, args, origin, request, say);
92
+ // A command that can't even start is a mistake in the command line: stop, and hand nothing over.
93
+ if (code === 127)
94
+ return 127;
95
+ await markHanded(io, origin, [
96
+ { requestId: request.requestId, moment: momentOf(request), closed: true },
97
+ ]);
98
+ if (options.once)
99
+ return code;
100
+ }
101
+ if (fresh.length > 0)
102
+ quietSince = io.now();
103
+ else if (io.now() - quietSince > PATIENT_AFTER_MS)
104
+ await io.sleep(PATIENT_PAUSE_MS, io.signal);
105
+ }
106
+ }
107
+ /** Runs the command for one request, trying twice more if it fails. Returns its last exit code. */
108
+ async function runFor(io, program, args, origin, request, say) {
109
+ const env = {
110
+ PENDINGYOU_REQUEST_ID: request.requestId,
111
+ PENDINGYOU_EVENT: eventOf(request),
112
+ PENDINGYOU_VERSION: String(request.version),
113
+ PENDINGYOU_ORIGIN: origin,
114
+ };
115
+ const stdin = `${JSON.stringify(request)}\n`;
116
+ let code = 0;
117
+ for (let attempt = 0; attempt <= RETRY_MS.length; attempt++) {
118
+ if (attempt > 0)
119
+ await io.sleep(RETRY_MS[attempt - 1], io.signal);
120
+ if (io.signal.aborted)
121
+ return code;
122
+ code = await io.exec(program, args, { env, stdin, timeoutMs: COMMAND_MS, signal: io.signal });
123
+ if (code === 0)
124
+ return 0;
125
+ if (code === 127) {
126
+ say(`couldn’t start ${program}. Check the command after --. Stopping.`);
127
+ return code;
128
+ }
129
+ }
130
+ say(`the command failed for ${request.requestId} (exit ${code}) three times; moving on.`);
131
+ return code;
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pending You for Claude Code: hear your answers from Pending You in the background, and set Claude Code up in one command.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.pendingyou.com",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "dist",
13
13
  "README.md",
14
+ "CHANGELOG.md",
14
15
  "LICENSE"
15
16
  ],
16
17
  "engines": {