pendingyou 0.7.0 → 0.9.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 +40 -0
- package/README.md +18 -4
- package/dist/confirm.js +231 -0
- package/dist/environment.js +98 -0
- package/dist/main.js +16 -3
- package/dist/pickup.js +15 -3
- package/dist/stopcheck.js +87 -8
- package/dist/version.js +1 -1
- package/package.json +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
- The Stop check catches a final message that asks you to type or reply something in Claude Code's own chat
|
|
6
|
+
("Reply “yes, erase disk12” here", "To confirm, type `yes`", "Confirm here"). Some steps an agent takes only on your
|
|
7
|
+
own words in its conversation, not on a card answer (something that can't be undone, credentials, anything outside
|
|
8
|
+
its sandbox), and that's right; but when it asks only in its chat, you never learn where you're needed. Unless a
|
|
9
|
+
Pending You card posted in that turn, or one still open from this folder, asks for the same thing, Claude Code is
|
|
10
|
+
asked once to post an action card that sends you there (about 76 tokens, plus the link): titled “Confirm in Claude
|
|
11
|
+
Code on <computer>: <step>”, with the exact words as a step's command (so the card shows them with a copy button),
|
|
12
|
+
where to type them, and what it'll do then; or, for a card you already answered, to reopen it with the same ask.
|
|
13
|
+
- The link is the session's own when Claude Code says it: `https://claude.ai/code/<id>` from
|
|
14
|
+
`CLAUDE_CODE_BRIDGE_SESSION_ID` while Remote Control is on, or from `CLAUDE_CODE_REMOTE_SESSION_ID` on the web.
|
|
15
|
+
- It counts as an ask: an imperative or a request to you ("please …", "can you …", "I need you to …") with the words
|
|
16
|
+
quoted, in backticks, or a plain "yes", tied to this chat ("here", "in this chat") or to going ahead ("to confirm",
|
|
17
|
+
"and I'll …"). Not in code, block quotes or quoted text; not what a program, prompt, page or message says; not a step
|
|
18
|
+
in your terminal, at a prompt or on a page; not a standing offer ("anytime"); not a command or slash command. A card
|
|
19
|
+
matches when its words name what's being confirmed ("erase" and "disk12"), or, with only a "yes" to go on, when it
|
|
20
|
+
was posted in the turn or its title asks to confirm.
|
|
21
|
+
- Only such a message costs a request: this folder's open cards (the same one the next-message hook makes), given up
|
|
22
|
+
after 1.5 seconds. When Pending You can't say (offline, slow, an older server), the message's own word that it's on
|
|
23
|
+
a card is taken. Everything else the Stop check does is as before, with no request.
|
|
24
|
+
- Hooks still run this version from `~/.config/pendingyou/cli/0.9.0/`: run `npx pendingyou@latest init` once to switch.
|
|
25
|
+
|
|
26
|
+
## 0.8.0
|
|
27
|
+
|
|
28
|
+
- The hooks say when a session is on another Pending You. A Claude Code session stayed connected to staging after its
|
|
29
|
+
person moved to production, so its cards went where they no longer looked. Now both hooks (`pickup` at session
|
|
30
|
+
start, `handoff` with every message) compare the `pendingyou` MCP server Claude Code uses in the session's folder
|
|
31
|
+
with the origin they run against (`--origin`), and when they're different sites add one line (about 45 tokens):
|
|
32
|
+
`Pending You: your MCP server is https://staging.pendingyou.com but your hooks and sign-in are
|
|
33
|
+
https://www.pendingyou.com, so your cards and their answers may not meet. Ask your person which one they use, and
|
|
34
|
+
restart the session after fixing it (npx pendingyou init --origin <that one>).` The server is the one Claude Code
|
|
35
|
+
would use there: local scope (`~/.claude.json`'s `projects`), then the folder's `.mcp.json`, then user scope, then
|
|
36
|
+
the Pending You plugin when `~/.claude/settings.json` enables it (production's server). All read only, and only the
|
|
37
|
+
`pendingyou` server's address is looked at; nothing is sent. `pendingyou.com` and `www.pendingyou.com` are the same
|
|
38
|
+
site; a file that can't be read is never a mismatch. The line is said even when the hooks' origin isn't signed in,
|
|
39
|
+
and it's kept when a hook reaches its 3-second deadline.
|
|
40
|
+
- `status` says the same: a `Mismatch:` line naming both sites and what to do, a line for a folder's own
|
|
41
|
+
(local- or project-scope) server that points elsewhere, and "Not ready" (exit 1) until they agree.
|
|
42
|
+
|
|
3
43
|
## 0.7.0
|
|
4
44
|
|
|
5
45
|
- Faster hooks. They ran `npx -y --prefer-offline pendingyou@<version> …`, and npx alone took about half a second
|
package/README.md
CHANGED
|
@@ -26,13 +26,18 @@ that. If Claude Code already has a `pendingyou` server pointing somewhere else (
|
|
|
26
26
|
asks before replacing it; `--yes` replaces it without asking, and with nobody to ask it prints the two commands to
|
|
27
27
|
run. Afterwards restart Claude Code, run `/mcp`, choose pendingyou and Authenticate.
|
|
28
28
|
|
|
29
|
+
If the `pendingyou` MCP server Claude Code uses in a session's folder is another Pending You than the hooks' (staging
|
|
30
|
+
and production, say), both hooks add one line telling Claude Code so, and to ask you which one you use: its cards
|
|
31
|
+
would otherwise go where you don't look. `npx pendingyou status` flags it too. Fix it with `npx pendingyou init
|
|
32
|
+
--origin <the one you use>`, then restart the session.
|
|
33
|
+
|
|
29
34
|
The hooks never get in your way: if anything goes wrong (no network, not signed in, a bad option), they print nothing
|
|
30
35
|
and let your message through. Each one stops within 3 seconds whatever happens, with at most its one-line reminder, and
|
|
31
36
|
never refreshes the sign-in itself: when that's due, it starts the refresh in the background, which finishes on its
|
|
32
37
|
own. Running `init` again updates them in place, and adds any a newer version brings.
|
|
33
38
|
|
|
34
39
|
The hooks run that copy with Node directly, like
|
|
35
|
-
`"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.
|
|
40
|
+
`"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.9.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
|
|
36
41
|
paths in full). If the copy can't be made (no npm, no network), they run through `npx -y --prefer-offline
|
|
37
42
|
pendingyou@<version>` instead. `npx pendingyou status` says which. If you remove the Node they run with, run `init`
|
|
38
43
|
again.
|
|
@@ -53,13 +58,21 @@ Deep into a long conversation, Claude Code tends to end a turn with a question f
|
|
|
53
58
|
Claude Code is asked to go on once (about 31 tokens): post them as cards, or say they don't need one. It never asks
|
|
54
59
|
twice in a row.
|
|
55
60
|
|
|
56
|
-
The check is pattern matching on your computer: no model
|
|
61
|
+
The check is pattern matching on your computer: no model. It reads only the last turn from the end of the
|
|
57
62
|
transcript (at most 4 MB) and skips code blocks and quotes. It counts as for you: a question to you (a sentence ending
|
|
58
63
|
in "?" with "you" or "your"); "you'll need to", "your step", "can you", "please run", "in your terminal", "only you
|
|
59
64
|
can", "waiting on you" and the like; or a list under a heading like "Your part", "Your steps", "For you", "Waiting on
|
|
60
65
|
you" or "Left for you". It lets the turn end when the message says nothing needs a card ("No card needed.", "Nothing
|
|
61
66
|
needs your attention.", "There's nothing waiting on you.").
|
|
62
67
|
|
|
68
|
+
Since 0.9.0 it also catches a message asking you to type or reply something in Claude Code's own chat ("Reply “yes,
|
|
69
|
+
erase disk12” here", "To confirm, type `yes`", "Confirm here"). Some steps an agent takes only on your own words in
|
|
70
|
+
its conversation, never on a card answer; that's fine, but then a card has to send you there. Unless a card posted in
|
|
71
|
+
that turn, or one still open from this folder, asks for the same thing, Claude Code is asked once to post an action
|
|
72
|
+
card titled “Confirm in Claude Code on <computer>: <step>”, with the exact words to copy, where to type them (the
|
|
73
|
+
session's link when Remote Control or Claude Code on the web gives one) and what it'll do then. Only such a message
|
|
74
|
+
costs a request: this folder's open cards, given up after 1.5 seconds.
|
|
75
|
+
|
|
63
76
|
**On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
|
|
64
77
|
`npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
|
|
65
78
|
|
|
@@ -125,8 +138,9 @@ The sign-in only hears answers to your own Claude Code assistants' questions, or
|
|
|
125
138
|
answer or change anything, and it doesn't show up as one. It's stored in `~/.config/pendingyou/credentials.json`
|
|
126
139
|
(readable only by you), refreshes itself, and ends after 30 days or on `logout`. `state.json` beside it remembers
|
|
127
140
|
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`
|
|
129
|
-
|
|
141
|
+
of the command line. `init`, `status` and the hooks read `~/.claude.json` (and a folder's `.mcp.json`, and
|
|
142
|
+
`~/.claude/settings.json`'s enabled plugins) only to see whether Claude Code has a `pendingyou` MCP server and where
|
|
143
|
+
it points. Nothing is sent anywhere but Pending You.
|
|
130
144
|
|
|
131
145
|
## Development
|
|
132
146
|
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// "Confirm in your agent" (guide 2.21, CLI 0.9.0). Some steps an agent takes only on its person's own words in its
|
|
2
|
+
// conversation: a card answer reaches it as a tool's result, and its rules (or its app's) want more for something that
|
|
3
|
+
// can't be undone, credentials, or anything outside its sandbox. That's right, but on 2026-10-03 a Claude Code agent
|
|
4
|
+
// declined to act on an approved "Erase disk12?" card, asked for "yes, erase disk12" in its own chat, and never told
|
|
5
|
+
// Pending You, so Darin had to go and find the session.
|
|
6
|
+
//
|
|
7
|
+
// The Stop hook (stopcheck.ts) now catches a final message that asks the person to type or reply something in this
|
|
8
|
+
// chat: asksToTypeHere finds it, cardFits says whether a card already asks for it (one posted in the turn, or one still
|
|
9
|
+
// open), and confirmReason is what Claude Code reads when none does: post an action card that sends them here, with
|
|
10
|
+
// the exact words as a step's command and, when Claude Code says it, the session's link.
|
|
11
|
+
//
|
|
12
|
+
// Pattern matching only, deliberately narrow (a block costs a whole extra turn): an ask said to the person (an
|
|
13
|
+
// imperative, "please …", "can you …", "I need you to …"), with the words quoted or a plain "yes", and anchored to this
|
|
14
|
+
// chat ("here", "in this chat") or to going ahead ("to confirm", "and I'll …"). Never in code, block quotes or quoted
|
|
15
|
+
// text; never what a program, a page or a message says; never a step somewhere else (their terminal, a prompt, a page,
|
|
16
|
+
// a link) or a standing offer ("anytime"); never a command or slash command to run.
|
|
17
|
+
/** Verbs that hand the person something to type. */
|
|
18
|
+
const VERB = '(?:type|reply|respond|write|say|send|enter|paste|answer)';
|
|
19
|
+
/**
|
|
20
|
+
* What comes right before a verb said to the person: the start of a sentence or clause, "and"/"then", a request
|
|
21
|
+
* ("can you", "I need you to", "I'll wait for you to", "you'll need to", "you can"), then any "please", "just" or
|
|
22
|
+
* "now".
|
|
23
|
+
*/
|
|
24
|
+
const LEAD = String.raw `(?:^|[,;:(—–→]\s*|\s-\s+|\b(?:and|then|or|so)\s+|\b(?:can|could|would|will)\s+you\s+|\b(?:need|want|like|for)\s+you\s+to\s+|\byou(?:’|')ll\s+(?:need|have)\s+to\s+|\byou\s+(?:need|have)\s+to\s+|\byou\s+can\s+|\ball\s+you\s+need\s+to\s+do\s+is\s+)(?:(?:please|just|simply|kindly|now|then)\s+)*`;
|
|
25
|
+
const ASK = new RegExp(`${LEAD}${VERB}\\b`, 'giu');
|
|
26
|
+
/** "Confirm here", "please confirm in this chat", "can you confirm it here". */
|
|
27
|
+
const CONFIRM_HERE = new RegExp(`${LEAD}confirm(?:\\s+(?:it|this|that|so))?\\s+(?:here|below|in\\s+(?:this|our|the)\\s+(?:chat|conversation|session))\\b`, 'iu');
|
|
28
|
+
/** "Confirm (in this chat) by typing “…”". */
|
|
29
|
+
const CONFIRM_BY = /\bconfirm\b[^.!?\n]{0,30}?\bby\s+(?:typing|replying(?:\s+with)?|saying|sending)\s+⟦(\d+)⟧/iu;
|
|
30
|
+
/** This chat: where the words go. */
|
|
31
|
+
const HERE = /\b(?:here|below|in\s+(?:this|our|the)\s+(?:chat|conversation|session)|in\s+chat|in\s+this\s+(?:terminal|window)|(?:back\s+)?to\s+me)\b/iu;
|
|
32
|
+
/** What typing it does: lets the agent go ahead. */
|
|
33
|
+
const PURPOSE = /\b(?:to\s+(?:confirm|proceed|continue|go\s+ahead|approve|start|begin|carry\s+on|unblock\s+me)|(?:and|so|then)\s+I(?:’|')ll\b|(?:and|so)\s+I\s+(?:can|will)\b|if\s+you\s+want\s+me\s+to\b|when\s+you(?:’|')re\s+ready\b|before\s+I\b)/iu;
|
|
34
|
+
/** Plain words people are asked to type without quotes. */
|
|
35
|
+
const PLAIN_WORD = /^\s*(?:back\s+)?(?:with\s+)?(go ahead|ship it|do it|yes|y|ok|okay|go|proceed|confirm|confirmed|continue|approve|approved)\b/iu;
|
|
36
|
+
/**
|
|
37
|
+
* What may come between the verb and the quoted words: "Reply here with “…”", "Type these words in this chat: “…”",
|
|
38
|
+
* "Reply with the exact phrase “…”". Never a recipient ("Reply to Sarah with “…”").
|
|
39
|
+
*/
|
|
40
|
+
const FILLER = /^(?:\s|[,:]|back\b|me\b|it\b|to me\b|with\b|exactly\b|precisely\b|verbatim\b|just\b|the\b|exact\b|following\b|phrase\b|words?\b|text\b|these\b|this\b|that\b|here\b|below\b|in (?:this|our) (?:chat|conversation|session|terminal|window)\b)*$/iu;
|
|
41
|
+
/** Somewhere else to type it: their terminal, a prompt, a page or a form, or a link (but this session's own). */
|
|
42
|
+
const ELSEWHERE = /\b(?:in|into|at|on)\s+(?:your|a|the|that|their)\s+(?:terminal|shell|browser|console|prompt|form|field|dialog|email|inbox|page|app|site|website|portal|dashboard)\b|\bin\s+(?:Terminal|iTerm)\b|\bwhen\s+(?:prompted|asked|it\s+asks)\b|\bat\s+the\s+prompt\b|https?:\/\/(?!claude\.ai\/code\/)/iu;
|
|
43
|
+
/** This chat, said outright, which wins over another place in the same sentence. */
|
|
44
|
+
const THIS_CHAT = /\bin\s+(?:this|our)\s+(?:chat|conversation|session)\b/iu;
|
|
45
|
+
/** A standing offer, not a wait: "Say “stop” anytime". */
|
|
46
|
+
const OFFER = /\b(?:any\s?time|whenever|at\s+any\s+(?:point|time)|if\s+you\s+change\s+your\s+mind|if\s+(?:anything|something)\s+(?:looks|seems|goes))\b/iu;
|
|
47
|
+
/** What something says rather than an ask: "The email ends with: Reply “STOP” here". */
|
|
48
|
+
const DESCRIBED = /\b(?:says?|said|reads?|ends?\s+with|starts?\s+with|shows?|prints?|displays?|asks?|prompts?|tells?\s+\w+|text|message|copy|label|button|template)\s*[:,]?$/iu;
|
|
49
|
+
/** A command or a slash command: a step to run (the general check's business), not words to type to the agent. */
|
|
50
|
+
const COMMAND_LIKE = /^\s*(?:[!/]|\$\s|(?:cd|npm|npx|pnpm|yarn|bun|git|gh|curl|wget|brew|node|python3?|pip|docker|ssh|scp|sudo|claude|codex|wrangler)\s)/u;
|
|
51
|
+
/** Quoted spans the words come in: “…”, "…", `…`, ‘…’ and '…' (not an apostrophe). */
|
|
52
|
+
const QUOTED = /“([^“”\n]{1,160})”|"([^"\n]{1,160})"|`([^`\n]{1,160})`|‘([^‘’\n]{1,160})’|(?<![\p{L}\p{N}])'([^'\n]{1,160})'(?![\p{L}\p{N}])/gu;
|
|
53
|
+
/**
|
|
54
|
+
* The text as it reads to the person: without fenced code and block quotes (quoted words stay), and without Markdown's
|
|
55
|
+
* list markers, headings and emphasis (`**Reply “yes” here**`).
|
|
56
|
+
*/
|
|
57
|
+
export function proseOf(text) {
|
|
58
|
+
const kept = [];
|
|
59
|
+
let fence = null;
|
|
60
|
+
for (const line of text.split('\n')) {
|
|
61
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
62
|
+
if (fence) {
|
|
63
|
+
if (marker?.startsWith(fence))
|
|
64
|
+
fence = null;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (marker) {
|
|
68
|
+
fence = marker;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^\s*>/.test(line))
|
|
72
|
+
continue;
|
|
73
|
+
kept.push(line
|
|
74
|
+
.replace(/^\s*(?:[-*•+]|\d+[.)])\s+/u, '')
|
|
75
|
+
.replace(/^\s*#+\s*/u, '')
|
|
76
|
+
.replace(/\*+|__/gu, ''));
|
|
77
|
+
}
|
|
78
|
+
return kept.join('\n');
|
|
79
|
+
}
|
|
80
|
+
/** Each quoted span replaced by ⟦n⟧, with what it held. */
|
|
81
|
+
function marked(text) {
|
|
82
|
+
const quotes = [];
|
|
83
|
+
const out = text.replace(QUOTED, (...groups) => {
|
|
84
|
+
const inner = groups.slice(1, 6).find((group) => typeof group === 'string');
|
|
85
|
+
quotes.push(inner.trim());
|
|
86
|
+
return `⟦${quotes.length - 1}⟧`;
|
|
87
|
+
});
|
|
88
|
+
return { text: out, quotes };
|
|
89
|
+
}
|
|
90
|
+
/** One sentence's ask, or null. `quotes` are the message's quoted spans, by marker number. */
|
|
91
|
+
function askIn(sentence, quotes) {
|
|
92
|
+
if ((ELSEWHERE.test(sentence) && !THIS_CHAT.test(sentence)) || OFFER.test(sentence))
|
|
93
|
+
return null;
|
|
94
|
+
const by = CONFIRM_BY.exec(sentence);
|
|
95
|
+
if (by) {
|
|
96
|
+
const words = quotes[Number(by[1])] ?? '';
|
|
97
|
+
if (words && !COMMAND_LIKE.test(words))
|
|
98
|
+
return { words };
|
|
99
|
+
}
|
|
100
|
+
for (const found of sentence.matchAll(ASK)) {
|
|
101
|
+
const start = found.index ?? 0;
|
|
102
|
+
// What a program, a page or a message says isn't an ask: "The SMS ends with: Reply “STOP” here".
|
|
103
|
+
if (DESCRIBED.test(sentence.slice(0, start).trimEnd()))
|
|
104
|
+
continue;
|
|
105
|
+
const from = start + found[0].length;
|
|
106
|
+
const tail = sentence.slice(from, from + 140);
|
|
107
|
+
const quote = /⟦(\d+)⟧/u.exec(tail);
|
|
108
|
+
let words = null;
|
|
109
|
+
let after = tail;
|
|
110
|
+
let before = '';
|
|
111
|
+
if (quote && FILLER.test(tail.slice(0, quote.index))) {
|
|
112
|
+
words = quotes[Number(quote[1])] ?? null;
|
|
113
|
+
before = tail.slice(0, quote.index);
|
|
114
|
+
after = tail.slice(quote.index + quote[0].length);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const plain = PLAIN_WORD.exec(tail);
|
|
118
|
+
if (plain) {
|
|
119
|
+
words = plain[1].toLowerCase();
|
|
120
|
+
after = tail.slice(plain[0].length);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (words !== null) {
|
|
124
|
+
if (COMMAND_LIKE.test(words))
|
|
125
|
+
continue;
|
|
126
|
+
// Anchored to this chat or to going ahead, or the ask stands alone: "Reply “yes, erase disk12”."
|
|
127
|
+
const near = after.slice(0, 80);
|
|
128
|
+
const alone = quote !== null && /^[\s.!)…]*$/u.test(after);
|
|
129
|
+
if (HERE.test(before) || HERE.test(near) || PURPOSE.test(near) || alone)
|
|
130
|
+
return { words };
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// "Reply here to confirm": no words, but here and going ahead.
|
|
134
|
+
if (HERE.test(tail.slice(0, 40)) && PURPOSE.test(tail.slice(0, 80)))
|
|
135
|
+
return { words: null };
|
|
136
|
+
}
|
|
137
|
+
return CONFIRM_HERE.test(sentence) ? { words: null } : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether the final message asks the person to type or reply something in this chat, and what: "Reply “yes, erase
|
|
141
|
+
* disk12” here", "To confirm, type `yes`", "Please reply yes here and I'll merge it", "Confirm here". Null otherwise.
|
|
142
|
+
*/
|
|
143
|
+
export function asksToTypeHere(text) {
|
|
144
|
+
const { text: prose, quotes } = marked(proseOf(text));
|
|
145
|
+
for (const sentence of prose.split(/(?<=[.!?])\s+|\n+/u)) {
|
|
146
|
+
const ask = askIn(sentence.trim(), quotes);
|
|
147
|
+
if (ask)
|
|
148
|
+
return ask;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
/** Words that don't tell one card from another: "yes", "confirm", "please"… */
|
|
153
|
+
const COMMON = new Set([
|
|
154
|
+
'yes',
|
|
155
|
+
'y',
|
|
156
|
+
'ok',
|
|
157
|
+
'okay',
|
|
158
|
+
'sure',
|
|
159
|
+
'please',
|
|
160
|
+
'confirm',
|
|
161
|
+
'confirmed',
|
|
162
|
+
'go',
|
|
163
|
+
'ahead',
|
|
164
|
+
'proceed',
|
|
165
|
+
'continue',
|
|
166
|
+
'approve',
|
|
167
|
+
'approved',
|
|
168
|
+
'do',
|
|
169
|
+
'it',
|
|
170
|
+
'i',
|
|
171
|
+
'the',
|
|
172
|
+
'a',
|
|
173
|
+
'an',
|
|
174
|
+
'to',
|
|
175
|
+
'and',
|
|
176
|
+
'of',
|
|
177
|
+
'me',
|
|
178
|
+
'my',
|
|
179
|
+
'this',
|
|
180
|
+
'that',
|
|
181
|
+
]);
|
|
182
|
+
const wordsOf = (text) => text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
183
|
+
/** The words that say what's being confirmed: "yes, erase disk12" → erase, disk12. */
|
|
184
|
+
export const telling = (words) => words === null ? [] : [...new Set(wordsOf(words).filter((word) => !COMMON.has(word)))];
|
|
185
|
+
/**
|
|
186
|
+
* Whether a card asks for this: one posted in the turn (`posted`: the words its call carried) or one still open
|
|
187
|
+
* (`open`: its title). A card fits when it names everything that tells the words apart ("erase" and "disk12" for
|
|
188
|
+
* “yes, erase disk12”). With nothing to tell them by ("yes", or no words at all), any card posted in the turn fits,
|
|
189
|
+
* and an open one only when its title asks to confirm; an open "Confirm …" card that names some of them fits too.
|
|
190
|
+
*/
|
|
191
|
+
export function cardFits(text, words, where) {
|
|
192
|
+
const wanted = telling(words);
|
|
193
|
+
const has = new Set(wordsOf(text));
|
|
194
|
+
if (wanted.length && wanted.every((word) => has.has(word)))
|
|
195
|
+
return true;
|
|
196
|
+
if (where === 'posted')
|
|
197
|
+
return wanted.length === 0;
|
|
198
|
+
return /\bconfirm/i.test(text) && (wanted.length === 0 || wanted.some((word) => has.has(word)));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The session's link when Claude Code says it: Remote Control's session (CLAUDE_CODE_BRIDGE_SESSION_ID, in `session_`
|
|
202
|
+
* form) or a session on the web (CLAUDE_CODE_REMOTE_SESSION_ID, whose `cse_` is the link's `session_`), as
|
|
203
|
+
* https://code.claude.com/docs/en/env-vars documents them. Null when neither is set.
|
|
204
|
+
*/
|
|
205
|
+
export function sessionLink(env) {
|
|
206
|
+
const bridge = env.CLAUDE_CODE_BRIDGE_SESSION_ID?.trim();
|
|
207
|
+
if (bridge && /^session_[A-Za-z0-9]{8,80}$/.test(bridge))
|
|
208
|
+
return `https://claude.ai/code/${bridge}`;
|
|
209
|
+
const remote = env.CLAUDE_CODE_REMOTE_SESSION_ID?.trim();
|
|
210
|
+
if (remote && /^(?:cse|session)_[A-Za-z0-9]{8,80}$/.test(remote))
|
|
211
|
+
return `https://claude.ai/code/${remote.replace(/^cse_/, 'session_')}`;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/** The most of the asked-for words the reason repeats. */
|
|
215
|
+
export const REASON_WORDS = 60;
|
|
216
|
+
/**
|
|
217
|
+
* What Claude Code reads when it asked for words here and no card says so: post the action card that sends them here,
|
|
218
|
+
* or reopen the card they already answered (about 65 tokens, 80 with a link).
|
|
219
|
+
*/
|
|
220
|
+
export function confirmReason(words, link) {
|
|
221
|
+
const chars = [...(words?.replace(/\s+/g, ' ').trim() ?? '')];
|
|
222
|
+
const shown = chars.length > REASON_WORDS
|
|
223
|
+
? `${chars
|
|
224
|
+
.slice(0, REASON_WORDS - 1)
|
|
225
|
+
.join('')
|
|
226
|
+
.trimEnd()}…`
|
|
227
|
+
: chars.join('');
|
|
228
|
+
const asked = shown ? `to type “${shown}” here` : 'to confirm here';
|
|
229
|
+
const what = shown ? 'those exact words' : 'the words to type';
|
|
230
|
+
return `You asked them ${asked}, but no open Pending You card says so: post an action card “Confirm in Claude Code on <computer>: <step>” with ${what} as a step command, where Claude Code on <computer>${link ? `, ${link}` : ''}, done what you’ll do (a card they answered: reply_in_thread with reopen).`;
|
|
231
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Is this session on the Pending You its hooks are on? A Claude Code session stayed connected to staging after its
|
|
2
|
+
// person moved to production (2026-10-01): its cards went where they no longer looked, and the hooks, signed in to
|
|
3
|
+
// production, never heard them. So the hooks compare the `pendingyou` MCP server Claude Code has for the session's
|
|
4
|
+
// folder (read from ~/.claude.json and the folder's .mcp.json, read only) with the origin they run against (`--origin`),
|
|
5
|
+
// and when they differ, add one line telling the agent to ask its person which one they use. `status` says the same.
|
|
6
|
+
//
|
|
7
|
+
// Only the pendingyou server's url is read, never anything else from those files; nothing is written, nothing is sent.
|
|
8
|
+
// A server Claude Code can't be read for (no file, another shape, a plugin's server) is never called a mismatch.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { claudeJsonPath, MCP_NAME } from "./claude.js";
|
|
11
|
+
import { claudeDir, readText } from "./files.js";
|
|
12
|
+
/**
|
|
13
|
+
* The published Pending You plugin for Claude (recordplane/pendingyou-plugin) and where its server is. A hand-added
|
|
14
|
+
* pendingyou server at any scope overrides it in Claude Code, so it counts only when there's none.
|
|
15
|
+
*/
|
|
16
|
+
export const PLUGIN_ID = 'pending-you@pendingyou';
|
|
17
|
+
export const PLUGIN_MCP_URL = 'https://www.pendingyou.com/mcp';
|
|
18
|
+
const objectAt = (value, key) => {
|
|
19
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
20
|
+
return null;
|
|
21
|
+
const found = value[key];
|
|
22
|
+
return typeof found === 'object' && found !== null && !Array.isArray(found)
|
|
23
|
+
? found
|
|
24
|
+
: null;
|
|
25
|
+
};
|
|
26
|
+
const urlOf = (servers) => {
|
|
27
|
+
const url = objectAt(servers, MCP_NAME)?.url;
|
|
28
|
+
return typeof url === 'string' && url.length > 0 ? url : null;
|
|
29
|
+
};
|
|
30
|
+
async function readJsonQuietly(path) {
|
|
31
|
+
try {
|
|
32
|
+
const text = await readText(path);
|
|
33
|
+
return text === null ? null : JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The `pendingyou` MCP server Claude Code uses in `folder`: local scope (~/.claude.json's projects[folder]), then the
|
|
41
|
+
* folder's own .mcp.json, then user scope (~/.claude.json's mcpServers), then the Pending You plugin when
|
|
42
|
+
* ~/.claude/settings.json enables it. Null when none of them has one with a url.
|
|
43
|
+
*/
|
|
44
|
+
export async function serverForFolder(io, folder) {
|
|
45
|
+
const [state, project, settings] = await Promise.all([
|
|
46
|
+
readJsonQuietly(claudeJsonPath(io)),
|
|
47
|
+
readJsonQuietly(join(folder, '.mcp.json')),
|
|
48
|
+
readJsonQuietly(join(claudeDir(io), 'settings.json')),
|
|
49
|
+
]);
|
|
50
|
+
const local = urlOf(objectAt(objectAt(objectAt(state, 'projects'), folder), 'mcpServers'));
|
|
51
|
+
if (local)
|
|
52
|
+
return { url: local, scope: 'local' };
|
|
53
|
+
const shared = urlOf(objectAt(project, 'mcpServers'));
|
|
54
|
+
if (shared)
|
|
55
|
+
return { url: shared, scope: 'project' };
|
|
56
|
+
const user = urlOf(objectAt(state, 'mcpServers'));
|
|
57
|
+
if (user)
|
|
58
|
+
return { url: user, scope: 'user' };
|
|
59
|
+
return objectAt(settings, 'enabledPlugins')?.[PLUGIN_ID] === true
|
|
60
|
+
? { url: PLUGIN_MCP_URL, scope: 'plugin' }
|
|
61
|
+
: null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The site an address is on, as one name: www.pendingyou.com and pendingyou.com are the same Pending You (the bare
|
|
65
|
+
* name redirects), so both read https://www.pendingyou.com. Null when it isn't an address.
|
|
66
|
+
*/
|
|
67
|
+
export function siteOf(address) {
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(address);
|
|
70
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
71
|
+
return null;
|
|
72
|
+
const host = url.host === 'pendingyou.com' ? 'www.pendingyou.com' : url.host;
|
|
73
|
+
return `${url.protocol}//${host}`;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Whether the server and the hooks' origin are different Pending Yous. Unknown addresses never differ. */
|
|
80
|
+
export function differs(server, origin) {
|
|
81
|
+
if (!server)
|
|
82
|
+
return false;
|
|
83
|
+
const a = siteOf(server.url);
|
|
84
|
+
const b = siteOf(origin);
|
|
85
|
+
return a !== null && b !== null && a !== b;
|
|
86
|
+
}
|
|
87
|
+
/** The line a hook adds when they differ (about 45 tokens). */
|
|
88
|
+
export const mismatchLine = (server, origin) => `Pending You: your MCP server is ${siteOf(server.url)} but your hooks and sign-in are ${siteOf(origin)}, so your cards and their answers may not meet. Ask your person which one they use, and restart the session after fixing it (npx pendingyou init --origin <that one>).`;
|
|
89
|
+
/** The hook's warning for this folder, or null when the two agree or can't be told. Never throws. */
|
|
90
|
+
export async function environmentWarning(io, folder, origin) {
|
|
91
|
+
try {
|
|
92
|
+
const server = await serverForFolder(io, folder);
|
|
93
|
+
return differs(server, origin) ? mismatchLine(server, origin) : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getJson, refreshInBackground, SignInNeeded, Unavailable } from "./api.j
|
|
|
4
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
|
+
import { differs, serverForFolder, siteOf } from "./environment.js";
|
|
7
8
|
import { PlainError } from "./errors.js";
|
|
8
9
|
import { hold } from "./hold.js";
|
|
9
10
|
import { OAuthError, revoke, signIn, signInWithDevice } from "./oauth.js";
|
|
@@ -71,7 +72,11 @@ async function logout(io, options) {
|
|
|
71
72
|
return 0;
|
|
72
73
|
}
|
|
73
74
|
async function status(io, origin) {
|
|
74
|
-
const [signIn, claude] = await Promise.all([
|
|
75
|
+
const [signIn, claude, here] = await Promise.all([
|
|
76
|
+
checkSignIn(io, origin),
|
|
77
|
+
claudeState(io),
|
|
78
|
+
serverForFolder(io, io.cwd),
|
|
79
|
+
]);
|
|
75
80
|
const lines = [`Pending You for Claude Code · ${origin}`];
|
|
76
81
|
const mark = (ok) => (ok ? 'ok ' : 'missing');
|
|
77
82
|
const url = `${origin}/mcp`;
|
|
@@ -83,7 +88,7 @@ async function status(io, origin) {
|
|
|
83
88
|
? 'not added; run npx pendingyou init'
|
|
84
89
|
: mcpOk
|
|
85
90
|
? `pendingyou${mcp.url ? ` (${mcp.url})` : ''}`
|
|
86
|
-
: `points at ${mcp.url}, not ${url}; run npx pendingyou init${origin === DEFAULT_ORIGIN ? '' : ` --origin ${origin}`} to replace it`;
|
|
91
|
+
: `points at ${mcp.url}, not ${url}, so sessions post cards where these hooks don’t hear them; run npx pendingyou init${origin === DEFAULT_ORIGIN ? '' : ` --origin ${origin}`} to replace it`;
|
|
87
92
|
const form = claude.form;
|
|
88
93
|
const hooksRun = form?.runs ?? true;
|
|
89
94
|
const formText = !form
|
|
@@ -104,7 +109,15 @@ async function status(io, origin) {
|
|
|
104
109
|
: signIn.state === 'none'
|
|
105
110
|
? 'not signed in; run npx pendingyou login'
|
|
106
111
|
: '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
|
-
|
|
112
|
+
// A folder's own server (local or project scope) wins over the user one in Claude Code: say when it's elsewhere. (The
|
|
113
|
+
// plugin's server counts only when no server was added by hand, and is production's.)
|
|
114
|
+
if (here && (here.scope === 'local' || here.scope === 'project') && differs(here, origin))
|
|
115
|
+
lines.push(` ${mark(false)} This folder: its ${here.scope}-scope pendingyou MCP server (${here.url}) is another Pending You than ${origin}; Claude Code uses it here instead`);
|
|
116
|
+
const user = mcp !== false && mcp !== 'unknown' && mcp.url ? { url: mcp.url, scope: 'user' } : null;
|
|
117
|
+
const other = differs(here, origin) ? here : differs(user, origin) ? user : null;
|
|
118
|
+
const ready = signIn.state === 'ok' && claude.hooks && hooksRun && !other;
|
|
119
|
+
if (other)
|
|
120
|
+
lines.push(`Mismatch: Claude Code’s MCP server is ${siteOf(other.url)} but the hooks and sign-in are ${siteOf(origin) ?? origin}. Use the one you look at (npx pendingyou init --origin <it>), then restart your Claude Code sessions.`);
|
|
108
121
|
lines.push(ready
|
|
109
122
|
? 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
|
|
110
123
|
: 'Not ready: Claude Code hears answers while it’s working (report_setup hears "while-working").');
|
package/dist/pickup.js
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
// one small request per message (the open cards, and answers too while a hold from this computer is outstanding),
|
|
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
|
+
// Both also add one line when the session's pendingyou MCP server is another Pending You than the hooks' origin
|
|
13
|
+
// (environment.ts): its cards would go where these hooks, and maybe its person, don't look.
|
|
14
|
+
//
|
|
12
15
|
// Whatever they print, Claude Code adds to the conversation. A hook must never get in the way: each is time-boxed,
|
|
13
16
|
// has a hard deadline (main.ts) after which it prints only its fallback, never refreshes the sign-in itself (api.ts),
|
|
14
17
|
// prints nothing when there's nothing new, never repeats an answer it handed over, and always exits 0.
|
|
15
18
|
import { getJson, SignInNeeded } from "./api.js";
|
|
16
19
|
import { readCredential } from "./credentials.js";
|
|
20
|
+
import { environmentWarning } from "./environment.js";
|
|
17
21
|
import { clip, describe, isClosed } from "./format.js";
|
|
18
22
|
import { markHanded, momentOf, waitingOn, wasHanded } from "./state.js";
|
|
19
23
|
/** Each request's own limit, inside the hook's 3-second deadline (main.ts). */
|
|
@@ -76,15 +80,20 @@ export async function pickup(io, options, hook = { fallback: '' }) {
|
|
|
76
80
|
const { origin, mode } = options;
|
|
77
81
|
// The hand-off's line, printed at the end unless something else was printed with it.
|
|
78
82
|
let line = null;
|
|
83
|
+
// Said by either hook, signed in or not, when the session's MCP server is another Pending You (environment.ts).
|
|
84
|
+
let warning = null;
|
|
79
85
|
try {
|
|
80
86
|
const cwd = await sessionFolder(io);
|
|
87
|
+
warning = await environmentWarning(io, cwd, origin);
|
|
88
|
+
if (warning)
|
|
89
|
+
hook.fallback = `${warning}\n`;
|
|
81
90
|
// The hand-off hears answers only while a hold from this computer is outstanding; it always asks for open cards.
|
|
82
91
|
let answers = true;
|
|
83
92
|
if (mode === 'handoff') {
|
|
84
93
|
if ((await readCredential(io, origin)) === null)
|
|
85
94
|
return 0;
|
|
86
95
|
line = REMINDER;
|
|
87
|
-
hook.fallback = `${REMINDER}\n`;
|
|
96
|
+
hook.fallback = `${[REMINDER, warning].filter(Boolean).join('\n')}\n`;
|
|
88
97
|
answers = (await waitingOn(io, origin)).length > 0;
|
|
89
98
|
}
|
|
90
99
|
const query = new URLSearchParams([
|
|
@@ -116,8 +125,10 @@ export async function pickup(io, options, hook = { fallback: '' }) {
|
|
|
116
125
|
...fresh.flatMap((heard) => [...describe(heard, again(heard.requestId)), '']),
|
|
117
126
|
'Tell them in one line first (“You answered in Pending You: …”), then act.',
|
|
118
127
|
...(line ? [line] : []),
|
|
128
|
+
...(warning ? [warning] : []),
|
|
119
129
|
].join('\n')}\n`);
|
|
120
130
|
line = null;
|
|
131
|
+
warning = null;
|
|
121
132
|
await markHanded(io, origin, fresh.map((heard) => ({
|
|
122
133
|
requestId: heard.requestId,
|
|
123
134
|
moment: momentOf(heard),
|
|
@@ -133,7 +144,8 @@ export async function pickup(io, options, hook = { fallback: '' }) {
|
|
|
133
144
|
return 0;
|
|
134
145
|
}
|
|
135
146
|
finally {
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
const last = [line, warning].filter(Boolean);
|
|
148
|
+
if (last.length)
|
|
149
|
+
io.out(`${last.join('\n')}\n`);
|
|
138
150
|
}
|
|
139
151
|
}
|
package/dist/stopcheck.js
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
// the person in the chat (a question to them, a step only they can do, a "Your part:" list) and it posted nothing to
|
|
3
3
|
// Pending You in that turn, it asks Claude Code to go on and post it (`{"decision":"block","reason":…}`), once.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// Since 0.9.0 (guide 2.21) it also catches a final message asking them to type or reply something in this chat
|
|
6
|
+
// ("Reply “yes, erase disk12” here", "confirm here"): an agent that won't act on a card answer for that step must say
|
|
7
|
+
// so on a card, or they never learn where they're needed. Unless a card posted in the turn, or one still open from this
|
|
8
|
+
// folder, asks for the same thing, it asks Claude Code to post the action card that sends them here (confirm.ts).
|
|
9
|
+
//
|
|
10
|
+
// Pattern matching only, no model: it reads the hook's JSON, the final message, and the tail of the transcript back to
|
|
11
|
+
// the person's last message, never more than READ_CAP bytes. Only an ask to type here costs a request (this folder's
|
|
12
|
+
// open cards, as the next-message hook asks for them, given up after 1.5 seconds). It never loops (stop_hook_active),
|
|
7
13
|
// stays quiet for anyone not signed in to this origin, and exits 0 silently on any error.
|
|
8
14
|
import { open } from 'node:fs/promises';
|
|
15
|
+
import { getJson } from "./api.js";
|
|
16
|
+
import { asksToTypeHere, cardFits, confirmReason, sessionLink } from "./confirm.js";
|
|
9
17
|
import { readCredential } from "./credentials.js";
|
|
18
|
+
import { folderForms } from "./pickup.js";
|
|
10
19
|
/** The most of the transcript it reads, from the end. A turn longer than this is let through. */
|
|
11
20
|
export const READ_CAP = 4 * 1024 * 1024;
|
|
12
21
|
const CHUNK = 64 * 1024;
|
|
@@ -14,6 +23,12 @@ const CHUNK = 64 * 1024;
|
|
|
14
23
|
export const STOP_REASON = 'Items for your person are in chat but not in Pending You: post them as cards (ask in both places), or say they don’t need one.';
|
|
15
24
|
/** Tools that put something in front of the person: Pending You's, under any MCP server name. */
|
|
16
25
|
const POSTING_TOOL = /^mcp__.+__(post_request|update_request|reply_in_thread)$/;
|
|
26
|
+
/** How long it waits for this folder's open cards, inside the hook's 3-second deadline (main.ts). */
|
|
27
|
+
const OPEN_TIME_BOX_MS = 1500;
|
|
28
|
+
/** As many of them as Pending You lists. */
|
|
29
|
+
const OPEN_CARDS = 5;
|
|
30
|
+
/** The most of one posting call's words it keeps, to match an ask against. */
|
|
31
|
+
const CARD_TEXT = 20_000;
|
|
17
32
|
/**
|
|
18
33
|
* Text that says the items are already in Pending You, or will be: "you'll get a card to publish it once it's merged"
|
|
19
34
|
* announces a card to come, not a step left in chat.
|
|
@@ -87,6 +102,27 @@ export function itemsForPerson(text) {
|
|
|
87
102
|
export const saysPosted = (text) => SAYS_POSTED.test(plainText(text));
|
|
88
103
|
/** Whether the text says nothing in it needs a card. */
|
|
89
104
|
export const saysNoCard = (text) => SAYS_NO_CARD.test(plainText(text));
|
|
105
|
+
/** Every string a tool call carried (a card's title, summary, steps and their commands…), up to `max` characters. */
|
|
106
|
+
function wordsIn(value, max = CARD_TEXT) {
|
|
107
|
+
const found = [];
|
|
108
|
+
let left = max;
|
|
109
|
+
const walk = (item, depth) => {
|
|
110
|
+
if (left <= 0 || depth > 8)
|
|
111
|
+
return;
|
|
112
|
+
if (typeof item === 'string') {
|
|
113
|
+
found.push(item.slice(0, left));
|
|
114
|
+
left -= item.length;
|
|
115
|
+
}
|
|
116
|
+
else if (Array.isArray(item))
|
|
117
|
+
for (const each of item)
|
|
118
|
+
walk(each, depth + 1);
|
|
119
|
+
else if (item && typeof item === 'object')
|
|
120
|
+
for (const each of Object.values(item))
|
|
121
|
+
walk(each, depth + 1);
|
|
122
|
+
};
|
|
123
|
+
walk(value, 0);
|
|
124
|
+
return found.join('\n');
|
|
125
|
+
}
|
|
90
126
|
/** A message from the person (not a tool's result): where a turn starts. */
|
|
91
127
|
function startsTurn(entry) {
|
|
92
128
|
if (entry.type !== 'user' || entry.isMeta || entry.message?.role !== 'user')
|
|
@@ -110,6 +146,7 @@ export async function lastTurn(path, cap = READ_CAP) {
|
|
|
110
146
|
const texts = [];
|
|
111
147
|
let finalDone = false;
|
|
112
148
|
let posted = false;
|
|
149
|
+
const cards = [];
|
|
113
150
|
const take = (raw) => {
|
|
114
151
|
if (!raw.trim())
|
|
115
152
|
return false;
|
|
@@ -131,8 +168,13 @@ export async function lastTurn(path, cap = READ_CAP) {
|
|
|
131
168
|
if (block?.type === 'tool_use') {
|
|
132
169
|
finalDone = true;
|
|
133
170
|
const name = typeof block.name === 'string' ? block.name : '';
|
|
134
|
-
const
|
|
135
|
-
|
|
171
|
+
const input = (block.input ?? {});
|
|
172
|
+
const command = typeof input.command === 'string' ? input.command : '';
|
|
173
|
+
if (POSTING_TOOL.test(name)) {
|
|
174
|
+
posted = true;
|
|
175
|
+
cards.push(wordsIn(block.input));
|
|
176
|
+
}
|
|
177
|
+
else if (/\bpendingyou\b.*\bhold\s+req_/.test(command))
|
|
136
178
|
posted = true;
|
|
137
179
|
}
|
|
138
180
|
else if (block?.type === 'text' && !finalDone && typeof block.text === 'string')
|
|
@@ -150,20 +192,41 @@ export async function lastTurn(path, cap = READ_CAP) {
|
|
|
150
192
|
let newline = buffer.lastIndexOf(10);
|
|
151
193
|
while (newline !== -1) {
|
|
152
194
|
if (take(buffer.subarray(newline + 1).toString('utf8')))
|
|
153
|
-
return { text: texts.join('\n'), posted, complete: true, bytesRead };
|
|
195
|
+
return { text: texts.join('\n'), posted, cards, complete: true, bytesRead };
|
|
154
196
|
buffer = buffer.subarray(0, newline);
|
|
155
197
|
newline = buffer.lastIndexOf(10);
|
|
156
198
|
}
|
|
157
199
|
carry = buffer;
|
|
158
200
|
}
|
|
159
201
|
if (position === 0 && take(carry.toString('utf8')))
|
|
160
|
-
return { text: texts.join('\n'), posted, complete: true, bytesRead };
|
|
161
|
-
return { text: texts.join('\n'), posted, complete: position === 0, bytesRead };
|
|
202
|
+
return { text: texts.join('\n'), posted, cards, complete: true, bytesRead };
|
|
203
|
+
return { text: texts.join('\n'), posted, cards, complete: position === 0, bytesRead };
|
|
162
204
|
}
|
|
163
205
|
finally {
|
|
164
206
|
await file.close();
|
|
165
207
|
}
|
|
166
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* The titles of this folder's cards still waiting on the person (what the next-message hook names), or null when that
|
|
211
|
+
* can't be told: an error, a slow answer, a server too old to list them.
|
|
212
|
+
*/
|
|
213
|
+
async function openTitles(io, origin, cwd) {
|
|
214
|
+
try {
|
|
215
|
+
const query = new URLSearchParams([
|
|
216
|
+
...folderForms(cwd, io.home).map((form) => ['cwd', form]),
|
|
217
|
+
['limit', '0'],
|
|
218
|
+
['open', String(OPEN_CARDS)],
|
|
219
|
+
]);
|
|
220
|
+
const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, OPEN_TIME_BOX_MS, io.signal, { hook: true });
|
|
221
|
+
const requests = status === 200 ? body.open?.requests : undefined;
|
|
222
|
+
if (!Array.isArray(requests))
|
|
223
|
+
return null;
|
|
224
|
+
return requests.flatMap((card) => typeof card?.title === 'string' ? [card.title] : []);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
167
230
|
export async function stopcheck(io, options) {
|
|
168
231
|
try {
|
|
169
232
|
const input = JSON.parse(await io.readStdin(1000));
|
|
@@ -180,7 +243,23 @@ export async function stopcheck(io, options) {
|
|
|
180
243
|
const text = typeof input.last_assistant_message === 'string' && input.last_assistant_message.trim()
|
|
181
244
|
? input.last_assistant_message
|
|
182
245
|
: turn.text;
|
|
183
|
-
if (
|
|
246
|
+
if (!text)
|
|
247
|
+
return 0;
|
|
248
|
+
// Words they must type here (guide 2.21): a card has to say so, one from this turn or one still open.
|
|
249
|
+
const ask = asksToTypeHere(text);
|
|
250
|
+
if (ask) {
|
|
251
|
+
if (saysNoCard(text) || turn.cards.some((card) => cardFits(card, ask.words, 'posted')))
|
|
252
|
+
return 0;
|
|
253
|
+
const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : io.cwd;
|
|
254
|
+
const open = await openTitles(io, options.origin, cwd);
|
|
255
|
+
// Not known (offline, slow, an older server): take the message's word that it's on a card.
|
|
256
|
+
if (open ? open.some((title) => cardFits(title, ask.words, 'open')) : saysPosted(text))
|
|
257
|
+
return 0;
|
|
258
|
+
const reason = confirmReason(ask.words, sessionLink(io.env));
|
|
259
|
+
io.out(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
if (turn.posted || saysPosted(text) || saysNoCard(text) || !itemsForPerson(text))
|
|
184
263
|
return 0;
|
|
185
264
|
io.out(`${JSON.stringify({ decision: 'block', reason: STOP_REASON })}\n`);
|
|
186
265
|
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.9.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
"node": ">=20"
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
|
-
".": "./
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./dist/index.js"
|
|
26
|
+
}
|
|
22
27
|
},
|
|
23
28
|
"scripts": {
|
|
24
29
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
30
|
+
"prepublishOnly": "pnpm run build && pnpm run test",
|
|
25
31
|
"test": "vitest run",
|
|
26
32
|
"typecheck": "tsc -p tsconfig.json"
|
|
27
33
|
}
|
|
28
|
-
}
|
|
34
|
+
}
|