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 +35 -1
- package/README.md +22 -0
- package/dist/apps/codex-wake.js +31 -3
- package/dist/apps/codex.js +18 -1
- package/dist/apps/opencode-plugin.js +1 -1
- package/dist/apps/pi-extension.js +6 -2
- package/dist/asked.js +102 -0
- package/dist/permission.js +17 -5
- package/dist/pickup.js +49 -14
- package/dist/sdk/client.js +1 -0
- package/dist/stopcheck.js +80 -11
- package/dist/version.js +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/README.md +1 -1
- package/mod/hooks/register.js +6 -5
- package/mod/hooks/wake.js +44 -8
- package/package.json +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
|
package/dist/apps/codex-wake.js
CHANGED
|
@@ -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)
|
|
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 [
|
package/dist/apps/codex.js
CHANGED
|
@@ -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 (
|
|
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
|
+
}
|
package/dist/permission.js
CHANGED
|
@@ -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.*
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
...
|
|
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
|
}
|
package/dist/sdk/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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)
|
|
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.
|
|
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.
|
|
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.
|
|
7
|
+
Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.54.1; send changes there.
|
package/mod/hooks/register.js
CHANGED
|
@@ -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
|
|
212
|
+
const stored = await $.store.get(key);
|
|
213
213
|
if (!view) {
|
|
214
|
-
if (claimHeld(
|
|
214
|
+
if (claimHeld(stored, handed.updatedAt, me))
|
|
215
215
|
await $.store.delete(key);
|
|
216
216
|
continue;
|
|
217
217
|
}
|
|
218
|
-
if (!claimHeld(
|
|
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
|
-
|
|
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
|
|
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'
|
|
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)
|
|
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(
|
|
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
|
|
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(
|
|
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.
|
|
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": {
|