pendingyou 0.5.0 → 0.7.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/CHANGELOG.md +39 -0
- package/README.md +23 -5
- package/dist/api.js +78 -10
- package/dist/args.js +10 -2
- package/dist/claude.js +203 -38
- package/dist/files.js +5 -1
- package/dist/install.js +163 -0
- package/dist/io.js +46 -3
- package/dist/main.js +88 -11
- package/dist/oauth.js +10 -7
- package/dist/pickup.js +16 -9
- package/dist/stopcheck.js +13 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- Faster hooks. They ran `npx -y --prefer-offline pendingyou@<version> …`, and npx alone took about half a second
|
|
6
|
+
(and real CPU) before each one started; sessions starting or resuming together queued on npm's cache lock. `init`
|
|
7
|
+
now keeps a copy of this version in `~/.config/pendingyou/cli/<version>/` (copied from the package it runs from, or
|
|
8
|
+
installed with `npm install --prefix`, checked before it's used) and the hooks run it with Node directly:
|
|
9
|
+
`"<node>" "~/.config/pendingyou/cli/0.7.0/node_modules/pendingyou/dist/cli.js" handoff || true`. A signed-out
|
|
10
|
+
pickup went from 0.42 s to 0.04 s; a hand-off with its request from 0.77 s to 0.26 s. When the copy can't be made,
|
|
11
|
+
the hooks keep the npx form. Running `init` again upgrades the copy in place and removes old versions;
|
|
12
|
+
`uninstall` removes it; `status` says how the hooks run. To switch: `npx pendingyou@latest init`.
|
|
13
|
+
- `init` no longer looks hung: it says before each slow step what it's waiting on ("Adding the pendingyou MCP server
|
|
14
|
+
to Claude Code (this can take a minute)…"), gives Claude Code 3 minutes, and says plainly if it ran out. It reads
|
|
15
|
+
the MCP server from `~/.claude.json` (read only) instead of asking `claude mcp get`, which takes a minute, so a
|
|
16
|
+
server that's already there costs no `claude` call at all.
|
|
17
|
+
- Fixed: `init` kept a `pendingyou` MCP server that pointed at another origin (staging, when you asked for
|
|
18
|
+
production) and only said "check Claude Code didn't add the MCP server". It now names both addresses and replaces
|
|
19
|
+
the old one once you say so: asked at the terminal, or `--yes` to replace without asking. With nobody to ask, it
|
|
20
|
+
prints the two `claude mcp` commands. Then restart Claude Code, run `/mcp` and Authenticate.
|
|
21
|
+
- Everything from 0.6.0 stays: the 3-second deadline (which no longer loses half a second to npx), the background
|
|
22
|
+
refresh and the Stop check.
|
|
23
|
+
|
|
24
|
+
## 0.6.0
|
|
25
|
+
|
|
26
|
+
- Fixed: a hook could run past Claude Code's 10 seconds (`UserPromptSubmit hook … timed out after 10s — output
|
|
27
|
+
discarded`), mostly when the sign-in needed refreshing, which no time limit covered. Every hook (`pickup`,
|
|
28
|
+
`handoff`, `stopcheck`) now stops 3 seconds after it starts, printing only its plain fallback (the hand-off's
|
|
29
|
+
one-line reminder; nothing from the others, so the Stop check lets the turn end) and exiting 0. Requests give up
|
|
30
|
+
sooner too: 2.5 seconds for `pickup` (was 5), 2 for `handoff` with answers (was 2.5), 1.5 for open cards.
|
|
31
|
+
- A hook never refreshes the sign-in itself. Cut off halfway, a refresh can end the sign-in, so when the access token
|
|
32
|
+
runs out within 5 minutes a hook starts `pendingyou refresh` in the background, detached, which finishes under the
|
|
33
|
+
credentials lock however the hook ends (one at a time: none starts while another holds the lock). The hook goes on
|
|
34
|
+
with the token it has; one that has run out waits up to 1.2 seconds for the new one, then falls back.
|
|
35
|
+
- A refresh now gives up within 20 seconds in all (it could take 35), inside the lock's 30, so a slow one is never
|
|
36
|
+
taken over while it's still running.
|
|
37
|
+
- The Stop check no longer asks Claude Code to go on over "There's nothing waiting on you" or "no cards from me
|
|
38
|
+
waiting on you" (a negation before "waiting on you"), or "say if it needs you to download it" (what another agent
|
|
39
|
+
will say, if it ever does). A message that says nothing needs a card ("No card needed.", "Nothing needs your
|
|
40
|
+
attention.", "doesn't need a card") is taken at its word, as the Stop reason offers.
|
|
41
|
+
|
|
3
42
|
## 0.5.0
|
|
4
43
|
|
|
5
44
|
- A card with several questions (guide 2.6) prints each answer on its own line when `hold` ends (and when a hook
|
package/README.md
CHANGED
|
@@ -15,12 +15,27 @@ Run it once on each computer. It:
|
|
|
15
15
|
- adds three hooks to `~/.claude/settings.json`: a new session picks up answers that arrived while nothing was
|
|
16
16
|
running, your next message brings a waiting answer with it, and a check when Claude Code finishes a turn (below),
|
|
17
17
|
- lets Claude Code start `npx pendingyou hold` and use Pending You's tools without a permission prompt, so a question
|
|
18
|
-
it asks while you're away doesn't wait on one
|
|
18
|
+
it asks while you're away doesn't wait on one,
|
|
19
|
+
- keeps a copy of this version for the hooks in `~/.config/pendingyou/cli/<version>/`, so they start in a few
|
|
20
|
+
hundredths of a second instead of waiting on npx (about half a second each).
|
|
19
21
|
|
|
20
22
|
Your other settings are kept as they are. `npx pendingyou uninstall` removes exactly what `init` added.
|
|
21
23
|
|
|
24
|
+
Adding the MCP server can take Claude Code a minute or more (it checks its servers); `init` says when it's waiting on
|
|
25
|
+
that. If Claude Code already has a `pendingyou` server pointing somewhere else (staging, say), `init` names both and
|
|
26
|
+
asks before replacing it; `--yes` replaces it without asking, and with nobody to ask it prints the two commands to
|
|
27
|
+
run. Afterwards restart Claude Code, run `/mcp`, choose pendingyou and Authenticate.
|
|
28
|
+
|
|
22
29
|
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.
|
|
30
|
+
and let your message through. Each one stops within 3 seconds whatever happens, with at most its one-line reminder, and
|
|
31
|
+
never refreshes the sign-in itself: when that's due, it starts the refresh in the background, which finishes on its
|
|
32
|
+
own. Running `init` again updates them in place, and adds any a newer version brings.
|
|
33
|
+
|
|
34
|
+
The hooks run that copy with Node directly, like
|
|
35
|
+
`"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.7.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
|
|
36
|
+
paths in full). If the copy can't be made (no npm, no network), they run through `npx -y --prefer-offline
|
|
37
|
+
pendingyou@<version>` instead. `npx pendingyou status` says which. If you remove the Node they run with, run `init`
|
|
38
|
+
again.
|
|
24
39
|
|
|
25
40
|
## Nothing left only in chat
|
|
26
41
|
|
|
@@ -42,7 +57,8 @@ The check is pattern matching on your computer: no model, no network. It reads o
|
|
|
42
57
|
transcript (at most 4 MB) and skips code blocks and quotes. It counts as for you: a question to you (a sentence ending
|
|
43
58
|
in "?" with "you" or "your"); "you'll need to", "your step", "can you", "please run", "in your terminal", "only you
|
|
44
59
|
can", "waiting on you" and the like; or a list under a heading like "Your part", "Your steps", "For you", "Waiting on
|
|
45
|
-
you" or "Left for you".
|
|
60
|
+
you" or "Left for you". It lets the turn end when the message says nothing needs a card ("No card needed.", "Nothing
|
|
61
|
+
needs your attention.", "There's nothing waiting on you.").
|
|
46
62
|
|
|
47
63
|
**On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
|
|
48
64
|
`npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -91,7 +107,7 @@ more, then skipped. `--once` stops after the first.
|
|
|
91
107
|
|
|
92
108
|
| Command | What it does |
|
|
93
109
|
| --- | --- |
|
|
94
|
-
| `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`, `--device`) |
|
|
110
|
+
| `init` | Set up Claude Code on this computer (`--no-login`, `--no-browser`, `--device`, `--yes`) |
|
|
95
111
|
| `hold <requestId>` | Wait for the answer, print it, exit (`--timeout 4h`) |
|
|
96
112
|
| `status` | What's set up; exits 0 when Claude Code hears answers right away |
|
|
97
113
|
| `login` / `logout` | Sign this computer in or out (`--device`, `--force`, `--all`) |
|
|
@@ -108,7 +124,9 @@ before or after the command.
|
|
|
108
124
|
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,
|
|
109
125
|
answer or change anything, and it doesn't show up as one. It's stored in `~/.config/pendingyou/credentials.json`
|
|
110
126
|
(readable only by you), refreshes itself, and ends after 30 days or on `logout`. `state.json` beside it remembers
|
|
111
|
-
which answers were already handed over (ids only).
|
|
127
|
+
which answers were already handed over (ids only), `claude-code.json` what `init` added, and `cli/` the hooks' copy
|
|
128
|
+
of the command line. `init` reads `~/.claude.json` only to see whether Claude Code has a `pendingyou` MCP server and
|
|
129
|
+
where it points. Nothing is sent anywhere but Pending You.
|
|
112
130
|
|
|
113
131
|
## Development
|
|
114
132
|
|
package/dist/api.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
// Calls to Pending You with the command line's sign-in: a fresh access token (refreshed under the credentials lock,
|
|
2
2
|
// so a hold and a hook never spend the same refresh token), one retry after a 401, and plain errors.
|
|
3
|
-
|
|
3
|
+
//
|
|
4
|
+
// Hooks never refresh in their own process. A hook has a hard deadline (main.ts), and a refresh cut off halfway can
|
|
5
|
+
// end the sign-in: the server rotates the refresh token as it answers, and keeps exactly one earlier token valid
|
|
6
|
+
// (@cloudflare/workers-oauth-provider's previousRefreshTokenId). A refresh abandoned before its answer arrives is
|
|
7
|
+
// harmless on its own (the stored token is that earlier one, and still works), but if the server finishes it after a
|
|
8
|
+
// later refresh has stored a newer token, that newer token stops working and the next refresh gets invalid_grant. So
|
|
9
|
+
// a hook that needs a new token starts `pendingyou refresh` detached (background), which runs to the end under the
|
|
10
|
+
// lock whatever happens to the hook, and waits for it only briefly; until it lands the hook uses the token it has, or
|
|
11
|
+
// gives up and prints its plain fallback.
|
|
12
|
+
import { credentialsPath, readCredential, updateCredential, } from "./credentials.js";
|
|
13
|
+
import { isLocked } from "./files.js";
|
|
4
14
|
import { OAuthError, refresh } from "./oauth.js";
|
|
5
15
|
/** Refresh this long before the access token runs out. */
|
|
6
16
|
const EARLY_MS = 60_000;
|
|
17
|
+
/** A hook starts a background refresh this long before the access token runs out, and goes on with the old one. */
|
|
18
|
+
export const SOON_MS = 5 * 60_000;
|
|
19
|
+
/** How long a hook waits for a background refresh when it has no working token. */
|
|
20
|
+
export const REFRESH_WAIT_MS = 1200;
|
|
21
|
+
/** An access token with less than this left isn't worth sending. */
|
|
22
|
+
const USABLE_MS = 10_000;
|
|
7
23
|
/** Not signed in on this computer (for this origin), or the sign-in ended: the person runs `pendingyou login`. */
|
|
8
24
|
export class SignInNeeded extends Error {
|
|
9
25
|
name = 'SignInNeeded';
|
|
@@ -23,12 +39,11 @@ export class Unavailable extends Error {
|
|
|
23
39
|
this.retryAfter = retryAfter;
|
|
24
40
|
}
|
|
25
41
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return stored.accessToken;
|
|
42
|
+
/**
|
|
43
|
+
* Refreshes the sign-in under the lock unless it no longer needs it: a token with more than `earlyMs` left (or, when
|
|
44
|
+
* `force`d after a 401, a refresh token another process already replaced) is used as it is.
|
|
45
|
+
*/
|
|
46
|
+
async function refreshStored(io, origin, stored, options) {
|
|
32
47
|
let ended = false;
|
|
33
48
|
const updated = await updateCredential(io, origin, async (current) => {
|
|
34
49
|
if (!current)
|
|
@@ -36,6 +51,8 @@ async function freshToken(io, origin, force) {
|
|
|
36
51
|
// Another process may have refreshed while this one waited for the lock.
|
|
37
52
|
if (current.refreshToken !== stored.refreshToken && current.expiresAt - EARLY_MS > io.now())
|
|
38
53
|
return current;
|
|
54
|
+
if (!options.force && current.expiresAt - options.earlyMs > io.now())
|
|
55
|
+
return current;
|
|
39
56
|
try {
|
|
40
57
|
const tokens = await refresh(io, origin, current.clientId, current.refreshToken);
|
|
41
58
|
return { ...current, ...tokens };
|
|
@@ -51,7 +68,53 @@ async function freshToken(io, origin, force) {
|
|
|
51
68
|
});
|
|
52
69
|
if (!updated)
|
|
53
70
|
throw new SignInNeeded(ended);
|
|
54
|
-
return updated
|
|
71
|
+
return updated;
|
|
72
|
+
}
|
|
73
|
+
async function freshToken(io, origin, force) {
|
|
74
|
+
const stored = await readCredential(io, origin);
|
|
75
|
+
if (!stored)
|
|
76
|
+
throw new SignInNeeded(false);
|
|
77
|
+
if (!force && stored.expiresAt - EARLY_MS > io.now())
|
|
78
|
+
return stored.accessToken;
|
|
79
|
+
return (await refreshStored(io, origin, stored, { force, earlyMs: EARLY_MS })).accessToken;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `pendingyou refresh`, what a hook starts in the background: refreshes the sign-in when it runs out within SOON_MS
|
|
83
|
+
* (or `force`, after a 401), under the lock, to the end. Quiet; true when there's a sign-in afterwards.
|
|
84
|
+
*/
|
|
85
|
+
export async function refreshInBackground(io, origin, force) {
|
|
86
|
+
const stored = await readCredential(io, origin);
|
|
87
|
+
if (!stored)
|
|
88
|
+
return false;
|
|
89
|
+
if (!force && stored.expiresAt - SOON_MS > io.now())
|
|
90
|
+
return true;
|
|
91
|
+
await refreshStored(io, origin, stored, { force, earlyMs: SOON_MS });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** A hook's token: never refreshed here (see the top of this file). */
|
|
95
|
+
async function hookToken(io, origin, force, signal) {
|
|
96
|
+
const stored = await readCredential(io, origin);
|
|
97
|
+
if (!stored)
|
|
98
|
+
throw new SignInNeeded(false);
|
|
99
|
+
const left = stored.expiresAt - io.now();
|
|
100
|
+
if (!force && left > SOON_MS)
|
|
101
|
+
return stored.accessToken;
|
|
102
|
+
// One refresher at a time: a lock held now is a refresh (or a write) already under way.
|
|
103
|
+
if (!(await isLocked(credentialsPath(io))))
|
|
104
|
+
io.background(['refresh', '--origin', origin, ...(force ? ['--force'] : [])]);
|
|
105
|
+
// Still good for a few seconds: this request goes with it, the next one with the new token.
|
|
106
|
+
if (!force && left > USABLE_MS)
|
|
107
|
+
return stored.accessToken;
|
|
108
|
+
const until = io.now() + REFRESH_WAIT_MS;
|
|
109
|
+
while (io.now() < until && !signal?.aborted) {
|
|
110
|
+
await io.sleep(50, signal);
|
|
111
|
+
const current = await readCredential(io, origin);
|
|
112
|
+
if (!current)
|
|
113
|
+
throw new SignInNeeded(true);
|
|
114
|
+
if (current.refreshToken !== stored.refreshToken)
|
|
115
|
+
return current.accessToken;
|
|
116
|
+
}
|
|
117
|
+
throw new Unavailable('The sign-in is being refreshed in the background.');
|
|
55
118
|
}
|
|
56
119
|
/** Aborts on `signal` or after `ms`, whichever comes first (AbortSignal.any needs Node 20.3). */
|
|
57
120
|
function either(signal, ms) {
|
|
@@ -70,13 +133,18 @@ function either(signal, ms) {
|
|
|
70
133
|
* GETs `path` on `origin` as the command line, and returns the JSON body with its status. 401 after a refresh means
|
|
71
134
|
* the sign-in is over (revoked, or 30 days passed). Network failures and 5xx are `Unavailable`.
|
|
72
135
|
*/
|
|
73
|
-
export async function getJson(io, origin, path, timeoutMs, signal) {
|
|
136
|
+
export async function getJson(io, origin, path, timeoutMs, signal, options = {}) {
|
|
74
137
|
for (let attempt = 0;; attempt++) {
|
|
75
138
|
let token;
|
|
76
139
|
try {
|
|
77
|
-
token =
|
|
140
|
+
token =
|
|
141
|
+
options.hook && io.background
|
|
142
|
+
? await hookToken(io, origin, attempt > 0, signal)
|
|
143
|
+
: await freshToken(io, origin, attempt > 0);
|
|
78
144
|
}
|
|
79
145
|
catch (error) {
|
|
146
|
+
if (error instanceof Unavailable)
|
|
147
|
+
throw error;
|
|
80
148
|
if (error instanceof SignInNeeded)
|
|
81
149
|
throw error;
|
|
82
150
|
throw new Unavailable('Pending You couldn’t be reached to refresh the sign-in.');
|
package/dist/args.js
CHANGED
|
@@ -49,6 +49,7 @@ const COMMANDS = new Set([
|
|
|
49
49
|
'init',
|
|
50
50
|
'uninstall',
|
|
51
51
|
'watch',
|
|
52
|
+
'refresh',
|
|
52
53
|
]);
|
|
53
54
|
/** Flags each command takes; `true` when the flag takes a value. */
|
|
54
55
|
const FLAGS = {
|
|
@@ -61,9 +62,10 @@ const FLAGS = {
|
|
|
61
62
|
pickup: { origin: true },
|
|
62
63
|
handoff: { origin: true },
|
|
63
64
|
stopcheck: { origin: true },
|
|
64
|
-
init: { origin: true, 'no-login': false, 'no-browser': false, device: false },
|
|
65
|
+
init: { origin: true, 'no-login': false, 'no-browser': false, device: false, yes: false },
|
|
65
66
|
uninstall: { origin: true },
|
|
66
67
|
watch: { origin: true, once: false },
|
|
68
|
+
refresh: { origin: true, force: false },
|
|
67
69
|
};
|
|
68
70
|
/** Options that take a value, whichever command they belong to: needed to find the command behind them. */
|
|
69
71
|
const VALUE_OPTIONS = new Set(['origin', 'timeout']);
|
|
@@ -177,6 +179,10 @@ export function parseArgs(argv, env = {}) {
|
|
|
177
179
|
case 'uninstall':
|
|
178
180
|
none();
|
|
179
181
|
return { name: 'uninstall', origin };
|
|
182
|
+
// Not in USAGE: what a hook starts in the background when the sign-in needs refreshing (api.ts).
|
|
183
|
+
case 'refresh':
|
|
184
|
+
none();
|
|
185
|
+
return { name: 'refresh', origin, force: flags.has('force') };
|
|
180
186
|
case 'init':
|
|
181
187
|
none();
|
|
182
188
|
return {
|
|
@@ -185,6 +191,7 @@ export function parseArgs(argv, env = {}) {
|
|
|
185
191
|
login: !flags.has('no-login'),
|
|
186
192
|
browser: !flags.has('no-browser'),
|
|
187
193
|
device: flags.has('device'),
|
|
194
|
+
yes: flags.has('yes'),
|
|
188
195
|
};
|
|
189
196
|
case 'watch':
|
|
190
197
|
none();
|
|
@@ -224,5 +231,6 @@ export const USAGE = `pendingyou: Pending You for Claude Code
|
|
|
224
231
|
npx pendingyou stopcheck Questions or steps for you left only in chat (the Stop hook)
|
|
225
232
|
|
|
226
233
|
Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser and --device (login,
|
|
227
|
-
init), --no-login (init), --
|
|
234
|
+
init), --no-login (init), --yes (init: replace a pendingyou MCP server that points elsewhere without asking),
|
|
235
|
+
--all (logout), --force (login), --once (watch).
|
|
228
236
|
`;
|
package/dist/claude.js
CHANGED
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
// ~/.claude/settings.json. Settings are merged, never replaced: a file that isn't JSON is left alone, entries that
|
|
4
4
|
// aren't ours are never touched, and running init again changes nothing it doesn't need to. What init added is written
|
|
5
5
|
// down in ~/.config/pendingyou/claude-code.json, so uninstall removes exactly that.
|
|
6
|
+
//
|
|
7
|
+
// Since 0.7.0 the hooks run a private copy of this version with Node directly (install.ts), and init asks Claude Code
|
|
8
|
+
// itself as little as it can: `claude mcp get` and `claude mcp add` each take up to a minute or more (Claude Code checks
|
|
9
|
+
// its servers), so the MCP server is looked up in ~/.claude.json first (read only), and init says what it's waiting on.
|
|
6
10
|
import { createHash } from 'node:crypto';
|
|
7
11
|
import { rm, rmdir } from 'node:fs/promises';
|
|
8
12
|
import { dirname, join } from 'node:path';
|
|
9
13
|
import { DEFAULT_ORIGIN } from "./args.js";
|
|
10
14
|
import { PlainError } from "./errors.js";
|
|
11
15
|
import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
|
|
16
|
+
import { hookForm, hookRuns, installCli, pruneCli, removeCli, shellQuote, } from "./install.js";
|
|
12
17
|
import { VERSION } from "./version.js";
|
|
13
18
|
export const MCP_NAME = 'pendingyou';
|
|
14
19
|
/** The hooks, by Claude Code event, and the command each runs. */
|
|
@@ -46,13 +51,15 @@ export function selfCommand(io) {
|
|
|
46
51
|
: `node ${quote(io.script)}`;
|
|
47
52
|
}
|
|
48
53
|
/**
|
|
49
|
-
* A hook's command line: the
|
|
50
|
-
*
|
|
51
|
-
*
|
|
54
|
+
* A hook's command line: the private copy run by Node directly when there is one (`"<node>" "<…/cli.js>"`, both
|
|
55
|
+
* quoted), otherwise selfCommand; the command first, then `--origin` only when it isn't the default. `|| true` keeps
|
|
56
|
+
* the hook from ever blocking the person, even when npx can't fetch the package or the command fails before it starts
|
|
57
|
+
* (Claude Code blocks a message when its UserPromptSubmit hook exits 2, and flags any other non-zero exit).
|
|
52
58
|
*/
|
|
53
|
-
export function hookCommand(io, origin, sub) {
|
|
59
|
+
export function hookCommand(io, origin, sub, direct) {
|
|
54
60
|
const flag = origin === DEFAULT_ORIGIN ? '' : ` --origin ${quote(origin)}`;
|
|
55
|
-
|
|
61
|
+
const self = direct ? `${shellQuote(direct.node)} ${shellQuote(direct.script)}` : selfCommand(io);
|
|
62
|
+
return `${self} ${sub}${flag} || true`;
|
|
56
63
|
}
|
|
57
64
|
/** Whether a hook command is one of ours: it runs pendingyou's `pickup`, `handoff` or `stopcheck`. */
|
|
58
65
|
export function isOurHook(command, sub) {
|
|
@@ -170,39 +177,157 @@ export function unmergeSettings(settings, permissionsAdded) {
|
|
|
170
177
|
return { settings: next, changed };
|
|
171
178
|
}
|
|
172
179
|
const writeSettings = (io, settings) => writeWhole(settingsPath(io), `${JSON.stringify(settings, null, 2)}\n`);
|
|
173
|
-
/**
|
|
174
|
-
|
|
180
|
+
/** How long Claude Code may take to look up, add or remove an MCP server (it checks each server it has). */
|
|
181
|
+
const MCP_TIMEOUT_MS = 180_000;
|
|
182
|
+
/** Claude Code's own state file: ~/.claude.json, or .claude.json in $CLAUDE_CONFIG_DIR. */
|
|
183
|
+
export const claudeJsonPath = (io) => io.env.CLAUDE_CONFIG_DIR
|
|
184
|
+
? join(io.env.CLAUDE_CONFIG_DIR, '.claude.json')
|
|
185
|
+
: join(io.home, '.claude.json');
|
|
186
|
+
/**
|
|
187
|
+
* The user-scope pendingyou MCP server, read from ~/.claude.json without asking Claude Code (which takes a minute):
|
|
188
|
+
* the server, null when there isn't one, or 'unknown' when the file isn't there or isn't the shape expected. Only
|
|
189
|
+
* mcpServers.pendingyou is looked at; nothing is written.
|
|
190
|
+
*/
|
|
191
|
+
export async function mcpFromFile(io) {
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
const text = await readText(claudeJsonPath(io));
|
|
195
|
+
if (text === null)
|
|
196
|
+
return 'unknown';
|
|
197
|
+
parsed = JSON.parse(text);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return 'unknown';
|
|
201
|
+
}
|
|
202
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
203
|
+
return 'unknown';
|
|
204
|
+
const servers = parsed.mcpServers;
|
|
205
|
+
if (servers === undefined)
|
|
206
|
+
return null;
|
|
207
|
+
if (typeof servers !== 'object' || servers === null || Array.isArray(servers))
|
|
208
|
+
return 'unknown';
|
|
209
|
+
const server = servers[MCP_NAME];
|
|
210
|
+
if (server === undefined)
|
|
211
|
+
return null;
|
|
212
|
+
if (typeof server !== 'object' || server === null)
|
|
213
|
+
return 'unknown';
|
|
214
|
+
const url = server.url;
|
|
215
|
+
return { url: typeof url === 'string' ? url : null, scope: 'user' };
|
|
216
|
+
}
|
|
217
|
+
/** `claude mcp get pendingyou`'s answer: the server, null when there isn't one, 'timeout' when it took too long. */
|
|
218
|
+
async function mcpFromClaude(io) {
|
|
219
|
+
const result = await io.run('claude', ['mcp', 'get', MCP_NAME], MCP_TIMEOUT_MS);
|
|
220
|
+
if (result.code === 124)
|
|
221
|
+
return 'timeout';
|
|
222
|
+
if (result.code !== 0)
|
|
223
|
+
return null;
|
|
224
|
+
const url = /^\s*URL:\s*(\S+)/m.exec(result.stdout)?.[1] ?? null;
|
|
225
|
+
const scope = /^\s*Scope:\s*(\w+)/m.exec(result.stdout)?.[1]?.toLowerCase();
|
|
226
|
+
return {
|
|
227
|
+
url,
|
|
228
|
+
scope: scope === 'user' || scope === 'local' || scope === 'project' ? scope : null,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const sameUrl = (a, b) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
|
|
232
|
+
const addCommand = (url) => `claude mcp add --scope user --transport http ${MCP_NAME} ${url}`;
|
|
233
|
+
const removeCommand = (scope) => `claude mcp remove${scope ? ` --scope ${scope}` : ''} ${MCP_NAME}`;
|
|
234
|
+
/**
|
|
235
|
+
* The MCP server at user scope. One already there pointing at this origin is kept; one pointing elsewhere (staging
|
|
236
|
+
* when you asked for production) is replaced once you say so: asked at the terminal, `--yes` without asking, or, with
|
|
237
|
+
* nobody to ask, the two commands are printed.
|
|
238
|
+
*/
|
|
239
|
+
async function addMcp(io, origin, options, progress) {
|
|
240
|
+
const url = `${origin}/mcp`;
|
|
241
|
+
let existing = await mcpFromFile(io);
|
|
242
|
+
if (existing !== 'unknown' && existing?.url && sameUrl(existing.url, url))
|
|
243
|
+
return {
|
|
244
|
+
added: false,
|
|
245
|
+
step: { ok: true, text: `The pendingyou MCP server (${url}) was already in Claude Code.` },
|
|
246
|
+
};
|
|
175
247
|
const version = await io.run('claude', ['--version'], 20_000);
|
|
176
248
|
if (version.code === 127)
|
|
177
249
|
return {
|
|
178
250
|
added: false,
|
|
179
251
|
step: {
|
|
180
252
|
ok: false,
|
|
181
|
-
text: `Claude Code isn’t on this computer’s PATH. Install it, then run:
|
|
253
|
+
text: `Claude Code isn’t on this computer’s PATH. Install it, then run: ${addCommand(url)}`,
|
|
182
254
|
},
|
|
183
255
|
};
|
|
184
|
-
|
|
185
|
-
|
|
256
|
+
if (existing === 'unknown') {
|
|
257
|
+
progress('Checking Claude Code’s MCP servers (this can take a minute)…\n');
|
|
258
|
+
existing = await mcpFromClaude(io);
|
|
259
|
+
if (existing === 'timeout')
|
|
260
|
+
return {
|
|
261
|
+
added: false,
|
|
262
|
+
step: {
|
|
263
|
+
ok: false,
|
|
264
|
+
text: `Claude Code took more than 3 minutes to list its MCP servers, so I left them alone. Check with: claude mcp get ${MCP_NAME}. If it isn’t there, run: ${addCommand(url)}`,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
let replacing = null;
|
|
269
|
+
if (existing) {
|
|
270
|
+
if (!existing.url || sameUrl(existing.url, url))
|
|
271
|
+
return {
|
|
272
|
+
added: false,
|
|
273
|
+
step: { ok: true, text: 'The pendingyou MCP server was already in Claude Code.' },
|
|
274
|
+
};
|
|
275
|
+
progress(`Claude Code’s pendingyou MCP server points at ${existing.url}, not ${url}.\n`);
|
|
276
|
+
const commands = ` ${removeCommand(existing.scope)}\n ${addCommand(url)}`;
|
|
277
|
+
let replace = options.yes;
|
|
278
|
+
if (!replace && io.interactive) {
|
|
279
|
+
const answer = await io.ask(`Replace it with ${url}? [y/N] `);
|
|
280
|
+
replace = /^\s*y(es)?\s*$/i.test(answer);
|
|
281
|
+
if (!replace)
|
|
282
|
+
return {
|
|
283
|
+
added: false,
|
|
284
|
+
step: {
|
|
285
|
+
ok: false,
|
|
286
|
+
text: `Kept the MCP server at ${existing.url}. To switch later, run:\n ${commands}`,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (!replace)
|
|
291
|
+
return {
|
|
292
|
+
added: false,
|
|
293
|
+
step: {
|
|
294
|
+
ok: false,
|
|
295
|
+
text: `Kept the MCP server at ${existing.url}. To use ${url}, run init again with --yes, or run:\n ${commands}`,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
progress('Removing the old pendingyou MCP server (this can take a minute)…\n');
|
|
299
|
+
const removed = await io.run('claude', ['mcp', 'remove', ...(existing.scope ? ['--scope', existing.scope] : []), MCP_NAME], MCP_TIMEOUT_MS);
|
|
300
|
+
if (removed.code !== 0)
|
|
301
|
+
return {
|
|
302
|
+
added: false,
|
|
303
|
+
step: {
|
|
304
|
+
ok: false,
|
|
305
|
+
text: `${removed.code === 124 ? 'Claude Code took more than 3 minutes to remove' : 'Claude Code didn’t remove'} the old MCP server. Run:\n ${commands}`,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
replacing = existing;
|
|
309
|
+
}
|
|
310
|
+
progress('Adding the pendingyou MCP server to Claude Code (this can take a minute)…\n');
|
|
311
|
+
const add = await io.run('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', MCP_NAME, url], MCP_TIMEOUT_MS);
|
|
312
|
+
if (add.code === 0)
|
|
186
313
|
return {
|
|
187
|
-
added: false,
|
|
188
|
-
step: { ok: true, text: 'The pendingyou MCP server was already in Claude Code.' },
|
|
189
|
-
};
|
|
190
|
-
const add = await io.run('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', MCP_NAME, `${origin}/mcp`], 30_000);
|
|
191
|
-
return add.code === 0
|
|
192
|
-
? {
|
|
193
314
|
added: true,
|
|
194
315
|
step: {
|
|
195
316
|
ok: true,
|
|
196
|
-
text:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
: {
|
|
200
|
-
added: false,
|
|
201
|
-
step: {
|
|
202
|
-
ok: false,
|
|
203
|
-
text: `Claude Code didn’t add the MCP server. Run: claude mcp add --scope user --transport http ${MCP_NAME} ${origin}/mcp`,
|
|
317
|
+
text: replacing
|
|
318
|
+
? `Replaced the pendingyou MCP server (was ${replacing.url}) with ${url}. Restart Claude Code, then run /mcp, choose pendingyou and Authenticate.`
|
|
319
|
+
: `Added the pendingyou MCP server (${url}) for every project.`,
|
|
204
320
|
},
|
|
205
321
|
};
|
|
322
|
+
return {
|
|
323
|
+
added: false,
|
|
324
|
+
step: {
|
|
325
|
+
ok: false,
|
|
326
|
+
text: add.code === 124
|
|
327
|
+
? `Claude Code took more than 3 minutes to add the MCP server. Check with: claude mcp get ${MCP_NAME}. If it isn’t there, run: ${addCommand(url)}`
|
|
328
|
+
: `Claude Code didn’t add the MCP server. Run: ${addCommand(url)}`,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
206
331
|
}
|
|
207
332
|
/** The stub skill: written when it isn't there, or refreshed while it's still the one init wrote. */
|
|
208
333
|
async function saveSkill(io, origin, before) {
|
|
@@ -238,27 +363,50 @@ async function saveSkill(io, origin, before) {
|
|
|
238
363
|
step: { ok: true, text: `Saved the skill to ${path}.` },
|
|
239
364
|
};
|
|
240
365
|
}
|
|
241
|
-
/** Everything init does in Claude Code (not the sign-in). Idempotent. */
|
|
242
|
-
export async function installClaude(io, origin) {
|
|
366
|
+
/** Everything init does in Claude Code (not the sign-in). Idempotent. Each step is reported as it's done. */
|
|
367
|
+
export async function installClaude(io, origin, options = {}) {
|
|
243
368
|
const before = await readManifest(io);
|
|
244
369
|
const steps = [];
|
|
245
|
-
const
|
|
246
|
-
|
|
370
|
+
const report = (step) => {
|
|
371
|
+
steps.push(step);
|
|
372
|
+
options.report?.(step);
|
|
373
|
+
};
|
|
374
|
+
const progress = (text) => io.out(text);
|
|
375
|
+
const mcp = await addMcp(io, origin, { yes: options.yes ?? false }, progress);
|
|
376
|
+
report(mcp.step);
|
|
247
377
|
const skill = await saveSkill(io, origin, before?.skill ?? null);
|
|
248
|
-
|
|
378
|
+
report(skill.step);
|
|
379
|
+
// The hooks' own copy of this version, unless PENDINGYOU_SELF says what they run.
|
|
380
|
+
let installed = null;
|
|
381
|
+
if (!io.env.PENDINGYOU_SELF) {
|
|
382
|
+
const cli = await installCli(io, progress);
|
|
383
|
+
installed = cli.installed;
|
|
384
|
+
report(cli.installed
|
|
385
|
+
? {
|
|
386
|
+
ok: true,
|
|
387
|
+
text: `${cli.installed.how === 'kept' ? `pendingyou ${VERSION} for the hooks was already in` : `Installed pendingyou ${VERSION} for the hooks in`} ${cli.installed.prefix} (they run it with ${cli.installed.node}, no npx).`,
|
|
388
|
+
}
|
|
389
|
+
: {
|
|
390
|
+
ok: true,
|
|
391
|
+
text: `Couldn’t install pendingyou for the hooks (${cli.error}), so they run it through npx, which is slower. Run init again to retry.`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
249
394
|
const commands = Object.fromEntries(Object.keys(HOOKS).map((event) => [
|
|
250
395
|
event,
|
|
251
|
-
hookCommand(io, origin, HOOKS[event]),
|
|
396
|
+
hookCommand(io, origin, HOOKS[event], installed),
|
|
252
397
|
]));
|
|
253
398
|
const merged = mergeSettings(await readSettings(io), commands);
|
|
254
399
|
if (merged.changed)
|
|
255
400
|
await writeSettings(io, merged.settings);
|
|
256
|
-
|
|
401
|
+
report({
|
|
257
402
|
ok: true,
|
|
258
403
|
text: merged.changed
|
|
259
404
|
? `Installed or updated the session-start pickup, next-message hand-off and Stop check hooks in ${settingsPath(io)}.`
|
|
260
405
|
: 'The hooks were already installed.',
|
|
261
406
|
});
|
|
407
|
+
// The hooks run this version now (or npx): the copies of other versions are no longer used.
|
|
408
|
+
if (!io.env.PENDINGYOU_SELF)
|
|
409
|
+
await pruneCli(io, installed?.version ?? null);
|
|
262
410
|
const manifest = {
|
|
263
411
|
version: 1,
|
|
264
412
|
origin,
|
|
@@ -267,6 +415,7 @@ export async function installClaude(io, origin) {
|
|
|
267
415
|
skill: skill.skill,
|
|
268
416
|
hooks: commands,
|
|
269
417
|
permissions: [...new Set([...(before?.permissions ?? []), ...merged.permissionsAdded])],
|
|
418
|
+
cli: installed ? { version: installed.version, prefix: installed.prefix } : null,
|
|
270
419
|
};
|
|
271
420
|
await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
|
|
272
421
|
return steps;
|
|
@@ -295,8 +444,11 @@ export async function uninstallClaude(io) {
|
|
|
295
444
|
else if (text !== null)
|
|
296
445
|
steps.push({ ok: true, text: `Kept ${manifest.skill.path}: it was changed after init.` });
|
|
297
446
|
}
|
|
447
|
+
if (await removeCli(io))
|
|
448
|
+
steps.push({ ok: true, text: 'Removed the hooks’ copy of pendingyou.' });
|
|
298
449
|
if (manifest?.mcpAdded) {
|
|
299
|
-
|
|
450
|
+
io.out('Removing the pendingyou MCP server from Claude Code (this can take a minute)…\n');
|
|
451
|
+
const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], MCP_TIMEOUT_MS);
|
|
300
452
|
steps.push(result.code === 0
|
|
301
453
|
? { ok: true, text: 'Removed the pendingyou MCP server from Claude Code.' }
|
|
302
454
|
: {
|
|
@@ -311,20 +463,33 @@ export async function uninstallClaude(io) {
|
|
|
311
463
|
export async function claudeState(io) {
|
|
312
464
|
const version = await io.run('claude', ['--version'], 20_000);
|
|
313
465
|
const claude = version.code === 0 ? version.stdout.trim().split(/\s+/)[0] || 'installed' : null;
|
|
314
|
-
|
|
466
|
+
let mcp = false;
|
|
467
|
+
const fromFile = await mcpFromFile(io);
|
|
468
|
+
if (fromFile !== 'unknown')
|
|
469
|
+
mcp = fromFile ?? false;
|
|
470
|
+
else if (claude) {
|
|
471
|
+
const fromClaude = await mcpFromClaude(io);
|
|
472
|
+
mcp = fromClaude === 'timeout' ? 'unknown' : (fromClaude ?? false);
|
|
473
|
+
}
|
|
315
474
|
const skill = (await readText(skillPath(io))) !== null;
|
|
316
|
-
let
|
|
475
|
+
let ours = (_event) => undefined;
|
|
317
476
|
try {
|
|
318
477
|
const all = objectAt(await readSettings(io), 'hooks') ?? {};
|
|
319
|
-
|
|
320
|
-
all[event]
|
|
478
|
+
ours = (event) => Array.isArray(all[event])
|
|
479
|
+
? all[event]
|
|
480
|
+
.flatMap((group) => group.hooks ?? [])
|
|
481
|
+
.find((hook) => isOurHook(hook.command, HOOKS[event]))
|
|
482
|
+
: undefined;
|
|
321
483
|
}
|
|
322
484
|
catch { }
|
|
485
|
+
const start = ours('SessionStart');
|
|
486
|
+
const form = typeof start?.command === 'string' ? hookForm(start.command) : null;
|
|
323
487
|
return {
|
|
324
488
|
claude,
|
|
325
489
|
mcp,
|
|
326
490
|
skill,
|
|
327
|
-
hooks:
|
|
328
|
-
stopcheck:
|
|
491
|
+
hooks: Boolean(start && ours('UserPromptSubmit')),
|
|
492
|
+
stopcheck: Boolean(ours('Stop')),
|
|
493
|
+
form: form ? { ...form, runs: await hookRuns(form) } : null,
|
|
329
494
|
};
|
|
330
495
|
}
|
package/dist/files.js
CHANGED
|
@@ -60,7 +60,11 @@ export async function writeWhole(path, text, options = {}) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
/** A lock older than this was left by a process that died; it's taken over. */
|
|
63
|
-
const STALE_MS = 30_000;
|
|
63
|
+
export const STALE_MS = 30_000;
|
|
64
|
+
/** Whether another process holds `<path>.lock` now (a lock older than STALE_MS doesn't count). */
|
|
65
|
+
export async function isLocked(path) {
|
|
66
|
+
return stat(`${path}.lock`).then((info) => Date.now() - info.mtimeMs <= STALE_MS, () => false);
|
|
67
|
+
}
|
|
64
68
|
/** Runs `work` while holding `<path>.lock`, so two processes (a hold and a hook) never write over each other. */
|
|
65
69
|
export async function withLock(path, io, work) {
|
|
66
70
|
const lock = `${path}.lock`;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The command line's own copy for Claude Code's hooks (0.7.0). Hooks used to run `npx -y --prefer-offline
|
|
2
|
+
// pendingyou@<version> …`: npx costs about half a second before the command starts, and sessions starting together
|
|
3
|
+
// queue on npm's cache lock. Now `init` puts this exact version in a private folder,
|
|
4
|
+
// ~/.config/pendingyou/cli/<version>/node_modules/pendingyou, and the hooks run it with Node directly:
|
|
5
|
+
//
|
|
6
|
+
// "<node>" "<config>/cli/<version>/node_modules/pendingyou/dist/cli.js" handoff || true
|
|
7
|
+
//
|
|
8
|
+
// The copy comes from the package init is running from when that's this very version (npx's copy: no network), and
|
|
9
|
+
// otherwise from `npm install --prefix`. It's built in a temporary folder, checked (`--version` must print this
|
|
10
|
+
// version), then moved into place, so a hook never runs half a copy. Running init again keeps a copy that checks out,
|
|
11
|
+
// and removes the other versions once the hooks point at this one. When none of that works, the hooks keep the npx
|
|
12
|
+
// form, which is slower but works.
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { cp, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
15
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
16
|
+
import { configDir, readJson } from "./files.js";
|
|
17
|
+
import { VERSION } from "./version.js";
|
|
18
|
+
/** Where the private copies live: ~/.config/pendingyou/cli. */
|
|
19
|
+
export const cliRoot = (io) => join(configDir(io), 'cli');
|
|
20
|
+
/** One version's prefix: ~/.config/pendingyou/cli/<version>. */
|
|
21
|
+
export const cliPrefix = (io, version = VERSION) => join(cliRoot(io), version);
|
|
22
|
+
/** The command line inside a prefix. */
|
|
23
|
+
export const cliScript = (prefix) => join(prefix, 'node_modules', 'pendingyou', 'dist', 'cli.js');
|
|
24
|
+
/** How long npm may take to install the package (it usually takes a few seconds). */
|
|
25
|
+
const NPM_TIMEOUT_MS = 180_000;
|
|
26
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
27
|
+
/**
|
|
28
|
+
* The Node the hooks should run: the running one, by the path on PATH that leads to it when there is one
|
|
29
|
+
* (Homebrew's /opt/homebrew/bin/node outlives an upgrade; the versioned folder it points into doesn't).
|
|
30
|
+
*/
|
|
31
|
+
export async function hookNode(io) {
|
|
32
|
+
const running = await realpath(io.execPath).catch(() => null);
|
|
33
|
+
if (running)
|
|
34
|
+
for (const dir of (io.env.PATH ?? '').split(':')) {
|
|
35
|
+
if (!dir || !isAbsolute(dir))
|
|
36
|
+
continue;
|
|
37
|
+
const candidate = join(dir, 'node');
|
|
38
|
+
if ((await realpath(candidate).catch(() => null)) === running)
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
return io.execPath;
|
|
42
|
+
}
|
|
43
|
+
/** Whether `script` run by `node` is this version of the command line. */
|
|
44
|
+
async function works(io, node, script) {
|
|
45
|
+
if (!(await exists(script)))
|
|
46
|
+
return false;
|
|
47
|
+
const result = await io.run(node, [script, '--version'], 30_000);
|
|
48
|
+
return result.code === 0 && result.stdout.trim() === VERSION;
|
|
49
|
+
}
|
|
50
|
+
/** The package init is running from, when it's this version of pendingyou (npx's copy, or a global install). */
|
|
51
|
+
async function runningPackage(io) {
|
|
52
|
+
if (!io.script || basename(io.script) !== 'cli.js' || basename(dirname(io.script)) !== 'dist')
|
|
53
|
+
return null;
|
|
54
|
+
const root = dirname(dirname(io.script));
|
|
55
|
+
const manifest = await readJson(join(root, 'package.json')).catch(() => null);
|
|
56
|
+
return manifest?.name === 'pendingyou' && manifest.version === VERSION ? root : null;
|
|
57
|
+
}
|
|
58
|
+
/** Copies the package's published files (no dependencies to bring: it has none). */
|
|
59
|
+
async function copyPackage(from, to) {
|
|
60
|
+
await mkdir(to, { recursive: true });
|
|
61
|
+
for (const name of ['package.json', 'dist', 'README.md', 'CHANGELOG.md', 'LICENSE'])
|
|
62
|
+
if (await exists(join(from, name)))
|
|
63
|
+
await cp(join(from, name), join(to, name), { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Puts this version in its private prefix, or keeps the one already there. Null (and why) when it couldn't: the hooks
|
|
67
|
+
* then use npx.
|
|
68
|
+
*/
|
|
69
|
+
export async function installCli(io, progress) {
|
|
70
|
+
const node = await hookNode(io);
|
|
71
|
+
const prefix = cliPrefix(io);
|
|
72
|
+
const script = cliScript(prefix);
|
|
73
|
+
if (await works(io, node, script))
|
|
74
|
+
return { installed: { version: VERSION, prefix, node, script, how: 'kept' } };
|
|
75
|
+
const root = cliRoot(io);
|
|
76
|
+
const staging = join(root, `.tmp-${VERSION}-${randomBytes(4).toString('hex')}`);
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
79
|
+
let how = null;
|
|
80
|
+
const from = await runningPackage(io);
|
|
81
|
+
if (from) {
|
|
82
|
+
try {
|
|
83
|
+
await copyPackage(from, join(staging, 'node_modules', 'pendingyou'));
|
|
84
|
+
if (await works(io, node, cliScript(staging)))
|
|
85
|
+
how = 'copied';
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
if (!how) {
|
|
90
|
+
await rm(staging, { recursive: true, force: true });
|
|
91
|
+
await mkdir(staging, { recursive: true });
|
|
92
|
+
progress(`Installing pendingyou@${VERSION} with npm, so the hooks start fast…\n`);
|
|
93
|
+
const result = await io.run('npm', [
|
|
94
|
+
'install',
|
|
95
|
+
'--prefix',
|
|
96
|
+
staging,
|
|
97
|
+
'--no-save',
|
|
98
|
+
'--no-package-lock',
|
|
99
|
+
'--no-audit',
|
|
100
|
+
'--no-fund',
|
|
101
|
+
'--prefer-offline',
|
|
102
|
+
'--loglevel=error',
|
|
103
|
+
`pendingyou@${VERSION}`,
|
|
104
|
+
], NPM_TIMEOUT_MS);
|
|
105
|
+
if (result.code === 127)
|
|
106
|
+
return { installed: null, error: 'npm isn’t on this computer’s PATH' };
|
|
107
|
+
if (result.code === 124)
|
|
108
|
+
return { installed: null, error: 'npm took more than 3 minutes to install it' };
|
|
109
|
+
if (result.code !== 0)
|
|
110
|
+
return { installed: null, error: 'npm couldn’t install it' };
|
|
111
|
+
if (!(await works(io, node, cliScript(staging))))
|
|
112
|
+
return { installed: null, error: 'the installed copy didn’t run' };
|
|
113
|
+
how = 'npm';
|
|
114
|
+
}
|
|
115
|
+
await rm(prefix, { recursive: true, force: true });
|
|
116
|
+
await rename(staging, prefix);
|
|
117
|
+
return { installed: { version: VERSION, prefix, node, script, how } };
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return { installed: null, error: `couldn’t write to ${root}` };
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await rm(staging, { recursive: true, force: true }).catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Removes every private copy but `keep` (the versions the hooks no longer run, and leftovers). */
|
|
127
|
+
export async function pruneCli(io, keep) {
|
|
128
|
+
const root = cliRoot(io);
|
|
129
|
+
const entries = await readdir(root).catch(() => []);
|
|
130
|
+
for (const entry of entries)
|
|
131
|
+
if (entry !== keep)
|
|
132
|
+
await rm(join(root, entry), { recursive: true, force: true }).catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
/** Removes the private copies altogether (uninstall). True when there were any. */
|
|
135
|
+
export async function removeCli(io) {
|
|
136
|
+
const root = cliRoot(io);
|
|
137
|
+
if (!(await exists(root)))
|
|
138
|
+
return false;
|
|
139
|
+
await rm(root, { recursive: true, force: true });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/** A double-quoted shell word: safe for any path, spaces and quotes included. */
|
|
143
|
+
export const shellQuote = (text) => `"${text.replace(/["$`\\]/g, '\\$&')}"`;
|
|
144
|
+
const QUOTED = /^"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"\s/;
|
|
145
|
+
const unquote = (text) => text.replace(/\\(.)/g, '$1');
|
|
146
|
+
export function hookForm(command) {
|
|
147
|
+
const direct = QUOTED.exec(command);
|
|
148
|
+
if (direct) {
|
|
149
|
+
const script = unquote(direct[2]);
|
|
150
|
+
const version = /\/cli\/([^/]+)\/node_modules\/pendingyou\/dist\/cli\.js$/.exec(script);
|
|
151
|
+
return {
|
|
152
|
+
form: 'direct',
|
|
153
|
+
node: unquote(direct[1]),
|
|
154
|
+
script,
|
|
155
|
+
version: version?.[1] ?? null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (/^npx\s/.test(command))
|
|
159
|
+
return { form: 'npx', version: /pendingyou@([^\s]+)/.exec(command)?.[1] ?? null };
|
|
160
|
+
return { form: 'other' };
|
|
161
|
+
}
|
|
162
|
+
/** Whether a direct hook's Node and script are both still there. */
|
|
163
|
+
export const hookRuns = async (form) => form.form !== 'direct' || ((await exists(form.node)) && (await exists(form.script)));
|
package/dist/io.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// network, a fake browser and a fake `claude`. `realIo()` is the real thing.
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { homedir, hostname } from 'node:os';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
5
6
|
export function runProgram(command, args, timeoutMs = 60_000) {
|
|
6
7
|
return new Promise((resolve) => {
|
|
7
8
|
let stdout = '';
|
|
@@ -10,7 +11,6 @@ export function runProgram(command, args, timeoutMs = 60_000) {
|
|
|
10
11
|
try {
|
|
11
12
|
child = spawn(command, [...args], {
|
|
12
13
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
-
timeout: timeoutMs,
|
|
14
14
|
windowsHide: true,
|
|
15
15
|
});
|
|
16
16
|
}
|
|
@@ -18,14 +18,23 @@ export function runProgram(command, args, timeoutMs = 60_000) {
|
|
|
18
18
|
resolve({ code: 127, stdout, stderr });
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
|
+
let timedOut = false;
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
timedOut = true;
|
|
24
|
+
child.kill('SIGTERM');
|
|
25
|
+
}, timeoutMs);
|
|
26
|
+
const done = (code) => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
resolve({ code: timedOut ? 124 : code, stdout, stderr });
|
|
29
|
+
};
|
|
21
30
|
child.stdout?.on('data', (chunk) => {
|
|
22
31
|
stdout += String(chunk);
|
|
23
32
|
});
|
|
24
33
|
child.stderr?.on('data', (chunk) => {
|
|
25
34
|
stderr += String(chunk);
|
|
26
35
|
});
|
|
27
|
-
child.on('error', (error) =>
|
|
28
|
-
child.on('close', (code) =>
|
|
36
|
+
child.on('error', (error) => done(error.code === 'ENOENT' ? 127 : 1));
|
|
37
|
+
child.on('close', (code) => done(code ?? 1));
|
|
29
38
|
});
|
|
30
39
|
}
|
|
31
40
|
export function execProgram(command, args, options) {
|
|
@@ -96,6 +105,21 @@ function readStdin(timeoutMs) {
|
|
|
96
105
|
process.stdin.on('error', finish);
|
|
97
106
|
});
|
|
98
107
|
}
|
|
108
|
+
function ask(question) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
111
|
+
let answered = false;
|
|
112
|
+
prompt.on('close', () => {
|
|
113
|
+
if (!answered)
|
|
114
|
+
resolve('');
|
|
115
|
+
});
|
|
116
|
+
prompt.question(question, (answer) => {
|
|
117
|
+
answered = true;
|
|
118
|
+
prompt.close();
|
|
119
|
+
resolve(answer);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
99
123
|
async function openBrowser(url) {
|
|
100
124
|
const [command, args] = process.platform === 'darwin'
|
|
101
125
|
? ['open', [url]]
|
|
@@ -105,6 +129,20 @@ async function openBrowser(url) {
|
|
|
105
129
|
const result = await runProgram(command, args, 10_000);
|
|
106
130
|
return result.code === 0;
|
|
107
131
|
}
|
|
132
|
+
function background(script, args) {
|
|
133
|
+
if (!script)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
const child = spawn(process.execPath, [script, ...args], {
|
|
137
|
+
detached: true,
|
|
138
|
+
stdio: 'ignore',
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
});
|
|
141
|
+
child.on('error', () => { });
|
|
142
|
+
child.unref();
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
}
|
|
108
146
|
export function realIo() {
|
|
109
147
|
const controller = new AbortController();
|
|
110
148
|
// The first signal lets the command finish cleanly (a hold says how the answer will still arrive); a second one
|
|
@@ -124,6 +162,9 @@ export function realIo() {
|
|
|
124
162
|
out: (text) => process.stdout.write(text),
|
|
125
163
|
err: (text) => process.stderr.write(text),
|
|
126
164
|
fetch: (input, init) => fetch(input, init),
|
|
165
|
+
execPath: process.execPath,
|
|
166
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
167
|
+
ask,
|
|
127
168
|
run: runProgram,
|
|
128
169
|
exec: execProgram,
|
|
129
170
|
openBrowser,
|
|
@@ -132,5 +173,7 @@ export function realIo() {
|
|
|
132
173
|
sleep,
|
|
133
174
|
signal: controller.signal,
|
|
134
175
|
script: process.argv[1] ?? '',
|
|
176
|
+
uptime: () => process.uptime() * 1000,
|
|
177
|
+
background: (args) => background(process.argv[1] ?? '', args),
|
|
135
178
|
};
|
|
136
179
|
}
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
import { getJson, SignInNeeded, Unavailable } from "./api.js";
|
|
4
|
-
import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
|
|
3
|
+
import { getJson, refreshInBackground, SignInNeeded, Unavailable } from "./api.js";
|
|
4
|
+
import { commandOf, DEFAULT_ORIGIN, 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";
|
|
@@ -74,6 +74,27 @@ async function status(io, origin) {
|
|
|
74
74
|
const [signIn, claude] = await Promise.all([checkSignIn(io, origin), claudeState(io)]);
|
|
75
75
|
const lines = [`Pending You for Claude Code · ${origin}`];
|
|
76
76
|
const mark = (ok) => (ok ? 'ok ' : 'missing');
|
|
77
|
+
const url = `${origin}/mcp`;
|
|
78
|
+
const mcp = claude.mcp;
|
|
79
|
+
const mcpOk = mcp !== false && mcp !== 'unknown' && (!mcp.url || mcp.url.replace(/\/+$/, '') === url);
|
|
80
|
+
const mcpText = mcp === 'unknown'
|
|
81
|
+
? 'Claude Code didn’t say in time; check with claude mcp get pendingyou'
|
|
82
|
+
: mcp === false
|
|
83
|
+
? 'not added; run npx pendingyou init'
|
|
84
|
+
: mcpOk
|
|
85
|
+
? `pendingyou${mcp.url ? ` (${mcp.url})` : ''}`
|
|
86
|
+
: `points at ${mcp.url}, not ${url}; run npx pendingyou init${origin === DEFAULT_ORIGIN ? '' : ` --origin ${origin}`} to replace it`;
|
|
87
|
+
const form = claude.form;
|
|
88
|
+
const hooksRun = form?.runs ?? true;
|
|
89
|
+
const formText = !form
|
|
90
|
+
? ''
|
|
91
|
+
: form.form === 'direct'
|
|
92
|
+
? form.runs
|
|
93
|
+
? `they run pendingyou${form.version ? ` ${form.version}` : ''} directly (${form.script})`
|
|
94
|
+
: `the Node or the copy of pendingyou they run is gone (${form.node}, ${form.script}); run npx pendingyou@latest init`
|
|
95
|
+
: form.form === 'npx'
|
|
96
|
+
? 'they run through npx, which is slower; run npx pendingyou@latest init to run them directly'
|
|
97
|
+
: 'they run pendingyou from PATH or a file of your own';
|
|
77
98
|
lines.push(` ${mark(signIn.state === 'ok')} Sign-in: ${signIn.state === 'ok'
|
|
78
99
|
? signIn.for
|
|
79
100
|
? `hears for ${signIn.for.name} only (for pendingyou watch)`
|
|
@@ -82,8 +103,8 @@ async function status(io, origin) {
|
|
|
82
103
|
? 'ended; run npx pendingyou login'
|
|
83
104
|
: signIn.state === 'none'
|
|
84
105
|
? 'not signed in; run npx pendingyou login'
|
|
85
|
-
: 'Pending You couldn’t be reached'}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(
|
|
86
|
-
const ready = signIn.state === 'ok' && claude.hooks;
|
|
106
|
+
: 'Pending You couldn’t be reached'}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(mcpOk)} MCP server: ${mcpText}`, ` ${mark(claude.skill)} Skill: ${claude.skill ? 'saved' : 'not saved; run npx pendingyou init'}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : 'not installed; run npx pendingyou init'}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : 'not installed; run npx pendingyou init'}`);
|
|
107
|
+
const ready = signIn.state === 'ok' && claude.hooks && hooksRun;
|
|
87
108
|
lines.push(ready
|
|
88
109
|
? 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
|
|
89
110
|
: 'Not ready: Claude Code hears answers while it’s working (report_setup hears "while-working").');
|
|
@@ -92,9 +113,10 @@ async function status(io, origin) {
|
|
|
92
113
|
}
|
|
93
114
|
async function init(io, options) {
|
|
94
115
|
io.out(`Setting up Pending You for Claude Code (${options.origin}).\n`);
|
|
95
|
-
const steps = await installClaude(io, options.origin
|
|
96
|
-
|
|
97
|
-
io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`)
|
|
116
|
+
const steps = await installClaude(io, options.origin, {
|
|
117
|
+
yes: options.yes,
|
|
118
|
+
report: (step) => io.out(` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`),
|
|
119
|
+
});
|
|
98
120
|
if (options.login) {
|
|
99
121
|
io.out('Signing this computer in, so answers can wake Claude Code:\n');
|
|
100
122
|
await login(io, {
|
|
@@ -116,7 +138,7 @@ async function uninstall(io, origin) {
|
|
|
116
138
|
await logout(io, { origin, all: false });
|
|
117
139
|
return steps.every((step) => step.ok) ? 0 : 1;
|
|
118
140
|
}
|
|
119
|
-
async function run(io, command) {
|
|
141
|
+
async function run(io, command, hook = { fallback: '' }) {
|
|
120
142
|
switch (command.name) {
|
|
121
143
|
case 'help':
|
|
122
144
|
io.out(USAGE);
|
|
@@ -133,9 +155,9 @@ async function run(io, command) {
|
|
|
133
155
|
case 'hold':
|
|
134
156
|
return hold(io, command);
|
|
135
157
|
case 'pickup':
|
|
136
|
-
return pickup(io, { origin: command.origin, mode: 'pickup' });
|
|
158
|
+
return pickup(io, { origin: command.origin, mode: 'pickup' }, hook);
|
|
137
159
|
case 'handoff':
|
|
138
|
-
return pickup(io, { origin: command.origin, mode: 'handoff' });
|
|
160
|
+
return pickup(io, { origin: command.origin, mode: 'handoff' }, hook);
|
|
139
161
|
case 'stopcheck':
|
|
140
162
|
return stopcheck(io, { origin: command.origin });
|
|
141
163
|
case 'init':
|
|
@@ -144,6 +166,61 @@ async function run(io, command) {
|
|
|
144
166
|
return uninstall(io, command.origin);
|
|
145
167
|
case 'watch':
|
|
146
168
|
return watch(io, command);
|
|
169
|
+
case 'refresh':
|
|
170
|
+
// Detached and quiet: nobody reads what it prints, and the next hook sees the result in the credentials file.
|
|
171
|
+
try {
|
|
172
|
+
await refreshInBackground(io, command.origin, command.force);
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A hook's hard deadline, from the moment this process started: Claude Code gives a hook 10 seconds (init's
|
|
180
|
+
* `timeout`), npx (hooks from before 0.7.0) takes about half a second before this process starts, and a normal hook
|
|
181
|
+
* is done in well under one.
|
|
182
|
+
* When it's reached, the hook prints its plain fallback (the hand-off's one-line reminder; nothing for pickup and
|
|
183
|
+
* stopcheck) unless it already printed, and exits 0, whatever it was still waiting on.
|
|
184
|
+
*/
|
|
185
|
+
export const HOOK_DEADLINE_MS = 3000;
|
|
186
|
+
async function runHook(io, command) {
|
|
187
|
+
const hook = { fallback: '' };
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
const stop = () => controller.abort();
|
|
190
|
+
if (io.signal.aborted)
|
|
191
|
+
stop();
|
|
192
|
+
io.signal.addEventListener('abort', stop, { once: true });
|
|
193
|
+
let finished = false;
|
|
194
|
+
let printed = false;
|
|
195
|
+
const hookIo = {
|
|
196
|
+
...io,
|
|
197
|
+
out: (text) => {
|
|
198
|
+
if (finished)
|
|
199
|
+
return;
|
|
200
|
+
printed = true;
|
|
201
|
+
io.out(text);
|
|
202
|
+
},
|
|
203
|
+
signal: controller.signal,
|
|
204
|
+
};
|
|
205
|
+
let timer;
|
|
206
|
+
const deadline = new Promise((resolve) => {
|
|
207
|
+
timer = setTimeout(() => resolve('deadline'), Math.max(0, HOOK_DEADLINE_MS - io.uptime()));
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
const outcome = await Promise.race([run(hookIo, command, hook), deadline]);
|
|
211
|
+
if (outcome !== 'deadline')
|
|
212
|
+
return outcome;
|
|
213
|
+
finished = true;
|
|
214
|
+
controller.abort();
|
|
215
|
+
if (!printed && hook.fallback)
|
|
216
|
+
io.out(hook.fallback);
|
|
217
|
+
io.err('pendingyou: Pending You took too long; skipped this hook.\n');
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
finished = true;
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
io.signal.removeEventListener('abort', stop);
|
|
147
224
|
}
|
|
148
225
|
}
|
|
149
226
|
/**
|
|
@@ -174,7 +251,7 @@ export async function main(argv, io) {
|
|
|
174
251
|
if (hook && !HOOK_COMMANDS.has(command.name))
|
|
175
252
|
return 0;
|
|
176
253
|
try {
|
|
177
|
-
return await run(io, command);
|
|
254
|
+
return hook ? await runHook(io, command) : await run(io, command);
|
|
178
255
|
}
|
|
179
256
|
catch (error) {
|
|
180
257
|
// Plain words only: never a stack, a token or a server's description.
|
package/dist/oauth.js
CHANGED
|
@@ -32,10 +32,10 @@ export function pkce() {
|
|
|
32
32
|
}
|
|
33
33
|
/** The resource the tokens are for: Pending You's MCP server, whose /mcp/cli/* the command line calls. */
|
|
34
34
|
export const resourceOf = (origin) => `${origin}/mcp`;
|
|
35
|
-
export async function discover(io, origin) {
|
|
35
|
+
export async function discover(io, origin, timeoutMs = 15_000) {
|
|
36
36
|
const response = await io.fetch(`${origin}/.well-known/oauth-authorization-server`, {
|
|
37
37
|
headers: { accept: 'application/json' },
|
|
38
|
-
signal: AbortSignal.timeout(
|
|
38
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
39
39
|
});
|
|
40
40
|
if (!response.ok)
|
|
41
41
|
throw new OAuthError('discovery', `Pending You at ${origin} didn’t answer (${response.status}).`);
|
|
@@ -136,12 +136,12 @@ async function listen(state, signal) {
|
|
|
136
136
|
},
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
-
async function tokenRequest(io, metadata, params) {
|
|
139
|
+
async function tokenRequest(io, metadata, params, timeoutMs = 20_000) {
|
|
140
140
|
const response = await io.fetch(metadata.token_endpoint, {
|
|
141
141
|
method: 'POST',
|
|
142
142
|
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
143
143
|
body: new URLSearchParams(params).toString(),
|
|
144
|
-
signal: AbortSignal.timeout(
|
|
144
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
145
145
|
});
|
|
146
146
|
const body = (await response.json().catch(() => ({})));
|
|
147
147
|
if (!response.ok ||
|
|
@@ -203,15 +203,18 @@ export async function signIn(io, options) {
|
|
|
203
203
|
loopback.close();
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* A new access token (and the next refresh token) from the current refresh token. At most 20 seconds in all, well
|
|
208
|
+
* inside the credentials lock's 30 seconds, so no other process takes the lock over while a refresh is still running.
|
|
209
|
+
*/
|
|
207
210
|
export async function refresh(io, origin, clientId, refreshToken) {
|
|
208
|
-
const metadata = await discover(io, origin);
|
|
211
|
+
const metadata = await discover(io, origin, 8_000);
|
|
209
212
|
return tokenRequest(io, metadata, {
|
|
210
213
|
grant_type: 'refresh_token',
|
|
211
214
|
refresh_token: refreshToken,
|
|
212
215
|
client_id: clientId,
|
|
213
216
|
resource: resourceOf(origin),
|
|
214
|
-
});
|
|
217
|
+
}, 12_000);
|
|
215
218
|
}
|
|
216
219
|
/** Revokes the sign-in on the server (RFC 7009). Best effort: signing out here still happens if this fails. */
|
|
217
220
|
export async function revoke(io, origin, clientId, refreshToken) {
|
package/dist/pickup.js
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
// time-boxed short; on any error, or from a server too old to list open cards, the line is the plain reminder.
|
|
11
11
|
//
|
|
12
12
|
// Whatever they print, Claude Code adds to the conversation. A hook must never get in the way: each is time-boxed,
|
|
13
|
+
// has a hard deadline (main.ts) after which it prints only its fallback, never refreshes the sign-in itself (api.ts),
|
|
13
14
|
// prints nothing when there's nothing new, never repeats an answer it handed over, and always exits 0.
|
|
14
15
|
import { getJson, SignInNeeded } from "./api.js";
|
|
15
16
|
import { readCredential } from "./credentials.js";
|
|
16
17
|
import { clip, describe, isClosed } from "./format.js";
|
|
17
18
|
import { markHanded, momentOf, waitingOn, wasHanded } from "./state.js";
|
|
18
|
-
|
|
19
|
+
/** Each request's own limit, inside the hook's 3-second deadline (main.ts). */
|
|
20
|
+
const TIME_BOX_MS = { pickup: 2500, handoff: 2000, open: 1500 };
|
|
19
21
|
/** What every message carries while signed in (about 18 tokens). */
|
|
20
22
|
export const REMINDER = 'Pending You: anything you need from your person also goes on a card.';
|
|
21
23
|
/** The most open cards the line names, and how much of each title. */
|
|
@@ -70,18 +72,19 @@ export function folderForms(cwd, home) {
|
|
|
70
72
|
forms.push(`~${cwd.slice(base.length)}`);
|
|
71
73
|
return forms;
|
|
72
74
|
}
|
|
73
|
-
export async function pickup(io, options) {
|
|
75
|
+
export async function pickup(io, options, hook = { fallback: '' }) {
|
|
74
76
|
const { origin, mode } = options;
|
|
75
|
-
|
|
76
|
-
let line =
|
|
77
|
+
// The hand-off's line, printed at the end unless something else was printed with it.
|
|
78
|
+
let line = null;
|
|
77
79
|
try {
|
|
78
80
|
const cwd = await sessionFolder(io);
|
|
79
81
|
// The hand-off hears answers only while a hold from this computer is outstanding; it always asks for open cards.
|
|
80
82
|
let answers = true;
|
|
81
83
|
if (mode === 'handoff') {
|
|
82
|
-
|
|
83
|
-
if (!remind)
|
|
84
|
+
if ((await readCredential(io, origin)) === null)
|
|
84
85
|
return 0;
|
|
86
|
+
line = REMINDER;
|
|
87
|
+
hook.fallback = `${REMINDER}\n`;
|
|
85
88
|
answers = (await waitingOn(io, origin)).length > 0;
|
|
86
89
|
}
|
|
87
90
|
const query = new URLSearchParams([
|
|
@@ -89,7 +92,7 @@ export async function pickup(io, options) {
|
|
|
89
92
|
['limit', answers ? '5' : '0'],
|
|
90
93
|
...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
|
|
91
94
|
]);
|
|
92
|
-
const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, answers ? TIME_BOX_MS[mode] : TIME_BOX_MS.open, io.signal);
|
|
95
|
+
const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, answers ? TIME_BOX_MS[mode] : TIME_BOX_MS.open, io.signal, { hook: true });
|
|
93
96
|
if (status !== 200)
|
|
94
97
|
return 0;
|
|
95
98
|
if (mode === 'handoff')
|
|
@@ -103,6 +106,8 @@ export async function pickup(io, options) {
|
|
|
103
106
|
if (fresh.length === 0)
|
|
104
107
|
return 0;
|
|
105
108
|
const again = (id) => `run \`npx pendingyou hold ${id}\` in the background`;
|
|
109
|
+
// One write, the line included: at the deadline a hook has printed all of this or none of it. Printed before it's
|
|
110
|
+
// marked as handed over, so a hook cut off in between repeats an answer rather than losing it.
|
|
106
111
|
io.out(`${[
|
|
107
112
|
mode === 'pickup'
|
|
108
113
|
? 'Pending You: your person answered while no session was listening.'
|
|
@@ -110,7 +115,9 @@ export async function pickup(io, options) {
|
|
|
110
115
|
'',
|
|
111
116
|
...fresh.flatMap((heard) => [...describe(heard, again(heard.requestId)), '']),
|
|
112
117
|
'Tell them in one line first (“You answered in Pending You: …”), then act.',
|
|
118
|
+
...(line ? [line] : []),
|
|
113
119
|
].join('\n')}\n`);
|
|
120
|
+
line = null;
|
|
114
121
|
await markHanded(io, origin, fresh.map((heard) => ({
|
|
115
122
|
requestId: heard.requestId,
|
|
116
123
|
moment: momentOf(heard),
|
|
@@ -122,11 +129,11 @@ export async function pickup(io, options) {
|
|
|
122
129
|
if (mode === 'pickup' && error instanceof SignInNeeded && error.ended)
|
|
123
130
|
io.out('Pending You: this computer’s pendingyou sign-in has ended, so answers can’t wake Claude Code. Ask your person to run `npx pendingyou login`.\n');
|
|
124
131
|
if (error instanceof SignInNeeded)
|
|
125
|
-
|
|
132
|
+
line = null;
|
|
126
133
|
return 0;
|
|
127
134
|
}
|
|
128
135
|
finally {
|
|
129
|
-
if (
|
|
136
|
+
if (line)
|
|
130
137
|
io.out(`${line}\n`);
|
|
131
138
|
}
|
|
132
139
|
}
|
package/dist/stopcheck.js
CHANGED
|
@@ -19,8 +19,16 @@ const POSTING_TOOL = /^mcp__.+__(post_request|update_request|reply_in_thread)$/;
|
|
|
19
19
|
* announces a card to come, not a step left in chat.
|
|
20
20
|
*/
|
|
21
21
|
const SAYS_POSTED = /\b(posted|put|added|raised|filed)\b[^.\n]{0,60}\b(cards?|pending ?you)\b|\bon (a|the|its|their|your|that|this) cards?\b|\b(in|to|through|via|on) pending ?you\b|\bpending ?you cards?\b|\b(i|we|you)(?:(?:'|’)ll| will)\s+(?:\w+\s+)?(post|send|put|add|raise|file|get|see|find)\b[^.\n]{0,40}\bcards?\b/i;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Steps only the person can take, said to them. Not "nothing (is) waiting on you" or "no cards … waiting on you" (a
|
|
24
|
+
* negation within the clause), nor "say if it needs you to …" (what another agent will say, if it ever does).
|
|
25
|
+
*/
|
|
26
|
+
const STEPS_FOR_THEM = /\b(you(?:'|’)ll need to|you will need to|you(?:'|’)ll have to|you will have to|your (?:next )?steps?\b|can you|could you|would you mind|please (?:run|confirm|approve|check|add|set|sign|create|open|paste|reply|send|choose|pick|decide|let me know)|in your terminal|only you can|(?<!\b(?:no|nothing|none|not)\b[^.,;:!?\n]{0,40})waiting (?:on|for) you|(?<!\bif (?:it|he|she|they|that|this|anything|something|someone|anyone|one) )needs? you to|over to you|parked for you|when you(?:'|’)re ready|up to you|your call\b|is yours\b|say the word|let me know (?:which|whether)|tell me (?:which|whether)|needs? your (?:ok|okay|approval|decision|sign-?off|go-ahead))/i;
|
|
27
|
+
/**
|
|
28
|
+
* Text that says nothing here needs a card: the "or say they don’t need one" the Stop reason offers ("No card
|
|
29
|
+
* needed.", "Nothing needs your attention.", "There’s nothing waiting on you.").
|
|
30
|
+
*/
|
|
31
|
+
const SAYS_NO_CARD = /\bno (?:new |more |further )?cards? (?:is |are )?(?:needed|necessary|required)\b|\b(?:doesn(?:'|’)t|don(?:'|’)t|does not|do not|won(?:'|’)t|will not) need (?:a |any )?(?:new )?cards?\b|\bnothing (?:here |else |more |now )?(?:needs|requires) your (?:attention|input|action|answer)\b|\b(?:nothing|no cards?)\b[^.,;:!?\n]{0,40}\b(?:waiting|pending) (?:on|for) you\b/i;
|
|
24
32
|
/** A heading over a list of things for the person. */
|
|
25
33
|
const FOR_THEM_HEADING = /\b(your part|your steps?|your to-?dos?|your turn|your call|for you|waiting on you|left for you|parked for you|needs you|over to you|what you need to do|you(?:'|’)ll need)\b/i;
|
|
26
34
|
const LIST_ITEM = /^\s*(?:\d+[.)]|[-*•]|\[ \])\s+\S/;
|
|
@@ -77,6 +85,8 @@ export function itemsForPerson(text) {
|
|
|
77
85
|
}
|
|
78
86
|
/** Whether the text says the items are already in Pending You. */
|
|
79
87
|
export const saysPosted = (text) => SAYS_POSTED.test(plainText(text));
|
|
88
|
+
/** Whether the text says nothing in it needs a card. */
|
|
89
|
+
export const saysNoCard = (text) => SAYS_NO_CARD.test(plainText(text));
|
|
80
90
|
/** A message from the person (not a tool's result): where a turn starts. */
|
|
81
91
|
function startsTurn(entry) {
|
|
82
92
|
if (entry.type !== 'user' || entry.isMeta || entry.message?.role !== 'user')
|
|
@@ -170,7 +180,7 @@ export async function stopcheck(io, options) {
|
|
|
170
180
|
const text = typeof input.last_assistant_message === 'string' && input.last_assistant_message.trim()
|
|
171
181
|
? input.last_assistant_message
|
|
172
182
|
: turn.text;
|
|
173
|
-
if (turn.posted || !text || saysPosted(text) || !itemsForPerson(text))
|
|
183
|
+
if (turn.posted || !text || saysPosted(text) || saysNoCard(text) || !itemsForPerson(text))
|
|
174
184
|
return 0;
|
|
175
185
|
io.out(`${JSON.stringify({ decision: 'block', reason: STOP_REASON })}\n`);
|
|
176
186
|
return 0;
|
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.7.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|