pendingyou 0.8.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 CHANGED
@@ -1,5 +1,28 @@
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
+
3
26
  ## 0.8.0
4
27
 
5
28
  - The hooks say when a session is on another Pending You. A Claude Code session stayed connected to staging after its
package/README.md CHANGED
@@ -37,7 +37,7 @@ never refreshes the sign-in itself: when that's due, it starts the refresh in th
37
37
  own. Running `init` again updates them in place, and adds any a newer version brings.
38
38
 
39
39
  The hooks run that copy with Node directly, like
40
- `"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.8.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
40
+ `"/opt/homebrew/bin/node" "~/.config/pendingyou/cli/0.9.0/node_modules/pendingyou/dist/cli.js" handoff || true` (the
41
41
  paths in full). If the copy can't be made (no npm, no network), they run through `npx -y --prefer-offline
42
42
  pendingyou@<version>` instead. `npx pendingyou status` says which. If you remove the Node they run with, run `init`
43
43
  again.
@@ -58,13 +58,21 @@ Deep into a long conversation, Claude Code tends to end a turn with a question f
58
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
59
59
  twice in a row.
60
60
 
61
- The check is pattern matching on your computer: no model, no network. It reads only the last turn from the end of the
61
+ The check is pattern matching on your computer: no model. It reads only the last turn from the end of the
62
62
  transcript (at most 4 MB) and skips code blocks and quotes. It counts as for you: a question to you (a sentence ending
63
63
  in "?" with "you" or "your"); "you'll need to", "your step", "can you", "please run", "in your terminal", "only you
64
64
  can", "waiting on you" and the like; or a list under a heading like "Your part", "Your steps", "For you", "Waiting on
65
65
  you" or "Left for you". It lets the turn end when the message says nothing needs a card ("No card needed.", "Nothing
66
66
  needs your attention.", "There's nothing waiting on you.").
67
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
+
68
76
  **On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
69
77
  `npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
70
78
 
@@ -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
+ }
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
- // Pattern matching only, no model, no network: it reads the hook's JSON, the final message, and the tail of the
6
- // transcript back to the person's last message, never more than READ_CAP bytes. It never loops (stop_hook_active),
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 command = typeof block.input?.command === 'string' ? block.input.command : '';
135
- if (POSTING_TOOL.test(name) || /\bpendingyou\b.*\bhold\s+req_/.test(command))
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 (turn.posted || !text || saysPosted(text) || saysNoCard(text) || !itemsForPerson(text))
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.8.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.8.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
- ".": "./dist/index.js"
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
+ }