pendingyou 0.4.0 → 0.6.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 +30 -0
- package/README.md +5 -2
- package/dist/api.js +78 -10
- package/dist/args.js +6 -0
- package/dist/files.js +5 -1
- package/dist/format.js +30 -5
- package/dist/io.js +16 -0
- package/dist/main.js +59 -5
- package/dist/oauth.js +10 -7
- package/dist/pickup.js +25 -11
- package/dist/stopcheck.js +13 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Fixed: a hook could run past Claude Code's 10 seconds (`UserPromptSubmit hook … timed out after 10s — output
|
|
6
|
+
discarded`), mostly when the sign-in needed refreshing, which no time limit covered. Every hook (`pickup`,
|
|
7
|
+
`handoff`, `stopcheck`) now stops 3 seconds after it starts, printing only its plain fallback (the hand-off's
|
|
8
|
+
one-line reminder; nothing from the others, so the Stop check lets the turn end) and exiting 0. Requests give up
|
|
9
|
+
sooner too: 2.5 seconds for `pickup` (was 5), 2 for `handoff` with answers (was 2.5), 1.5 for open cards.
|
|
10
|
+
- A hook never refreshes the sign-in itself. Cut off halfway, a refresh can end the sign-in, so when the access token
|
|
11
|
+
runs out within 5 minutes a hook starts `pendingyou refresh` in the background, detached, which finishes under the
|
|
12
|
+
credentials lock however the hook ends (one at a time: none starts while another holds the lock). The hook goes on
|
|
13
|
+
with the token it has; one that has run out waits up to 1.2 seconds for the new one, then falls back.
|
|
14
|
+
- 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
|
|
15
|
+
taken over while it's still running.
|
|
16
|
+
- The Stop check no longer asks Claude Code to go on over "There's nothing waiting on you" or "no cards from me
|
|
17
|
+
waiting on you" (a negation before "waiting on you"), or "say if it needs you to download it" (what another agent
|
|
18
|
+
will say, if it ever does). A message that says nothing needs a card ("No card needed.", "Nothing needs your
|
|
19
|
+
attention.", "doesn't need a card") is taken at its word, as the Stop reason offers.
|
|
20
|
+
|
|
21
|
+
## 0.5.0
|
|
22
|
+
|
|
23
|
+
- A card with several questions (guide 2.6) prints each answer on its own line when `hold` ends (and when a hook
|
|
24
|
+
hands the answer over): `1. Pay Kaiser’s invoice 09/30 for $1,200? (pay): Approved`, numbered in the card's order,
|
|
25
|
+
with the question's id as `answer.answers` has it. A question your person split out into a card of its own is named
|
|
26
|
+
with its new request id: hear its answer there.
|
|
27
|
+
- When an answer is ready, the line telling Claude Code to call `ack_answer` (or `reply_in_thread`) now names the
|
|
28
|
+
session too (`name “billing-webhooks”`): since guide 2.5 the sessions sharing a computer's sign-in each act only on
|
|
29
|
+
their own cards, and pass their name to do so.
|
|
30
|
+
- The next-message hook's open-cards line names the session that asked each card (`(req_…, by dns-setup)`, from
|
|
31
|
+
servers on guide 2.5, cut to 24 characters) and says to cancel only one of yours, with `answeredHere` and your name.
|
|
32
|
+
|
|
3
33
|
## 0.4.0
|
|
4
34
|
|
|
5
35
|
- The next-message hook's reminder now names this folder's open cards still waiting on you, so a question you answer
|
package/README.md
CHANGED
|
@@ -20,7 +20,9 @@ Run it once on each computer. It:
|
|
|
20
20
|
Your other settings are kept as they are. `npx pendingyou uninstall` removes exactly what `init` added.
|
|
21
21
|
|
|
22
22
|
The hooks never get in your way: if anything goes wrong (no network, not signed in, a bad option), they print nothing
|
|
23
|
-
and let your message through.
|
|
23
|
+
and let your message through. Each one stops within 3 seconds whatever happens, with at most its one-line reminder, and
|
|
24
|
+
never refreshes the sign-in itself: when that's due, it starts the refresh in the background, which finishes on its
|
|
25
|
+
own. Running `init` again updates them in place, and adds any a newer version brings.
|
|
24
26
|
|
|
25
27
|
## Nothing left only in chat
|
|
26
28
|
|
|
@@ -42,7 +44,8 @@ The check is pattern matching on your computer: no model, no network. It reads o
|
|
|
42
44
|
transcript (at most 4 MB) and skips code blocks and quotes. It counts as for you: a question to you (a sentence ending
|
|
43
45
|
in "?" with "you" or "your"); "you'll need to", "your step", "can you", "please run", "in your terminal", "only you
|
|
44
46
|
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".
|
|
47
|
+
you" or "Left for you". It lets the turn end when the message says nothing needs a card ("No card needed.", "Nothing
|
|
48
|
+
needs your attention.", "There's nothing waiting on you.").
|
|
46
49
|
|
|
47
50
|
**On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
|
|
48
51
|
`npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
|
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 = {
|
|
@@ -64,6 +65,7 @@ const FLAGS = {
|
|
|
64
65
|
init: { origin: true, 'no-login': false, 'no-browser': false, device: 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 {
|
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/format.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
const MAX_ANSWER = 600;
|
|
5
5
|
const MAX_MESSAGE = 400;
|
|
6
6
|
const MAX_TITLE = 140;
|
|
7
|
+
/** A grouped card's question and its answer, each: four of them stay well under one long answer. */
|
|
8
|
+
const MAX_QUESTION = 100;
|
|
9
|
+
const MAX_QUESTION_ANSWER = 300;
|
|
7
10
|
/** Cuts text to `max` characters on one line, and says so. */
|
|
8
11
|
export function clip(text, max) {
|
|
9
12
|
const flat = text.replace(/\s+/g, ' ').trim();
|
|
@@ -22,6 +25,11 @@ const quoted = (text, max) => {
|
|
|
22
25
|
const { text: clipped, cut } = clip(text, max);
|
|
23
26
|
return { line: `“${clipped}”`, cut };
|
|
24
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* The session's name, as it asked (its session label): the card tools take it, and need it where other sessions share
|
|
30
|
+
* the sign-in (guide 2.5).
|
|
31
|
+
*/
|
|
32
|
+
const named = (heard) => `“${clip(heard.sessionLabel, MAX_TITLE).text}”`;
|
|
25
33
|
/** Closed for good: nothing more to wait for. */
|
|
26
34
|
export const isClosed = (heard) => heard.status === 'answered' ||
|
|
27
35
|
heard.status === 'resolved' ||
|
|
@@ -40,12 +48,24 @@ export function describe(heard, again) {
|
|
|
40
48
|
const lines = [];
|
|
41
49
|
switch (heard.status) {
|
|
42
50
|
case 'answered': {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
if (heard.answers?.length) {
|
|
52
|
+
// One line per question, so each answer reads against its own question (and its id, as answer.answers has it).
|
|
53
|
+
lines.push(`Pending You: ${id} is answered, all ${heard.answers.length} questions.`, question);
|
|
54
|
+
for (const [index, each] of heard.answers.entries()) {
|
|
55
|
+
const asked = clip(each.title, MAX_QUESTION);
|
|
56
|
+
const answer = clip(each.said, MAX_QUESTION_ANSWER);
|
|
57
|
+
cut ||= asked.cut || answer.cut;
|
|
58
|
+
lines.push(`${index + 1}. ${asked.text} (${each.questionId}): ${answer.text}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const answer = clip(heard.said ?? 'Answered', MAX_ANSWER);
|
|
63
|
+
cut ||= answer.cut;
|
|
64
|
+
lines.push(`Pending You: ${id} is answered.`, question, `Answer: ${answer.text}`);
|
|
65
|
+
}
|
|
46
66
|
if (wrote.length)
|
|
47
67
|
lines.push(`They also wrote: ${wrote.join(' ')}`);
|
|
48
|
-
lines.push(`Act on it, then call ack_answer with requestId ${id}, expectedVersion ${heard.version} and a one-line outcome.`);
|
|
68
|
+
lines.push(`Act on it, then call ack_answer with requestId ${id}, name ${named(heard)}, expectedVersion ${heard.version} and a one-line outcome.`);
|
|
49
69
|
break;
|
|
50
70
|
}
|
|
51
71
|
case 'expired':
|
|
@@ -59,12 +79,17 @@ export function describe(heard, again) {
|
|
|
59
79
|
break;
|
|
60
80
|
default:
|
|
61
81
|
if (heard.turn === 'agent') {
|
|
62
|
-
lines.push(`Pending You: they wrote back on ${id}.`, question, `They wrote: ${wrote.join(' ') || '(a follow-up; call get_request to read it)'}`, `Answer with reply_in_thread (requestId ${id}), then ${again}.`);
|
|
82
|
+
lines.push(`Pending You: they wrote back on ${id}.`, question, `They wrote: ${wrote.join(' ') || '(a follow-up; call get_request to read it)'}`, `Answer with reply_in_thread (requestId ${id}, name ${named(heard)}), then ${again}.`);
|
|
63
83
|
}
|
|
64
84
|
else {
|
|
65
85
|
lines.push(`Pending You: ${id} is still waiting for them (${heard.status}).`, question);
|
|
66
86
|
}
|
|
67
87
|
}
|
|
88
|
+
// Questions split out of a grouped card are cards of their own now, with answers of their own.
|
|
89
|
+
if (heard.splits?.length && heard.status !== 'cancelled' && heard.status !== 'resolved')
|
|
90
|
+
lines.push(`They split ${heard.splits
|
|
91
|
+
.map((split) => `“${clip(split.title, MAX_QUESTION).text}” (${split.requestId})`)
|
|
92
|
+
.join(', ')} out into ${heard.splits.length === 1 ? 'a card of its own' : 'cards of their own'}: hear ${heard.splits.length === 1 ? 'its answer' : 'their answers'} there.`);
|
|
68
93
|
if (cut)
|
|
69
94
|
lines.push(`(Cut short: get_request ${id} has all of it.)`);
|
|
70
95
|
return lines;
|
package/dist/io.js
CHANGED
|
@@ -105,6 +105,20 @@ async function openBrowser(url) {
|
|
|
105
105
|
const result = await runProgram(command, args, 10_000);
|
|
106
106
|
return result.code === 0;
|
|
107
107
|
}
|
|
108
|
+
function background(script, args) {
|
|
109
|
+
if (!script)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
const child = spawn(process.execPath, [script, ...args], {
|
|
113
|
+
detached: true,
|
|
114
|
+
stdio: 'ignore',
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
child.on('error', () => { });
|
|
118
|
+
child.unref();
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
}
|
|
108
122
|
export function realIo() {
|
|
109
123
|
const controller = new AbortController();
|
|
110
124
|
// The first signal lets the command finish cleanly (a hold says how the answer will still arrive); a second one
|
|
@@ -132,5 +146,7 @@ export function realIo() {
|
|
|
132
146
|
sleep,
|
|
133
147
|
signal: controller.signal,
|
|
134
148
|
script: process.argv[1] ?? '',
|
|
149
|
+
uptime: () => process.uptime() * 1000,
|
|
150
|
+
background: (args) => background(process.argv[1] ?? '', args),
|
|
135
151
|
};
|
|
136
152
|
}
|
package/dist/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
3
|
+
import { getJson, refreshInBackground, SignInNeeded, Unavailable } from "./api.js";
|
|
4
4
|
import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
|
|
5
5
|
import { claudeState, installClaude, uninstallClaude } from "./claude.js";
|
|
6
6
|
import { readCredential, signedInOrigins, updateCredential } from "./credentials.js";
|
|
@@ -116,7 +116,7 @@ async function uninstall(io, origin) {
|
|
|
116
116
|
await logout(io, { origin, all: false });
|
|
117
117
|
return steps.every((step) => step.ok) ? 0 : 1;
|
|
118
118
|
}
|
|
119
|
-
async function run(io, command) {
|
|
119
|
+
async function run(io, command, hook = { fallback: '' }) {
|
|
120
120
|
switch (command.name) {
|
|
121
121
|
case 'help':
|
|
122
122
|
io.out(USAGE);
|
|
@@ -133,9 +133,9 @@ async function run(io, command) {
|
|
|
133
133
|
case 'hold':
|
|
134
134
|
return hold(io, command);
|
|
135
135
|
case 'pickup':
|
|
136
|
-
return pickup(io, { origin: command.origin, mode: 'pickup' });
|
|
136
|
+
return pickup(io, { origin: command.origin, mode: 'pickup' }, hook);
|
|
137
137
|
case 'handoff':
|
|
138
|
-
return pickup(io, { origin: command.origin, mode: 'handoff' });
|
|
138
|
+
return pickup(io, { origin: command.origin, mode: 'handoff' }, hook);
|
|
139
139
|
case 'stopcheck':
|
|
140
140
|
return stopcheck(io, { origin: command.origin });
|
|
141
141
|
case 'init':
|
|
@@ -144,6 +144,60 @@ async function run(io, command) {
|
|
|
144
144
|
return uninstall(io, command.origin);
|
|
145
145
|
case 'watch':
|
|
146
146
|
return watch(io, command);
|
|
147
|
+
case 'refresh':
|
|
148
|
+
// Detached and quiet: nobody reads what it prints, and the next hook sees the result in the credentials file.
|
|
149
|
+
try {
|
|
150
|
+
await refreshInBackground(io, command.origin, command.force);
|
|
151
|
+
}
|
|
152
|
+
catch { }
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A hook's hard deadline, from the moment this process started: Claude Code gives a hook 10 seconds (init's
|
|
158
|
+
* `timeout`), npx takes about half a second before this process starts, and a normal hook is done in about one.
|
|
159
|
+
* When it's reached, the hook prints its plain fallback (the hand-off's one-line reminder; nothing for pickup and
|
|
160
|
+
* stopcheck) unless it already printed, and exits 0, whatever it was still waiting on.
|
|
161
|
+
*/
|
|
162
|
+
export const HOOK_DEADLINE_MS = 3000;
|
|
163
|
+
async function runHook(io, command) {
|
|
164
|
+
const hook = { fallback: '' };
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const stop = () => controller.abort();
|
|
167
|
+
if (io.signal.aborted)
|
|
168
|
+
stop();
|
|
169
|
+
io.signal.addEventListener('abort', stop, { once: true });
|
|
170
|
+
let finished = false;
|
|
171
|
+
let printed = false;
|
|
172
|
+
const hookIo = {
|
|
173
|
+
...io,
|
|
174
|
+
out: (text) => {
|
|
175
|
+
if (finished)
|
|
176
|
+
return;
|
|
177
|
+
printed = true;
|
|
178
|
+
io.out(text);
|
|
179
|
+
},
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
};
|
|
182
|
+
let timer;
|
|
183
|
+
const deadline = new Promise((resolve) => {
|
|
184
|
+
timer = setTimeout(() => resolve('deadline'), Math.max(0, HOOK_DEADLINE_MS - io.uptime()));
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const outcome = await Promise.race([run(hookIo, command, hook), deadline]);
|
|
188
|
+
if (outcome !== 'deadline')
|
|
189
|
+
return outcome;
|
|
190
|
+
finished = true;
|
|
191
|
+
controller.abort();
|
|
192
|
+
if (!printed && hook.fallback)
|
|
193
|
+
io.out(hook.fallback);
|
|
194
|
+
io.err('pendingyou: Pending You took too long; skipped this hook.\n');
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
finished = true;
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
io.signal.removeEventListener('abort', stop);
|
|
147
201
|
}
|
|
148
202
|
}
|
|
149
203
|
/**
|
|
@@ -174,7 +228,7 @@ export async function main(argv, io) {
|
|
|
174
228
|
if (hook && !HOOK_COMMANDS.has(command.name))
|
|
175
229
|
return 0;
|
|
176
230
|
try {
|
|
177
|
-
return await run(io, command);
|
|
231
|
+
return hook ? await runHook(io, command) : await run(io, command);
|
|
178
232
|
}
|
|
179
233
|
catch (error) {
|
|
180
234
|
// 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,17 +10,21 @@
|
|
|
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. */
|
|
22
24
|
export const OPEN_SHOWN = 3;
|
|
23
25
|
export const OPEN_TITLE = 50;
|
|
26
|
+
/** How much of the asking session's name each card shows. */
|
|
27
|
+
export const OPEN_NAME = 24;
|
|
24
28
|
/**
|
|
25
29
|
* The reminder, naming this folder's open cards still waiting on the person when there are any (about 45 tokens plus
|
|
26
30
|
* 20 a card): `… Your open cards here: "Which DNS host?" (req_…); … +2 more. If this message answers …`.
|
|
@@ -40,9 +44,14 @@ export function openLine(open) {
|
|
|
40
44
|
return REMINDER;
|
|
41
45
|
const more = typeof total === 'number' && total > cards.length ? total - cards.length : 0;
|
|
42
46
|
const named = cards
|
|
43
|
-
.map((card) =>
|
|
47
|
+
.map((card) => {
|
|
48
|
+
const by = typeof card.name === 'string' && card.name.trim()
|
|
49
|
+
? `, by ${clip(card.name.replaceAll('"', "'"), OPEN_NAME).text}`
|
|
50
|
+
: '';
|
|
51
|
+
return `"${clip(card.title.replaceAll('"', "'"), OPEN_TITLE).text}" (${card.requestId}${by})`;
|
|
52
|
+
})
|
|
44
53
|
.join('; ');
|
|
45
|
-
return `${REMINDER} Your open cards here: ${named}${more ? ` +${more} more` : ''}. If this message answers or settles one, cancel_request it with answeredHere (or update_request if it changed).`;
|
|
54
|
+
return `${REMINDER} Your open cards here: ${named}${more ? ` +${more} more` : ''}. If this message answers or settles one of yours, cancel_request it with answeredHere and your name (or update_request if it changed).`;
|
|
46
55
|
}
|
|
47
56
|
/** The folder Claude Code says the session is in (the hook's stdin), or where this runs. */
|
|
48
57
|
async function sessionFolder(io) {
|
|
@@ -63,18 +72,19 @@ export function folderForms(cwd, home) {
|
|
|
63
72
|
forms.push(`~${cwd.slice(base.length)}`);
|
|
64
73
|
return forms;
|
|
65
74
|
}
|
|
66
|
-
export async function pickup(io, options) {
|
|
75
|
+
export async function pickup(io, options, hook = { fallback: '' }) {
|
|
67
76
|
const { origin, mode } = options;
|
|
68
|
-
|
|
69
|
-
let line =
|
|
77
|
+
// The hand-off's line, printed at the end unless something else was printed with it.
|
|
78
|
+
let line = null;
|
|
70
79
|
try {
|
|
71
80
|
const cwd = await sessionFolder(io);
|
|
72
81
|
// The hand-off hears answers only while a hold from this computer is outstanding; it always asks for open cards.
|
|
73
82
|
let answers = true;
|
|
74
83
|
if (mode === 'handoff') {
|
|
75
|
-
|
|
76
|
-
if (!remind)
|
|
84
|
+
if ((await readCredential(io, origin)) === null)
|
|
77
85
|
return 0;
|
|
86
|
+
line = REMINDER;
|
|
87
|
+
hook.fallback = `${REMINDER}\n`;
|
|
78
88
|
answers = (await waitingOn(io, origin)).length > 0;
|
|
79
89
|
}
|
|
80
90
|
const query = new URLSearchParams([
|
|
@@ -82,7 +92,7 @@ export async function pickup(io, options) {
|
|
|
82
92
|
['limit', answers ? '5' : '0'],
|
|
83
93
|
...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
|
|
84
94
|
]);
|
|
85
|
-
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 });
|
|
86
96
|
if (status !== 200)
|
|
87
97
|
return 0;
|
|
88
98
|
if (mode === 'handoff')
|
|
@@ -96,6 +106,8 @@ export async function pickup(io, options) {
|
|
|
96
106
|
if (fresh.length === 0)
|
|
97
107
|
return 0;
|
|
98
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.
|
|
99
111
|
io.out(`${[
|
|
100
112
|
mode === 'pickup'
|
|
101
113
|
? 'Pending You: your person answered while no session was listening.'
|
|
@@ -103,7 +115,9 @@ export async function pickup(io, options) {
|
|
|
103
115
|
'',
|
|
104
116
|
...fresh.flatMap((heard) => [...describe(heard, again(heard.requestId)), '']),
|
|
105
117
|
'Tell them in one line first (“You answered in Pending You: …”), then act.',
|
|
118
|
+
...(line ? [line] : []),
|
|
106
119
|
].join('\n')}\n`);
|
|
120
|
+
line = null;
|
|
107
121
|
await markHanded(io, origin, fresh.map((heard) => ({
|
|
108
122
|
requestId: heard.requestId,
|
|
109
123
|
moment: momentOf(heard),
|
|
@@ -115,11 +129,11 @@ export async function pickup(io, options) {
|
|
|
115
129
|
if (mode === 'pickup' && error instanceof SignInNeeded && error.ended)
|
|
116
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');
|
|
117
131
|
if (error instanceof SignInNeeded)
|
|
118
|
-
|
|
132
|
+
line = null;
|
|
119
133
|
return 0;
|
|
120
134
|
}
|
|
121
135
|
finally {
|
|
122
|
-
if (
|
|
136
|
+
if (line)
|
|
123
137
|
io.out(`${line}\n`);
|
|
124
138
|
}
|
|
125
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.6.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|