pendingyou 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/dist/args.js +29 -6
- package/dist/index.js +1 -1
- package/dist/io.js +33 -0
- package/dist/main.js +21 -8
- package/dist/oauth.js +69 -0
- package/dist/version.js +1 -1
- package/dist/watch.js +132 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,14 +34,40 @@ lines (the question, your answer, the next step) and exits. Claude Code wakes up
|
|
|
34
34
|
When you're at the terminal, Claude Code asks you there and puts the same question in Pending You at once; the
|
|
35
35
|
first answer wins. If you answer in the terminal, it withdraws the card and the hold ends by itself.
|
|
36
36
|
|
|
37
|
+
## A computer with no browser
|
|
38
|
+
|
|
39
|
+
On a server or over SSH, sign in with a code instead:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npx pendingyou login --device
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
It shows a short code. Open pendingyou.com/device on your phone, sign in, enter the code and press Allow. Only enter a
|
|
46
|
+
code your own computer is showing you.
|
|
47
|
+
|
|
48
|
+
## Always-on scripts
|
|
49
|
+
|
|
50
|
+
`watch` runs a command each time an answer is ready, for a script on your assistant's own machine:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx pendingyou watch -- ./on-answer.sh
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
When you sign in, pick which assistant it hears for ("Hear answers for: Only Muse"); by default it's your Claude Code
|
|
57
|
+
assistants. The command runs without a shell, once per answer, message or fallback that comes due, oldest first. It
|
|
58
|
+
gets the request as JSON on stdin, and `PENDINGYOU_REQUEST_ID`, `PENDINGYOU_EVENT` (`answer_ready`, `message_ready` or
|
|
59
|
+
`fallback_due`), `PENDINGYOU_VERSION` and `PENDINGYOU_ORIGIN` in its environment. A failing command is tried twice
|
|
60
|
+
more, then skipped. `--once` stops after the first.
|
|
61
|
+
|
|
37
62
|
## Commands
|
|
38
63
|
|
|
39
64
|
| Command | What it does |
|
|
40
65
|
| --- | --- |
|
|
41
|
-
| `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`) |
|
|
66
|
+
| `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`, `--device`) |
|
|
42
67
|
| `hold <requestId>` | Wait for the answer, print it, exit (`--timeout 4h`) |
|
|
43
68
|
| `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`) |
|
|
69
|
+
| `login` / `logout` | Sign this computer in or out (`--device`, `--force`, `--all`) |
|
|
70
|
+
| `watch -- <command>` | Run a command each time an answer is ready (`--once`) |
|
|
45
71
|
| `pickup` / `handoff` | The session-start and next-message hooks |
|
|
46
72
|
| `uninstall` | Remove what `init` added, and sign out |
|
|
47
73
|
|
|
@@ -49,7 +75,7 @@ Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDI
|
|
|
49
75
|
|
|
50
76
|
## What it can see, and where it keeps things
|
|
51
77
|
|
|
52
|
-
The sign-in only hears answers to your own Claude Code assistants' questions. It isn't an assistant: it can't ask,
|
|
78
|
+
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
79
|
answer or change anything, and it doesn't show up as one. It's stored in `~/.config/pendingyou/credentials.json`
|
|
54
80
|
(readable only by you), refreshes itself, and ends after 30 days or on `logout`. `state.json` beside it remembers
|
|
55
81
|
which answers were already handed over (ids only). Nothing is sent anywhere but Pending You.
|
package/dist/args.js
CHANGED
|
@@ -46,19 +46,21 @@ const COMMANDS = new Set([
|
|
|
46
46
|
'handoff',
|
|
47
47
|
'init',
|
|
48
48
|
'uninstall',
|
|
49
|
+
'watch',
|
|
49
50
|
]);
|
|
50
51
|
/** Flags each command takes; `true` when the flag takes a value. */
|
|
51
52
|
const FLAGS = {
|
|
52
53
|
help: {},
|
|
53
54
|
version: {},
|
|
54
|
-
login: { origin: true, 'no-browser': false, force: false },
|
|
55
|
+
login: { origin: true, 'no-browser': false, force: false, device: false },
|
|
55
56
|
logout: { origin: true, all: false },
|
|
56
57
|
status: { origin: true },
|
|
57
58
|
hold: { origin: true, timeout: true },
|
|
58
59
|
pickup: { origin: true },
|
|
59
60
|
handoff: { origin: true },
|
|
60
|
-
init: { origin: true, 'no-login': false, 'no-browser': false },
|
|
61
|
+
init: { origin: true, 'no-login': false, 'no-browser': false, device: false },
|
|
61
62
|
uninstall: { origin: true },
|
|
63
|
+
watch: { origin: true, once: false },
|
|
62
64
|
};
|
|
63
65
|
export function parseArgs(argv, env = {}) {
|
|
64
66
|
const [first, ...rest] = argv;
|
|
@@ -71,8 +73,14 @@ export function parseArgs(argv, env = {}) {
|
|
|
71
73
|
const allowed = FLAGS[first] ?? {};
|
|
72
74
|
const flags = new Map();
|
|
73
75
|
const positional = [];
|
|
76
|
+
// `watch -- <command> [args…]`: everything after `--` is the command, taken as it is.
|
|
77
|
+
let command;
|
|
74
78
|
for (let index = 0; index < rest.length; index++) {
|
|
75
79
|
const arg = rest[index];
|
|
80
|
+
if (arg === '--' && first === 'watch') {
|
|
81
|
+
command = rest.slice(index + 1);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
76
84
|
if (arg === '-h' || arg === '--help')
|
|
77
85
|
return { name: 'help' };
|
|
78
86
|
if (!arg.startsWith('--')) {
|
|
@@ -112,7 +120,13 @@ export function parseArgs(argv, env = {}) {
|
|
|
112
120
|
return { name: 'version' };
|
|
113
121
|
case 'login':
|
|
114
122
|
none();
|
|
115
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
name: 'login',
|
|
125
|
+
origin,
|
|
126
|
+
browser: !flags.has('no-browser'),
|
|
127
|
+
force: flags.has('force'),
|
|
128
|
+
device: flags.has('device'),
|
|
129
|
+
};
|
|
116
130
|
case 'logout':
|
|
117
131
|
none();
|
|
118
132
|
return { name: 'logout', origin, all: flags.has('all') };
|
|
@@ -135,7 +149,13 @@ export function parseArgs(argv, env = {}) {
|
|
|
135
149
|
origin,
|
|
136
150
|
login: !flags.has('no-login'),
|
|
137
151
|
browser: !flags.has('no-browser'),
|
|
152
|
+
device: flags.has('device'),
|
|
138
153
|
};
|
|
154
|
+
case 'watch':
|
|
155
|
+
none();
|
|
156
|
+
if (!command?.length || !command[0])
|
|
157
|
+
throw new UsageError('watch needs a command after --, like: pendingyou watch -- ./on-answer.sh');
|
|
158
|
+
return { name: 'watch', origin, command, once: flags.has('once') };
|
|
139
159
|
case 'hold': {
|
|
140
160
|
if (positional.length !== 1)
|
|
141
161
|
throw new UsageError('hold needs one request id, like: pendingyou hold req_0123…');
|
|
@@ -158,11 +178,14 @@ export const USAGE = `pendingyou: Pending You for Claude Code
|
|
|
158
178
|
npx pendingyou hold <req_…> Wait in the background until the answer to a request is ready,
|
|
159
179
|
print it, and exit (which wakes Claude Code)
|
|
160
180
|
npx pendingyou status What's set up, and whether Claude Code hears answers right away
|
|
161
|
-
npx pendingyou login | logout
|
|
181
|
+
npx pendingyou login [--device] | logout
|
|
182
|
+
--device: on a computer with no browser, approve a short code on your phone
|
|
183
|
+
npx pendingyou watch -- <command> [args…]
|
|
184
|
+
Run a command each time an answer is ready (always-on scripts)
|
|
162
185
|
npx pendingyou uninstall Remove exactly what init added
|
|
163
186
|
npx pendingyou pickup Answers waiting for this folder (the session-start hook)
|
|
164
187
|
npx pendingyou handoff The same, only while a hold is outstanding (the next-message hook)
|
|
165
188
|
|
|
166
|
-
Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser (login,
|
|
167
|
-
--no-login (init), --all (logout), --force (login).
|
|
189
|
+
Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser and --device (login,
|
|
190
|
+
init), --no-login (init), --all (logout), --force (login), --once (watch).
|
|
168
191
|
`;
|
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
|
@@ -6,16 +6,21 @@ 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,6 +139,8 @@ 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
|
}
|
|
133
146
|
export async function main(argv, io) {
|
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.
|
|
2
|
+
export const VERSION = '0.2.0';
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|