pendingyou 0.35.1 → 0.36.1

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,6 +1,40 @@
1
1
  # Changelog
2
2
 
3
- ## 0.35.1 (2026-10-11)
3
+ ## 0.36.1 (2026-10-20)
4
+
5
+ - Fixed: when you replied on an answer card, the Claude Code session that posted the answer wasn't woken: the wake mod
6
+ didn't know `post_answer`, so your reply reached it only with your next message, or at the next session start. Now
7
+ the mod follows a session's answers as it does its questions, and your reply starts a turn even while the session
8
+ sits idle. Got it, reading the answer in the chat, a newer answer replacing it, or the agent withdrawing it wakes
9
+ nothing, and the mod forgets the answer (after one more look when you mark it Got it, since you can take that back
10
+ for a few seconds).
11
+ - An answer asks you nothing, so it's no longer counted as waiting on you: not in “2 waiting on you” under Claude
12
+ Code's prompt, and not on Herdr's badges for Claude Code, Codex, OpenCode or Pi.
13
+ - A reply on an answer wakes Codex, OpenCode and Pi in the same words as Claude Code: “Pending You: your person replied
14
+ to your answer “…” (req_…).”, then to read it with get_request, act on it and ack_answer, or post_answer with
15
+ follows for a long one.
16
+
17
+ ## 0.36.0 (2026-10-19)
18
+
19
+ - Your agents' answers to your questions land in Pending You too (guide 2.54). When you ask an agent something and
20
+ its last message answers it (30 words or more outside code, or a table) but it posted nothing to Pending You that
21
+ turn, the check at the end of its turn asks it once: “Your person asked you something and your answer is only in
22
+ chat: post_answer it too, or say it doesn’t need one.” Your question is read from your own message that started the
23
+ turn, leniently, since dictation often has no “?”: “pending you how many calls did billing get last week” counts.
24
+ A request for work doesn't (“Can you fix the flaky test?”, “Deploy it to staging”, “When you're done, push it”),
25
+ nor anything under four words without a “?”, nor a turn Pending You started. Pattern matching only, on your
26
+ computer, as before. Claude Code reads your message from its transcript; Codex's next-message hook notes whether it
27
+ asked (never your words), OpenCode's plugin reads the turn's first message, and Pi's extension hands over the
28
+ prompt that started the run.
29
+ - Your next message marks the answers that session posted as read in Pending You, so they leave your Pending without
30
+ notifying you: you've seen them in the chat. Only that session's own, posted before your message, and only when it
31
+ posted one in the last day. It goes beside the hand-off's request, inside its 2 seconds, prints nothing, and
32
+ changes nothing if it fails.
33
+ - Codex: `init` gives the hook for Pending You's tools post_answer, which changes it, so trust it again in Codex's
34
+ `/hooks` (init and status say how). Until then the check for an answer left in chat stays off there.
35
+ - `post_answer` counts as a card call: the end-of-turn checks see it, and Codex's hook, OpenCode's plugin and Pi's
36
+ extension remember the answer for the session, so a Reply on it wakes the session there as an answer does. In
37
+ Claude Code a Reply comes with your next message, or at the next session start.
4
38
 
5
39
  - Fixed: the Pending You popup in Herdr no longer breaks on a kind of card it doesn't know yet (or a grouped card with
6
40
  a question of such a kind). It shows the card and says "Answer this one in Pending You: o opens it."; reply, Later,
package/README.md CHANGED
@@ -234,6 +234,28 @@ it or hand it back instead (about 45 tokens). Codex's hook for Pending You's too
234
234
  tools, which doesn't name those two, so there the check knows only that the turn started with one (when Codex gives the
235
235
  message to its next-message hook), and says “unless you already did”.
236
236
 
237
+ ### Answers to your questions (0.36.0)
238
+
239
+ When you ask an agent something and it answers, the answer goes in Pending You too (`post_answer`, guide 2.54): it
240
+ waits in your Pending under your question, quiet while you're in the chat. Two things help:
241
+
242
+ - **At the end of a turn**, when your own message started it and asked something, its last message answers it (30
243
+ words or more outside code, or a table, and not only a recap of cards), and nothing was posted to Pending You in
244
+ that turn, the agent is asked once (about 29 tokens): *Your person asked you something and your answer is only in
245
+ chat: post_answer it too, or say it doesn’t need one.* Your question is read leniently, since dictation often has
246
+ no "?": a "?" in its first sentence or one of its last three, a question word and three more words ("pending you how
247
+ many calls did billing get last week"), or asking for facts ("tell me what broke", "how many", "check whether"). Not
248
+ a request for work ("Can you fix the flaky test?", "Deploy it to staging", "When you're done, push it", "Do the
249
+ migration"), anything under four words without a "?" ("yes", "go on"), or a turn Pending You started. Claude Code's
250
+ question comes from its transcript; Codex's next-message hook notes whether it asked (never your words), and the
251
+ check stays off on a computer until `init` has given Codex's hook for Pending You's tools `post_answer` and you've
252
+ trusted it again in `/hooks`; OpenCode's plugin reads the turn's first message, and Pi's extension hands over the
253
+ prompt that started the run.
254
+ - **Your next message** in that session marks the answers it posted as read in Pending You (`POST /mcp/cli/read`), so
255
+ they leave your Pending without a notification: you've seen them in the chat. Only that session's own, posted before
256
+ your message, and only when it posted one in the last day; beside the hand-off's request, inside its 2 seconds. It
257
+ prints nothing, and when it fails nothing changes: an unread answer notifies you once, after your hand-off time.
258
+
237
259
  **On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
238
260
  `npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
239
261
 
@@ -27,10 +27,10 @@ const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
27
27
  export const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
28
28
  const HANDED = '_handed';
29
29
  const NAMES = 5;
30
- const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
30
+ const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|post_answer|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
31
31
  const CARD_TOOLS = {
32
32
  codex: MCP_CARD_TOOL,
33
- opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i,
33
+ opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|post_answer|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i,
34
34
  pi: MCP_CARD_TOOL,
35
35
  };
36
36
  const HELPER_CALLS = new Set(['answer_delegated', 'hand_back']);
@@ -58,6 +58,9 @@ function threadOf(value) {
58
58
  ...(names.length ? { names: names.slice(-NAMES) } : {}),
59
59
  ...(typeof value.cwd === 'string' && value.cwd ? { cwd: value.cwd } : {}),
60
60
  ...(typeof value.herdr === 'string' && PANE_ID.test(value.herdr) ? { herdr: value.herdr } : {}),
61
+ ...(typeof value.answeredAt === 'number' && Number.isFinite(value.answeredAt)
62
+ ? { answeredAt: value.answeredAt }
63
+ : {}),
61
64
  };
62
65
  }
63
66
  async function load(io, app) {
@@ -178,6 +181,7 @@ export async function posted(io, options) {
178
181
  const urgency = isUrgency(args.urgency) ? args.urgency : before?.urgency;
179
182
  const blocking = typeof args.blocking === 'boolean' ? args.blocking : before?.blocking;
180
183
  const askedFirst = call === 'post_request' ? askedIn(args) : before?.askedFirst;
184
+ const answer = call === 'post_answer' || before?.kind === 'answer';
181
185
  waiting.cards[requestId] = {
182
186
  ...(name ? { name } : {}),
183
187
  ...(title ? { title } : {}),
@@ -185,6 +189,7 @@ export async function posted(io, options) {
185
189
  ...(urgency ? { urgency } : {}),
186
190
  ...(blocking ? { blocking } : {}),
187
191
  ...(askedFirst ? { askedFirst } : {}),
192
+ ...(answer ? { kind: 'answer' } : {}),
188
193
  };
189
194
  }
190
195
  }
@@ -192,6 +197,8 @@ export async function posted(io, options) {
192
197
  waiting.cwd = cwd;
193
198
  if (pane)
194
199
  waiting.herdr = pane.pane;
200
+ if (call === 'post_answer' && requestId)
201
+ waiting.answeredAt = now;
195
202
  waiting.origin = options.origin;
196
203
  waiting.at = now;
197
204
  state.threads[thread] = waiting;
@@ -246,7 +253,9 @@ export async function reportThreads(io, app, options) {
246
253
  if (here.length === 0 && options.closed)
247
254
  return await clearHerdr(io, options.at, pane);
248
255
  const newest = [...here].sort((a, b) => b.at - a.at)[0];
249
- const cards = here.flatMap((thread) => Object.entries(thread.cards).map(([requestId, card]) => ({
256
+ const cards = here.flatMap((thread) => Object.entries(thread.cards)
257
+ .filter(([, card]) => card.kind !== 'answer')
258
+ .map(([requestId, card]) => ({
250
259
  requestId,
251
260
  ...(typeof card.title === 'string' ? { title: card.title } : {}),
252
261
  ...(isUrgency(card.urgency) ? { urgency: card.urgency } : {}),
@@ -287,6 +296,17 @@ export async function threadCards(io, app, thread) {
287
296
  const cards = (await load(io, app).catch(() => null))?.threads[thread]?.cards ?? {};
288
297
  return Object.keys(cards).filter((id) => REQUEST_ID.test(id));
289
298
  }
299
+ export async function threadAnsweredAt(io, app, thread) {
300
+ if (!thread)
301
+ return null;
302
+ return (await load(io, app).catch(() => null))?.threads[thread]?.answeredAt ?? null;
303
+ }
304
+ export async function markAskedTurn(io, turn, app) {
305
+ const now = io.now();
306
+ await change(io, app, (state) => {
307
+ state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, asked: true };
308
+ });
309
+ }
290
310
  export async function markHandedTurn(io, turn, app) {
291
311
  const now = io.now();
292
312
  await change(io, app, (state) => {
@@ -346,6 +366,7 @@ export async function turnRecord(io, turn, app = 'codex') {
346
366
  cards: Array.isArray(record.cards) ? record.cards : [],
347
367
  nudged: record.nudged === true,
348
368
  delegated: record.delegated === true,
369
+ asked: record.asked === true,
349
370
  }
350
371
  : null;
351
372
  }
@@ -401,6 +422,13 @@ function line(heard, card) {
401
422
  `${from} followed up on ${titled}, which it answered for your person`,
402
423
  `call ${get} to read it, act on it if it needs anything, then ack_answer with its version to mark it heard`,
403
424
  ];
425
+ if (heard.kind === 'answer')
426
+ return heard.status === 'resolved' || heard.status === 'cancelled' || heard.status === 'expired'
427
+ ? null
428
+ : [
429
+ `your person replied to your answer ${titled}`,
430
+ `call ${get}, act on their words, then ack_answer with its version and a one-line outcome (it reaches them as your reply), or post_answer with follows: its requestId for a long one`,
431
+ ];
404
432
  switch (heard.status) {
405
433
  case 'expired':
406
434
  return [
@@ -37,7 +37,8 @@ export function trustSteps(found) {
37
37
  ];
38
38
  }
39
39
  const DONT_SAVE = 'In the Codex app, don’t press Save on pendingyou in Settings › MCP: that writes it again without this computer’s sign-in (if you did, run init again to put it back).';
40
- const CARD_TOOLS = '^mcp__pendingyou__(post_request|update_request|reply_in_thread|cancel_request)$';
40
+ const CARD_TOOLS = '^mcp__pendingyou__(post_request|post_answer|update_request|reply_in_thread|cancel_request)$';
41
+ const POST_ANSWER = 'mcp__pendingyou__post_answer';
41
42
  export const CODEX_HOOKS = [
42
43
  { event: 'SessionStart', sub: 'pickup', matcher: 'startup|resume|clear' },
43
44
  { event: 'UserPromptSubmit', sub: 'handoff' },
@@ -210,6 +211,22 @@ async function readHookTrust(io) {
210
211
  });
211
212
  }
212
213
  const untrusted = async (io) => (await readHookTrust(io)).filter((hook) => hook.trust === 'untrusted' || hook.trust === 'changed');
214
+ export async function codexSeesAnswers(io) {
215
+ try {
216
+ const file = await readHooksFile(io);
217
+ if (!('json' in file))
218
+ return false;
219
+ const hooks = file.json.hooks && typeof file.json.hooks === 'object' ? file.json.hooks : {};
220
+ const groups = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
221
+ const ours = groups.find((group) => (group?.hooks ?? []).some((hook) => isOurHook(hook?.command, 'posted')));
222
+ if (typeof ours?.matcher !== 'string' || !new RegExp(ours.matcher).test(POST_ANSWER))
223
+ return false;
224
+ return (await readHookTrust(io)).some((hook) => hook.sub === 'posted' && hook.trust === 'trusted');
225
+ }
226
+ catch {
227
+ return false;
228
+ }
229
+ }
213
230
  async function signedInByItself(io, program) {
214
231
  const result = await io.run(program, ['mcp', 'list', '--json'], 30_000);
215
232
  if (result.code !== 0)
@@ -10,7 +10,7 @@ const PRODUCTION = 'https://www.pendingyou.com';
10
10
  const HOOK_MS = 5000;
11
11
  const TURN_MESSAGES = 200;
12
12
  const TEXT_CHARS = 100_000;
13
- const CARD_TOOL = /^[A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*_(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
13
+ const CARD_TOOL = /^[A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*_(post_request|post_answer|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
14
14
  export const HANDED_FOR_MS = 12 * 60 * 60_000;
15
15
  const NUDGED = 'Asked OpenCode to put what it needs from you on a card.';
16
16
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -8,7 +8,7 @@ export const READY_EVERY_MS = 500;
8
8
  export const READY_FOR_MS = 30_000;
9
9
  const WHOAMI = /^mcp__[A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*__whoami$/i;
10
10
  const STOP_MS = 2000;
11
- export const CARD_TOOL = /^mcp__[A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*__(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
11
+ export const CARD_TOOL = /^mcp__[A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*__(post_request|post_answer|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
12
12
  export const HANDED_FOR_MS = 12 * 60 * 60_000;
13
13
  export const HANDED_PREFIX = 'Pending You: your person handed you';
14
14
  const HANDED_REPLY = ' on the question handed to you, ';
@@ -148,6 +148,7 @@ export function pendingYou(setup, options = {}) {
148
148
  let cwd = '';
149
149
  let closed = false;
150
150
  let turn = null;
151
+ let prompt = null;
151
152
  let nudged = false;
152
153
  let posting = Promise.resolve();
153
154
  let listener = null;
@@ -328,7 +329,8 @@ export function pendingYou(setup, options = {}) {
328
329
  nextTurn(said);
329
330
  listen();
330
331
  });
331
- pi.on('before_agent_start', async (_event, ctx) => {
332
+ pi.on('before_agent_start', async (event, ctx) => {
333
+ prompt = typeof event?.prompt === 'string' ? event.prompt : null;
332
334
  listen();
333
335
  const said = await hook('handoff', { cwd: ctx.cwd || cwd, session_id: session });
334
336
  return said ? { message: { customType: TYPE, content: said, display: false } } : undefined;
@@ -373,6 +375,7 @@ export function pendingYou(setup, options = {}) {
373
375
  last_assistant_message: text,
374
376
  stop_hook_active: false,
375
377
  ...(startedHanded(event.context?.contextMessages, handedWakes) ? { handed: true } : {}),
378
+ ...(prompt !== null ? { prompt } : {}),
376
379
  }));
377
380
  if (!reason)
378
381
  return;
@@ -388,6 +391,7 @@ export function pendingYou(setup, options = {}) {
388
391
  });
389
392
  pi.on('agent_settled', () => {
390
393
  turn = null;
394
+ prompt = null;
391
395
  nudged = false;
392
396
  });
393
397
  pi.on('session_shutdown', async () => {
package/dist/asked.js ADDED
@@ -0,0 +1,102 @@
1
+ import { isHandedWake } from "./format.js";
2
+ const PENDING_YOU_LINE = /^Pending You: (?:your person|nobody answered|\d+ of your cards|this computer|anything you need|[\w .’'-]{1,40} is connected|.*\(req_[A-Za-z0-9-]+)/;
3
+ const TAGGED = /^<[a-z][\w-]*>/i;
4
+ const SLASH = /^\/[a-z]/i;
5
+ const NOT_THEIRS = /^(?:\[Request interrupted|Stop hook feedback)/i;
6
+ export function ownWords(text) {
7
+ const words = text.trim();
8
+ if (!words)
9
+ return null;
10
+ const first = words.split('\n')[0] ?? '';
11
+ if (PENDING_YOU_LINE.test(first) ||
12
+ isHandedWake(words) ||
13
+ TAGGED.test(words) ||
14
+ SLASH.test(words) ||
15
+ NOT_THEIRS.test(words))
16
+ return null;
17
+ return words;
18
+ }
19
+ function spoken(text) {
20
+ const kept = [];
21
+ let fence = null;
22
+ for (const line of text.split('\n')) {
23
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
24
+ if (fence) {
25
+ if (marker?.startsWith(fence))
26
+ fence = null;
27
+ continue;
28
+ }
29
+ if (marker) {
30
+ fence = marker;
31
+ continue;
32
+ }
33
+ if (/^\s*>/.test(line))
34
+ continue;
35
+ kept.push(line
36
+ .replace(/`[^`]*`/g, ' ')
37
+ .replace(/“[^”]*”|"[^"]*"/g, ' ')
38
+ .replace(/\b(?:https?:\/\/|www\.)\S+/gi, ' '));
39
+ }
40
+ return kept.join('\n');
41
+ }
42
+ const FILLER = /^(?:(?:pending\s*(?:you|new|view)|hey|hi|hello|yo|ok(?:ay)?|so|um+|uh+|well|alright|all right|right|oh|and|also|now|please|quick question)\b[\s,.:;!?-]*)+/i;
43
+ const ASSISTANT = /^(?:claude|codex|opencode|pi|cursor|grok|chatgpt|gemini)\b[\s,.:;!-]*/i;
44
+ const NAMED = /^[A-Z][\p{L}’'-]*,\s+/u;
45
+ function opening(sentence) {
46
+ let text = sentence.trim();
47
+ for (let tries = 0; tries < 6; tries++) {
48
+ const next = text.replace(FILLER, '').replace(ASSISTANT, '').replace(NAMED, '').trim();
49
+ if (next === text)
50
+ break;
51
+ text = next;
52
+ }
53
+ return text;
54
+ }
55
+ export const wordsOf = (text) => text.match(/[\p{L}\p{N}][\p{L}\p{N}’'.,:%-]*/gu) ?? [];
56
+ const QUESTION = /^(?:what|which|who|when|where|why|how|is|are|was|do|does|did|can|could|will|would|should|has|have|any)(?:n[’']t)?\b/i;
57
+ const DO_OR_HAVE = /^(?:do|have)\s+(?!(?:you|we|i|they|he|she|it|any|all|both|our|my|your|their)\b)/i;
58
+ const NEGATED = /^[a-z]+n[’']t\b/i;
59
+ const STATEMENT = /^(?:what|which|when|where|why|how)\s+(?:i|we|you|they|he|she)\b/i;
60
+ const FACTS = /\b(?:(?:tell me|let me know)\s+(?:what|which|who|whose|where|why|how|whether|about)|find out|look up|show me|how many|how much|i want to know|do you know|check (?:whether|if))\b/i;
61
+ const WORK = String.raw `(?:fix|add|make|run|deploy|write|create|update|change|remove|commit|push|merge|install|set\s+up|try)`;
62
+ const WORK_ASKED = new RegExp(String.raw `\b(?:can|could|would|will)\s+you\s+(?:please\s+|just\s+|also\s+|quickly\s+|now\s+)?${WORK}\b`, 'i');
63
+ const WORK_FIRST = new RegExp(String.raw `^${WORK}\b`, 'i');
64
+ const WORK_LATER = new RegExp(String.raw `(?:[,;:]\s*|\b(?:and|then)\s+)${WORK}\b`, 'i');
65
+ const sentencesOf = (text) => text
66
+ .split(/(?<=[.!?])\s+|\n+/)
67
+ .map((sentence) => sentence.trim())
68
+ .filter(Boolean);
69
+ function asks(sentence) {
70
+ const said = opening(sentence);
71
+ if (!said)
72
+ return false;
73
+ if (WORK_ASKED.test(said))
74
+ return false;
75
+ const facts = FACTS.test(said);
76
+ if (WORK_FIRST.test(said) && !facts)
77
+ return false;
78
+ if (said.includes('?'))
79
+ return true;
80
+ if (facts)
81
+ return true;
82
+ if (WORK_LATER.test(said))
83
+ return false;
84
+ return (QUESTION.test(said) &&
85
+ !DO_OR_HAVE.test(said) &&
86
+ !NEGATED.test(said) &&
87
+ !STATEMENT.test(said) &&
88
+ wordsOf(said).length >= 4);
89
+ }
90
+ export function askedQuestion(message) {
91
+ const plain = spoken(message);
92
+ const said = opening(plain);
93
+ if (!said.includes('?') && wordsOf(said).length < 4)
94
+ return false;
95
+ const sentences = sentencesOf(plain);
96
+ const looked = [...new Set([sentences[0], ...sentences.slice(-3)])].filter((sentence) => sentence !== undefined);
97
+ return looked.some(asks);
98
+ }
99
+ export function asksQuestion(message) {
100
+ const words = typeof message === 'string' ? ownWords(message) : null;
101
+ return words !== null && askedQuestion(words);
102
+ }
@@ -40,7 +40,7 @@ const KEEP_MS = 7 * 24 * 60 * 60_000;
40
40
  const STDIN_MAX = 8_000_000;
41
41
  const TRANSCRIPT_CAP = 4 * 1024 * 1024;
42
42
  const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
43
- const NAMED_TOOL = /^mcp__.*pendingyou.*__(whoami|post_request|update_request|cancel_request|reply_in_thread|get_request|ack_answer|list_pending|get_attachment)$/i;
43
+ const NAMED_TOOL = /^mcp__.*pendingyou.*__(whoami|post_request|post_answer|update_request|cancel_request|reply_in_thread|get_request|ack_answer|list_pending|get_attachment)$/i;
44
44
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
45
45
  const folderOf = (io) => join(configDir(io), PERMISSION_FOLDER);
46
46
  const pathOf = (io, session) => join(folderOf(io), `${session}.json`);
@@ -623,7 +623,7 @@ export async function sessionName(path) {
623
623
  await file.close().catch(() => { });
624
624
  }
625
625
  }
626
- const POST_TOOL = /^mcp__.*pendingyou.*__post_request$/i;
626
+ const POST_TOOL = /^mcp__.*pendingyou.*__(post_request|post_answer)$/i;
627
627
  const POSTED_ID = /"requestId"\s*:\s*"(req_[A-Za-z0-9-]{1,40})"/;
628
628
  export const POSTED_MAX = 50;
629
629
  function resultText(content) {
@@ -650,9 +650,10 @@ export async function sessionPosted(path) {
650
650
  const results = new Map();
651
651
  const posted = [];
652
652
  let more = false;
653
+ let answeredAt;
653
654
  const read = (line) => {
654
655
  const asResult = line.includes('tool_result') && line.includes('req_');
655
- const asCall = line.includes('post_request');
656
+ const asCall = line.includes('post_request') || line.includes('post_answer');
656
657
  if (!asResult && !asCall)
657
658
  return;
658
659
  let entry;
@@ -678,7 +679,14 @@ export async function sessionPosted(path) {
678
679
  block.type === 'tool_use' &&
679
680
  POST_TOOL.test(text(block.name))) {
680
681
  const id = results.get(text(block.id));
681
- if (!id || posted.includes(id))
682
+ if (!id)
683
+ continue;
684
+ const at = Date.parse(text(entry.timestamp));
685
+ if (answeredAt === undefined &&
686
+ /post_answer$/i.test(text(block.name)) &&
687
+ Number.isFinite(at))
688
+ answeredAt = at;
689
+ if (posted.includes(id))
682
690
  continue;
683
691
  if (posted.length < POSTED_MAX)
684
692
  posted.push(id);
@@ -703,7 +711,11 @@ export async function sessionPosted(path) {
703
711
  }
704
712
  if (position === 0)
705
713
  read(carry.toString('utf8'));
706
- return { posted, whole: position === 0 && !more };
714
+ return {
715
+ posted,
716
+ whole: position === 0 && !more,
717
+ ...(answeredAt !== undefined ? { answeredAt } : {}),
718
+ };
707
719
  }
708
720
  catch {
709
721
  return null;
package/dist/pickup.js CHANGED
@@ -1,6 +1,7 @@
1
- import { getJson, SignInNeeded, Unavailable } from "./api.js";
2
- import { computerNames, hearsOnly, keepListening, markHandedTurn, sessionFor, threadCards, threadNames, touchThread, } from "./apps/codex-wake.js";
1
+ import { getJson, postJson, SignInNeeded, Unavailable } from "./api.js";
2
+ import { computerNames, hearsOnly, keepListening, markAskedTurn, markHandedTurn, sessionFor, threadAnsweredAt, threadCards, threadNames, touchThread, } from "./apps/codex-wake.js";
3
3
  import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
4
+ import { asksQuestion, ownWords } from "./asked.js";
4
5
  import { connectionMachine, readCredential } from "./credentials.js";
5
6
  import { environmentWarning } from "./environment.js";
6
7
  import { configDir, readJson } from "./files.js";
@@ -16,6 +17,8 @@ import { announce, keepPresent, readSaid } from "./presence.js";
16
17
  import { setupReminder } from "./setup.js";
17
18
  import { claimHanded, markHanded, momentOf, releaseHanded, wasHanded } from "./state.js";
18
19
  const TIME_BOX_MS = { pickup: 2500, handoff: 2000 };
20
+ export const READ_PATH = '/mcp/cli/read';
21
+ export const READ_FOR_MS = 24 * 60 * 60_000;
19
22
  export const ANSWERS_ASKED = 20;
20
23
  export const ANSWERS_SHOWN = 5;
21
24
  export const REMINDER = 'Pending You: anything you need from your person also goes on a card.';
@@ -113,29 +116,54 @@ export function folderForms(cwd, home) {
113
116
  forms.push(`~${cwd.slice(base.length)}`);
114
117
  return forms;
115
118
  }
116
- export async function askerParams(io, origin, app, session) {
119
+ export async function askerOf(io, origin, app, session) {
117
120
  let name = session.name ?? undefined;
118
121
  let posted = [];
119
122
  let own = false;
123
+ let answeredAt = null;
120
124
  if (app === DEFAULT_APP) {
121
125
  const record = await sessionPosted(session.transcript ?? undefined);
122
126
  if (record) {
123
127
  posted = record.posted;
124
128
  own = record.whole;
129
+ answeredAt = record.answeredAt ?? null;
125
130
  }
126
131
  }
127
132
  else if (session.id) {
128
133
  name ??= (await threadNames(io, app, session.id).catch(() => [])).at(-1);
129
134
  posted = (await threadCards(io, app, session.id).catch(() => [])).slice(0, POSTED_MAX);
135
+ answeredAt = await threadAnsweredAt(io, app, session.id).catch(() => null);
130
136
  }
131
137
  const machine = await connectionMachine(io, origin, app).catch(() => null);
132
138
  const machines = [...new Set([io.host, machine].filter((each) => Boolean(each)))];
133
- return [
134
- ...(name ? [['name', name]] : []),
135
- ...posted.map((id) => ['posted', id]),
136
- ...(own ? [['own', '1']] : []),
137
- ...machines.slice(0, 4).map((each) => ['machine', each]),
138
- ];
139
+ return {
140
+ params: [
141
+ ...(name ? [['name', name]] : []),
142
+ ...posted.map((id) => ['posted', id]),
143
+ ...(own ? [['own', '1']] : []),
144
+ ...machines.slice(0, 4).map((each) => ['machine', each]),
145
+ ],
146
+ answeredAt,
147
+ };
148
+ }
149
+ export const askerParams = async (io, origin, app, session) => (await askerOf(io, origin, app, session)).params;
150
+ export const readsAnswers = (asker, prompt, now) => asker.answeredAt !== null &&
151
+ now - asker.answeredAt < READ_FOR_MS &&
152
+ (prompt === null || ownWords(prompt) !== null);
153
+ async function readHere(io, origin, app, cwd, asker) {
154
+ try {
155
+ const query = new URLSearchParams([
156
+ ...folderForms(cwd, io.home).map((form) => ['cwd', form]),
157
+ ['source', app],
158
+ ...asker.params,
159
+ ['at', new Date(io.now()).toISOString()],
160
+ ]);
161
+ await postJson(io, origin, `${READ_PATH}?${query}`, {}, TIME_BOX_MS.handoff, {
162
+ app,
163
+ signal: io.signal,
164
+ });
165
+ }
166
+ catch { }
139
167
  }
140
168
  export async function pickup(io, options, hook = { fallback: '' }) {
141
169
  const { origin, mode } = options;
@@ -145,6 +173,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
145
173
  let reminder = null;
146
174
  let unwoken = null;
147
175
  let stuck = null;
176
+ let reading = null;
148
177
  try {
149
178
  const input = await hookInput(io);
150
179
  const { cwd } = input;
@@ -186,6 +215,8 @@ export async function pickup(io, options, hook = { fallback: '' }) {
186
215
  await touchThread(io, app, input.session, input.given ? input.cwd : null);
187
216
  if (app === 'codex' && input.turn && isHandedWake(input.prompt ?? ''))
188
217
  await markHandedTurn(io, input.turn, app).catch(() => { });
218
+ else if (app === 'codex' && mode === 'handoff' && input.turn && asksQuestion(input.prompt))
219
+ await markAskedTurn(io, input.turn, app).catch(() => { });
189
220
  }
190
221
  if (app === 'codex' && input.session)
191
222
  await keepListening(io, origin, input.session);
@@ -193,16 +224,19 @@ export async function pickup(io, options, hook = { fallback: '' }) {
193
224
  ? sessionName(input.transcript ?? undefined).then((name) => (name ? [name] : []))
194
225
  : null;
195
226
  const named = claudeNames ? (await claudeNames)[0] : undefined;
227
+ const asker = await askerOf(io, origin, app, {
228
+ id: input.session,
229
+ transcript: input.transcript,
230
+ name: named ?? null,
231
+ });
232
+ if (mode === 'handoff' && readsAnswers(asker, input.prompt, io.now()))
233
+ reading = readHere(io, origin, app, cwd, asker);
196
234
  const query = new URLSearchParams([
197
235
  ...folderForms(cwd, io.home).map((form) => ['cwd', form]),
198
236
  ['limit', String(ANSWERS_ASKED)],
199
237
  ...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
200
238
  ['source', app],
201
- ...(await askerParams(io, origin, app, {
202
- id: input.session,
203
- transcript: input.transcript,
204
- name: named ?? null,
205
- })),
239
+ ...asker.params,
206
240
  ]);
207
241
  const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, TIME_BOX_MS[mode], io.signal, {
208
242
  hook: true,
@@ -309,5 +343,6 @@ export async function pickup(io, options, hook = { fallback: '' }) {
309
343
  const last = [line, warning, reminder, unwoken, stuck].filter(Boolean);
310
344
  if (last.length)
311
345
  io.out(`${last.join('\n')}\n`);
346
+ await reading;
312
347
  }
313
348
  }
@@ -177,6 +177,7 @@ export function createClient(options) {
177
177
  const keyed = (given) => ({ idempotencyKey: given ?? newKey() });
178
178
  const client = {
179
179
  me: (opts) => request('GET', '/v1/me', opts),
180
+ snapshot: (opts) => request('GET', '/v1/snapshot', opts),
180
181
  cards: {
181
182
  list: (query, opts) => request('GET', '/v1/cards', {
182
183
  ...opts,
package/dist/stopcheck.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { open } from 'node:fs/promises';
2
2
  import { getJson } from "./api.js";
3
+ import { codexSeesAnswers } from "./apps/codex.js";
3
4
  import { markNudged, turnRecord } from "./apps/codex-wake.js";
4
5
  import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
6
+ import { asksQuestion, wordsOf } from "./asked.js";
5
7
  import { asksToTypeHere, cardFits, confirmReason, sessionLink } from "./confirm.js";
6
8
  import { readCredential } from "./credentials.js";
7
9
  import { isHandedWake } from "./format.js";
@@ -11,7 +13,9 @@ export const READ_CAP = 4 * 1024 * 1024;
11
13
  const CHUNK = 64 * 1024;
12
14
  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.';
13
15
  export const DELEGATED_REASON = 'Your person handed you that question to answer, not to ask them back: answer_delegated if you know, or hand_back with what you checked, unless you already did. Never post it as a card of your own.';
14
- const POSTING_TOOL = /^mcp__.+__(post_request|update_request|reply_in_thread|answer_delegated|hand_back)$/;
16
+ export const ANSWER_REASON = 'Your person asked you something and your answer is only in chat: post_answer it too, or say it doesn’t need one.';
17
+ export const ANSWER_WORDS = 30;
18
+ const POSTING_TOOL = /^mcp__.+__(post_request|post_answer|update_request|reply_in_thread|answer_delegated|hand_back)$/;
15
19
  export const OPEN_TIME_BOX_MS = 1500;
16
20
  const OPEN_CARDS = 5;
17
21
  const CARD_TEXT = 20_000;
@@ -35,7 +39,7 @@ const CARDS_HEADING = /\b(?:your|open|pending|waiting)\s+(?:(?:open|pending|wait
35
39
  const SENTENCE_END = /(?<=[.!?])\s+/;
36
40
  const OTHER_CARDS = /\b(?:credit|debit|payment|gift|sim|sd|business|graphics|video|sound|network|memory|smart)\s+cards?\b|\bcards?\s+(?:numbers?|readers?|details|holders?)\b/gi;
37
41
  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;
38
- 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;
42
+ const SAYS_NO_CARD = /\bno (?:new |more |further |answer )?cards? (?:is |are )?(?:needed|necessary|required)\b|\b(?:doesn(?:'|’)t|don(?:'|’)t|does not|do not|won(?:'|’)t|will not) need (?:a |an |any )?(?:new |answer )?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;
39
43
  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;
40
44
  const LIST_ITEM = /^\s*(?:\d+[.)]|[-*•]|\[ \])\s+\S/;
41
45
  const YOU = /\byou(?:r|rs|(?:'|’)(?:ll|d|re|ve))?\b/i;
@@ -123,6 +127,36 @@ function withoutRecaps(plain) {
123
127
  export function itemsOutsideRecaps(text) {
124
128
  return itemsIn(withoutRecaps(plainText(text)));
125
129
  }
130
+ function withoutCode(text) {
131
+ const kept = [];
132
+ let fence = null;
133
+ for (const line of text.split('\n')) {
134
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
135
+ if (fence) {
136
+ if (marker?.startsWith(fence))
137
+ fence = null;
138
+ continue;
139
+ }
140
+ if (marker) {
141
+ fence = marker;
142
+ continue;
143
+ }
144
+ kept.push(line.replace(/`[^`]*`/g, ' '));
145
+ }
146
+ return kept.join('\n');
147
+ }
148
+ function hasTable(text) {
149
+ const lines = text.split('\n');
150
+ return lines.some((line, index) => index > 0 &&
151
+ line.includes('|') &&
152
+ line.includes('-') &&
153
+ /^[\s|:-]+$/.test(line) &&
154
+ (lines[index - 1] ?? '').includes('|'));
155
+ }
156
+ export function isAnswer(text) {
157
+ const said = withoutRecaps(withoutCode(text));
158
+ return wordsOf(said).length >= ANSWER_WORDS || hasTable(said);
159
+ }
126
160
  export const saysPosted = (text) => SAYS_POSTED.test(plainText(text));
127
161
  export const saysNoCard = (text) => SAYS_NO_CARD.test(plainText(text));
128
162
  export const mentionsCards = (text) => /\bcards?\b/i.test(plainText(text).replace(OTHER_CARDS, ''));
@@ -176,6 +210,7 @@ export async function lastTurn(path, cap = READ_CAP) {
176
210
  let finalDone = false;
177
211
  let posted = false;
178
212
  let handed = false;
213
+ let asked = false;
179
214
  const cards = [];
180
215
  const take = (raw) => {
181
216
  if (!raw.trim())
@@ -190,7 +225,9 @@ export async function lastTurn(path, cap = READ_CAP) {
190
225
  if (entry.isSidechain)
191
226
  return false;
192
227
  if (startsTurn(entry)) {
193
- handed = startsHanded(startText(entry));
228
+ const words = startText(entry);
229
+ handed = startsHanded(words);
230
+ asked = !handed && asksQuestion(words);
194
231
  return true;
195
232
  }
196
233
  if (entry.type !== 'assistant' || !Array.isArray(entry.message?.content))
@@ -224,21 +261,29 @@ export async function lastTurn(path, cap = READ_CAP) {
224
261
  let newline = buffer.lastIndexOf(10);
225
262
  while (newline !== -1) {
226
263
  if (take(buffer.subarray(newline + 1).toString('utf8')))
227
- return { text: texts.join('\n'), posted, cards, handed, complete: true, bytesRead };
264
+ return { text: texts.join('\n'), posted, cards, handed, asked, complete: true, bytesRead };
228
265
  buffer = buffer.subarray(0, newline);
229
266
  newline = buffer.lastIndexOf(10);
230
267
  }
231
268
  carry = buffer;
232
269
  }
233
270
  if (position === 0 && take(carry.toString('utf8')))
234
- return { text: texts.join('\n'), posted, cards, handed, complete: true, bytesRead };
235
- return { text: texts.join('\n'), posted, cards, handed, complete: position === 0, bytesRead };
271
+ return { text: texts.join('\n'), posted, cards, handed, asked, complete: true, bytesRead };
272
+ return {
273
+ text: texts.join('\n'),
274
+ posted,
275
+ cards,
276
+ handed,
277
+ asked,
278
+ complete: position === 0,
279
+ bytesRead,
280
+ };
236
281
  }
237
282
  finally {
238
283
  await file.close();
239
284
  }
240
285
  }
241
- const OPENCODE_POSTING = /^[A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*_(post_request|update_request|reply_in_thread|answer_delegated|hand_back)$/i;
286
+ const OPENCODE_POSTING = /^[A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*_(post_request|post_answer|update_request|reply_in_thread|answer_delegated|hand_back)$/i;
242
287
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
243
288
  function opencodeTurn(input) {
244
289
  const messages = Array.isArray(input.messages) ? input.messages.filter(isObject) : [];
@@ -252,6 +297,17 @@ function opencodeTurn(input) {
252
297
  part.type === 'text' &&
253
298
  typeof part.text === 'string' &&
254
299
  startsHanded(part.text));
300
+ const own = partsOf(first).some((part) => part.pendingyou === 'wake')
301
+ ? ''
302
+ : partsOf(first)
303
+ .flatMap((part) => part.type === 'text' &&
304
+ part.synthetic !== true &&
305
+ part.pendingyou === undefined &&
306
+ typeof part.text === 'string'
307
+ ? [part.text]
308
+ : [])
309
+ .join('\n');
310
+ const asked = !handed && asksQuestion(own);
255
311
  const answers = rest.filter((message) => message.role === 'assistant');
256
312
  if (answers.at(-1)?.error === 'MessageAbortedError')
257
313
  return null;
@@ -273,7 +329,7 @@ function opencodeTurn(input) {
273
329
  final.push(part.text);
274
330
  }
275
331
  const text = final.join('\n').trim();
276
- return text ? { text, posted, cards, handed } : null;
332
+ return text ? { text, posted, cards, handed, asked } : null;
277
333
  }
278
334
  async function openTitles(io, origin, cwd, app, session) {
279
335
  try {
@@ -305,11 +361,16 @@ async function turnOf(io, input, app) {
305
361
  const record = await turnRecord(io, input.turn_id, app);
306
362
  if (record?.nudged)
307
363
  return null;
364
+ const handed = Boolean(record?.delegated) || input.handed === true;
308
365
  return {
309
366
  text: final,
310
367
  posted: Boolean(record?.cards.length),
311
368
  cards: record?.cards ?? [],
312
- handed: Boolean(record?.delegated) || input.handed === true,
369
+ handed,
370
+ asked: !handed &&
371
+ (app === 'codex'
372
+ ? record?.asked === true
373
+ : typeof input.prompt === 'string' && asksQuestion(input.prompt)),
313
374
  };
314
375
  }
315
376
  if (app === 'opencode') {
@@ -326,6 +387,7 @@ async function turnOf(io, input, app) {
326
387
  posted: turn.posted || Boolean(record?.cards.length),
327
388
  cards: [...turn.cards, ...(record?.cards ?? [])],
328
389
  handed: turn.handed,
390
+ asked: turn.asked,
329
391
  };
330
392
  }
331
393
  if (typeof input.transcript_path !== 'string' || !input.transcript_path)
@@ -334,8 +396,11 @@ async function turnOf(io, input, app) {
334
396
  if (!turn.complete)
335
397
  return null;
336
398
  const text = final || turn.text;
337
- return text ? { text, posted: turn.posted, cards: turn.cards, handed: turn.handed } : null;
399
+ return text
400
+ ? { text, posted: turn.posted, cards: turn.cards, handed: turn.handed, asked: turn.asked }
401
+ : null;
338
402
  }
403
+ const seesAnswers = (io, app) => app === 'codex' ? codexSeesAnswers(io).catch(() => false) : Promise.resolve(true);
339
404
  export async function stopcheck(io, options) {
340
405
  const app = options.app ?? DEFAULT_APP;
341
406
  try {
@@ -383,7 +448,11 @@ export async function stopcheck(io, options) {
383
448
  return 0;
384
449
  return block(confirmReason(ask.words, sessionLink(io.env), APP_NAMES[app]));
385
450
  }
386
- if (turn.posted || saysPosted(text) || saysNoCard(text) || !itemsOutsideRecaps(text))
451
+ if (turn.posted || saysPosted(text) || saysNoCard(text))
452
+ return 0;
453
+ if (turn.asked && isAnswer(text) && (await seesAnswers(io, app)))
454
+ return block(ANSWER_REASON);
455
+ if (!itemsOutsideRecaps(text))
387
456
  return 0;
388
457
  if (mentionsCards(text)) {
389
458
  const open = await openTitles(io, options.origin, cwd, app, session);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.35.1';
1
+ export const VERSION = '0.36.1';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.52.0",
4
+ "version": "2.54.1",
5
5
  "description": "Wakes Claude Code when your person answers a Pending You card it posted, even while it sits idle. Installed by `npx pendingyou init`.",
6
6
  "author": {
7
7
  "name": "RecordPlane",
package/mod/README.md CHANGED
@@ -4,4 +4,4 @@ The pendingyou command line’s copy of the Pending You plugin’s wake mod, for
4
4
 
5
5
  In each session it learns the cards the session posts to Pending You, checks them through the session’s own Pending You connection, and starts a turn when your person answers, writes back or a fallback comes due, or hands the session a question from another of their assistants (CLI 0.14.0), even while it sits idle. It answers `npx pendingyou hold` itself, so nothing waits in the background, and shows “2 waiting on you” under the prompt, after the plugin’s name. With the Pending You plugin installed too, only one copy acts.
6
6
 
7
- Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.52.0; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.54.1; send changes there.
@@ -209,13 +209,13 @@ async function deliverHanded($) {
209
209
  view = handedView(resultObject(await $.mcp.call(server, 'get_request', args)), handed, name);
210
210
  }
211
211
  catch { }
212
- const claim = await $.store.get(key);
212
+ const stored = await $.store.get(key);
213
213
  if (!view) {
214
- if (claimHeld(claim, handed.updatedAt, me))
214
+ if (claimHeld(stored, handed.updatedAt, me))
215
215
  await $.store.delete(key);
216
216
  continue;
217
217
  }
218
- if (!claimHeld(claim, handed.updatedAt, me)) {
218
+ if (!claimHeld(stored, handed.updatedAt, me)) {
219
219
  live.handedTold.set(handed.requestId, handed.updatedAt);
220
220
  continue;
221
221
  }
@@ -534,7 +534,8 @@ async function answerHold($, e) {
534
534
  if ((await check($, requestId)) !== 'ok')
535
535
  return null;
536
536
  showCards($);
537
- return { result: { stdout: holdReply(requestId), stderr: '', interrupted: false } };
537
+ const answer = live.book.cards[requestId]?.kind === 'answer';
538
+ return { result: { stdout: holdReply(requestId, answer), stderr: '', interrupted: false } };
538
539
  }
539
540
  export function register(on) {
540
541
  on('session.start', async ($, e, next) => {
@@ -564,7 +565,7 @@ export function register(on) {
564
565
  return next(e);
565
566
  });
566
567
  on('tool.call', {
567
- tool: /^mcp__.+__(post_request|get_request|update_request|reply_in_thread|ack_answer|cancel_request|whoami|list_pending|answer_delegated|hand_back)$/,
568
+ tool: /^mcp__.+__(post_request|post_answer|get_request|update_request|reply_in_thread|ack_answer|cancel_request|whoami|list_pending|answer_delegated|hand_back)$/,
568
569
  }, async ($, e, next) => {
569
570
  const result = await next(e);
570
571
  try {
package/mod/hooks/wake.js CHANGED
@@ -21,6 +21,7 @@ const URGENCIES = new Set(['now', 'today', 'whenever']);
21
21
  const isUrgency = (value) => typeof value === 'string' && URGENCIES.has(value);
22
22
  const READS = new Set([
23
23
  'post_request',
24
+ 'post_answer',
24
25
  'get_request',
25
26
  'update_request',
26
27
  'reply_in_thread',
@@ -32,6 +33,7 @@ const READS = new Set([
32
33
  'hand_back',
33
34
  ]);
34
35
  export const HANDED_READS = new Set(['get_request', 'answer_delegated', 'hand_back']);
36
+ const POSTS = new Set(['post_request', 'post_answer']);
35
37
  export const APPROVED_DRAFT_NOTICE = 'The approved words are in get_request’s answer.approvedDrafts: send them exactly.';
36
38
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
37
39
  const parseJson = (text) => {
@@ -99,7 +101,7 @@ export function resultObject(result) {
99
101
  }
100
102
  export function nameOf(tool, input) {
101
103
  const name = text(input.name, 120);
102
- if (name || tool !== 'post_request')
104
+ if (name || !POSTS.has(tool))
103
105
  return name;
104
106
  const session = typeof input.session === 'string' ? parseJson(input.session) : input.session;
105
107
  return isObject(session) ? text(session.label, 120) : undefined;
@@ -111,6 +113,7 @@ export function argumentsOf(call) {
111
113
  export const isClosed = (status) => CLOSED.has(status);
112
114
  export const isReady = (state) => state.turn === 'agent' || isClosed(state.status);
113
115
  export const isWaitingOnYou = (state) => state.turn === 'you' && OPEN.has(state.status);
116
+ const waitsOnYou = (card) => card.kind !== 'answer' && card.state !== undefined && isWaitingOnYou(card.state);
114
117
  export const momentOf = (state) => `${state.status}/${state.turn}/${state.version}/${state.cursor}`;
115
118
  export function stateOf(output) {
116
119
  const status = text(output.status, 40);
@@ -157,6 +160,7 @@ export function readBook(value) {
157
160
  ...(isUrgency(card.urgency) ? { urgency: card.urgency } : {}),
158
161
  ...(card.blocking === true ? { blocking: true } : {}),
159
162
  ...(card.askedFirst === true ? { askedFirst: true } : {}),
163
+ ...(card.kind === 'answer' ? { kind: 'answer' } : {}),
160
164
  ...(state ? { state } : {}),
161
165
  ...(typeof card.told === 'string' ? { told: card.told } : {}),
162
166
  ...(typeof card.seen === 'string' ? { seen: card.seen } : {}),
@@ -220,12 +224,14 @@ function tracked(book, facts, now) {
220
224
  const card = book.cards[facts.requestId];
221
225
  const name = facts.name ?? card?.name;
222
226
  const title = facts.title ?? card?.title;
227
+ const kind = facts.kind ?? card?.kind;
223
228
  return put(book, {
224
229
  requestId: facts.requestId,
225
230
  server: facts.server,
226
231
  ...(name ? { name } : {}),
227
232
  ...(title ? { title } : {}),
228
233
  ...(facts.pressing ?? pressingOfCard(card)),
234
+ ...(kind ? { kind } : {}),
229
235
  learnedAt: card?.learnedAt ?? now,
230
236
  changedAt: now,
231
237
  state: facts.state,
@@ -269,6 +275,22 @@ export function learn(book, call, now) {
269
275
  }, now);
270
276
  return tool === 'post_request' ? followedUp(posted, requestIdOf(input.follows), now) : posted;
271
277
  }
278
+ case 'post_answer': {
279
+ const status = text(output.status, 40);
280
+ if (!status || !OPEN.has(status) || version === undefined)
281
+ return next;
282
+ const title = text(input.title, 400);
283
+ const posted = tracked(next, {
284
+ requestId,
285
+ server,
286
+ ...(name ? { name } : {}),
287
+ ...(title ? { title: clip(title) } : {}),
288
+ pressing: {},
289
+ kind: 'answer',
290
+ state: { status, turn: 'you', version, cursor: card?.state?.cursor ?? '' },
291
+ }, now);
292
+ return followedUp(posted, requestIdOf(input.follows), now);
293
+ }
272
294
  case 'reply_in_thread': {
273
295
  const reopen = isObject(input.reopen) ? input.reopen : null;
274
296
  if (version === undefined || (!reopen && output.turn !== 'you'))
@@ -304,7 +326,9 @@ export function learn(book, call, now) {
304
326
  case 'ack_answer':
305
327
  return output.status === 'resolved' ? drop(next, requestId) : next;
306
328
  case 'cancel_request':
307
- return output.status === 'cancelled' ? drop(next, requestId) : next;
329
+ return output.status === 'cancelled' || output.status === 'resolved'
330
+ ? drop(next, requestId)
331
+ : next;
308
332
  default:
309
333
  return next;
310
334
  }
@@ -332,15 +356,22 @@ export function failed(card, now, blocked) {
332
356
  nextAt: now + Math.min(MAX_BACKOFF_MS, FAST_MS * 2 ** (failures - 1)),
333
357
  };
334
358
  }
359
+ export const ANSWER_UNDO_MS = 20_000;
360
+ const mayComeBack = (card) => card.kind === 'answer' &&
361
+ card.state?.status === 'resolved' &&
362
+ !(card.checkedAt !== undefined && card.checkedAt - card.changedAt >= ANSWER_UNDO_MS);
335
363
  export function dueCards(book, now) {
336
364
  return Object.values(book.cards)
337
- .filter((card) => !(card.state && isClosed(card.state.status)) && (card.nextAt ?? 0) <= now)
365
+ .filter((card) => !(card.state && isClosed(card.state.status) && !mayComeBack(card)) &&
366
+ (card.nextAt ?? 0) <= now)
338
367
  .sort((a, b) => (a.nextAt ?? 0) - (b.nextAt ?? 0));
339
368
  }
340
369
  export function needsTelling(card) {
341
370
  const state = card.state;
342
371
  if (!state || !isReady(state))
343
372
  return false;
373
+ if (card.kind === 'answer' && isClosed(state.status))
374
+ return false;
344
375
  if (state.status === 'resolved')
345
376
  return false;
346
377
  const moment = momentOf(state);
@@ -377,7 +408,7 @@ export function untold(book, before) {
377
408
  export function settle(book, now) {
378
409
  let next = book;
379
410
  for (const card of Object.values(book.cards)) {
380
- const done = card.state && isClosed(card.state.status) && !needsTelling(card);
411
+ const done = card.state && isClosed(card.state.status) && !needsTelling(card) && !mayComeBack(card);
381
412
  if (done || now - Math.max(card.learnedAt, card.changedAt) > KEEP_MS)
382
413
  next = drop(next, card.requestId);
383
414
  }
@@ -389,6 +420,11 @@ const named = (card) => (card.name ? `name “${card.name}”` : 'your name');
389
420
  function line(card) {
390
421
  const state = card.state;
391
422
  const get = `get_request (requestId ${card.requestId}, ${named(card)})`;
423
+ if (card.kind === 'answer')
424
+ return [
425
+ `your person replied to your answer ${titled(card)}`,
426
+ `call ${get}, act on their words, then ack_answer with its version and a one-line outcome (it reaches them as your reply), or post_answer with follows: its requestId for a long one`,
427
+ ];
392
428
  switch (state.status) {
393
429
  case 'expired':
394
430
  return [
@@ -449,7 +485,7 @@ export function wakeText(cards) {
449
485
  export function statusText(book, blocked) {
450
486
  if (blocked)
451
487
  return `to hear answers here, allow mcp__${blocked} in /permissions`;
452
- const waiting = Object.values(book.cards).filter((card) => card.state && isWaitingOnYou(card.state)).length;
488
+ const waiting = Object.values(book.cards).filter(waitsOnYou).length;
453
489
  return waiting ? `${waiting} waiting on you` : undefined;
454
490
  }
455
491
  export const blockedNote = (server) => `Pending You can’t wake this session when your person answers: Claude Code doesn’t let it check your cards yet. Ask them to allow mcp__${server} in /permissions (Allow, user settings). Until then, check get_request at breakpoints.`;
@@ -572,7 +608,7 @@ export function holdOf(command) {
572
608
  }
573
609
  return held ? requestId : null;
574
610
  }
575
- export const holdReply = (requestId) => `Pending You: no hold is needed in this session. It’s woken when your person answers ${requestId}, writes to you on it, or its fallback runs, even while it’s idle. Nothing is running in the background and there’s nothing to wait on: keep working, or end your turn.`;
611
+ export const holdReply = (requestId, answer = false) => `Pending You: no hold is needed in this session. It’s woken when your person ${answer ? `replies to your answer ${requestId}` : `answers ${requestId}, writes to you on it, or its fallback runs`}, even while it’s idle. Nothing is running in the background and there’s nothing to wait on: keep working, or end your turn.`;
576
612
  function leaseOf(value) {
577
613
  const match = /^(\S+) (\d+)$/.exec(value ?? '');
578
614
  return match ? { holder: match[1], until: Number(match[2]) } : null;
@@ -677,7 +713,7 @@ export function graceEnds(handed, folders) {
677
713
  return Number.isFinite(at) ? at + HANDED_GRACE_MS : null;
678
714
  }
679
715
  export function folderOf(tool, input) {
680
- if (tool !== 'post_request')
716
+ if (!POSTS.has(tool))
681
717
  return undefined;
682
718
  const session = typeof input.session === 'string' ? parseJson(input.session) : input.session;
683
719
  return isObject(session) ? text(session.cwd, 1000) : undefined;
@@ -919,7 +955,7 @@ export function inHerdr(on, bin, pane) {
919
955
  }
920
956
  export function herdrCards(book) {
921
957
  return Object.values(book.cards)
922
- .filter((card) => card.state && isWaitingOnYou(card.state))
958
+ .filter(waitsOnYou)
923
959
  .sort((a, b) => (a.requestId < b.requestId ? -1 : a.requestId > b.requestId ? 1 : 0))
924
960
  .map((card) => ({
925
961
  requestId: card.requestId,
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.35.1",
3
+ "version": "0.36.1",
4
4
  "description": "Pending You for your coding agents: one command sets up Claude Code, Codex, OpenCode and Pi to ask you through Pending You and hear your answers, even while they sit idle.",
5
+ "keywords": [
6
+ "pendingyou",
7
+ "claude-code",
8
+ "codex",
9
+ "opencode",
10
+ "pi",
11
+ "mcp",
12
+ "human-in-the-loop",
13
+ "ai-agents"
14
+ ],
5
15
  "license": "Apache-2.0",
6
16
  "homepage": "https://www.pendingyou.com",
7
17
  "repository": {