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 +18 -0
- package/README.md +37 -4
- package/dist/args.js +64 -11
- package/dist/claude.js +22 -12
- package/dist/cli.js +13 -4
- package/dist/index.js +1 -1
- package/dist/io.js +33 -0
- package/dist/main.js +38 -10
- package/dist/oauth.js +69 -0
- package/dist/version.js +1 -1
- package/dist/watch.js +132 -0
- package/package.json +2 -1
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
|
|
2
|
-
//
|
|
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,
|
|
65
|
-
|
|
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 ===
|
|
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 {
|
|
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,
|
|
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
|
|
34
|
+
export function selfCommand(io) {
|
|
35
35
|
const script = io.script.replaceAll('\\', '/');
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 } =
|
|
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
|
-
?
|
|
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, {
|
|
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
|
|
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
|
|
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": {
|