pendingyou 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +89 -23
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/confirm.js +231 -0
- package/dist/credentials.js +39 -1
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -17
- package/dist/index.js +1 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +195 -35
- package/dist/mod.js +87 -0
- package/dist/oauth.js +27 -6
- package/dist/pickup.js +16 -18
- package/dist/qr.js +365 -0
- package/dist/remote.js +306 -0
- package/dist/state.js +5 -32
- package/dist/stopcheck.js +88 -8
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +314 -0
- package/mod/hooks/wake.js +599 -0
- package/mod/tests/wake.test.ts +198 -0
- package/package.json +11 -4
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// Generated from packages/claude-plugin/src/wake/wake.ts in recordplane/pendingyou: edit that, then run
|
|
2
|
+
// `pnpm --filter @pendingyou/claude-plugin generate`.
|
|
3
|
+
|
|
4
|
+
// The Pending You wake mod's rules, as plain functions with no Claude Code in them, so every rule has a test
|
|
5
|
+
// (test/wake.test.ts). register.ts calls Claude Code and keeps its state; src/plugin.ts ships both as JavaScript in the
|
|
6
|
+
// plugin's hooks/ and in the pendingyou command line's mod/.
|
|
7
|
+
//
|
|
8
|
+
// In one Claude Code session (2.1.287 and later) the mod:
|
|
9
|
+
// - learns the session's own cards from its Pending You tool calls: post_request's requestId, update_request,
|
|
10
|
+
// reply_in_thread with reopen (or any reply that leaves the card the person's turn), and get_request of a card still
|
|
11
|
+
// waiting on them; it forgets a card after its ack_answer or cancel_request, or once it's closed;
|
|
12
|
+
// - answers `npx pendingyou hold <id>` itself, so nothing runs in the background and nothing has to be restarted;
|
|
13
|
+
// - checks each card with get_request over the session's own connection, every 20 seconds while it changed in the
|
|
14
|
+
// last 30 minutes, then every minute, backing off on errors;
|
|
15
|
+
// - starts one turn when it's the agent's move (an answer, a message, a fallback that ran, a card closed under it),
|
|
16
|
+
// once per change, several cards in one prompt;
|
|
17
|
+
// - shows "N waiting on you" under the prompt (Claude Code puts the plugin's name in front: "⚠ pending-you: 1 waiting
|
|
18
|
+
// on you").
|
|
19
|
+
/** How often register.ts looks at what's due. */
|
|
20
|
+
export const TICK_MS = 5000;
|
|
21
|
+
/** Each card is checked every 20 seconds while it changed in the last 30 minutes, then every minute. */
|
|
22
|
+
export const FAST_MS = 20_000;
|
|
23
|
+
export const SLOW_MS = 60_000;
|
|
24
|
+
export const RECENT_MS = 30 * 60_000;
|
|
25
|
+
/** A check that failed is tried again after 20 seconds, doubling each time, up to 15 minutes. */
|
|
26
|
+
export const MAX_BACKOFF_MS = 15 * 60_000;
|
|
27
|
+
/** The soonest it looks again on Pending You's hint (pollAfterSeconds). */
|
|
28
|
+
export const HINT_FLOOR_MS = 5000;
|
|
29
|
+
/** While Claude Code won't let the mod check (the tool isn't allowed), it tries again every minute. */
|
|
30
|
+
export const BLOCKED_MS = 60_000;
|
|
31
|
+
/** A card, or a session's whole list, untouched for 7 days is forgotten. */
|
|
32
|
+
export const KEEP_MS = 7 * 24 * 60 * 60_000;
|
|
33
|
+
/** The most cards one session keeps; the oldest go first. */
|
|
34
|
+
export const MAX_CARDS = 50;
|
|
35
|
+
/** The lease that keeps one copy of the mod acting in a session's process, and how often its holder renews it. */
|
|
36
|
+
export const LEASE_MS = 60_000;
|
|
37
|
+
export const RENEW_MS = 20_000;
|
|
38
|
+
/** The most of a card's title a prompt or the status line repeats. */
|
|
39
|
+
export const TITLE_CHARS = 80;
|
|
40
|
+
const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
|
|
41
|
+
const OPEN = new Set(['pending', 'snoozed', 'delegated']);
|
|
42
|
+
const CLOSED = new Set(['resolved', 'cancelled', 'expired']);
|
|
43
|
+
/** The tools the mod reads: the card tools, and two more that say which name and server the session uses. */
|
|
44
|
+
const READS = new Set([
|
|
45
|
+
'post_request',
|
|
46
|
+
'get_request',
|
|
47
|
+
'update_request',
|
|
48
|
+
'reply_in_thread',
|
|
49
|
+
'ack_answer',
|
|
50
|
+
'cancel_request',
|
|
51
|
+
'whoami',
|
|
52
|
+
'list_pending',
|
|
53
|
+
]);
|
|
54
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
55
|
+
const parseJson = (text) => {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const text = (value, max = 200) => typeof value === 'string' && value.trim() && value.length <= max ? value.trim() : undefined;
|
|
64
|
+
const count = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
65
|
+
const requestIdOf = (value) => typeof value === 'string' && REQUEST_ID.test(value) ? value : undefined;
|
|
66
|
+
/** A title cut to TITLE_CHARS on one line, with its double quotes made single. */
|
|
67
|
+
export function clip(title) {
|
|
68
|
+
const flat = title.replace(/\s+/g, ' ').trim().replaceAll('“', '‘').replaceAll('”', '’');
|
|
69
|
+
const chars = [...flat];
|
|
70
|
+
return chars.length <= TITLE_CHARS
|
|
71
|
+
? flat
|
|
72
|
+
: `${chars
|
|
73
|
+
.slice(0, TITLE_CHARS - 1)
|
|
74
|
+
.join('')
|
|
75
|
+
.trimEnd()}…`;
|
|
76
|
+
}
|
|
77
|
+
/** Whether an MCP server is a Pending You, whatever it's called (`pendingyou`, the plugin's, a claude.ai connector). */
|
|
78
|
+
export const isPendingYou = (server) => server
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[^a-z0-9]/g, '')
|
|
81
|
+
.includes('pendingyou');
|
|
82
|
+
/** `mcp__<server>__<tool>` for a Pending You tool the mod reads; null for any other tool. */
|
|
83
|
+
export function cardTool(name) {
|
|
84
|
+
const match = /^mcp__(.+)__([a-z_]+)$/.exec(name);
|
|
85
|
+
if (!match)
|
|
86
|
+
return null;
|
|
87
|
+
const [, server = '', tool = ''] = match;
|
|
88
|
+
return READS.has(tool) && isPendingYou(server) ? { server, tool } : null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The JSON object a Pending You tool answered with: a `tool.call` result's `text` (or a string `result`), or an MCP
|
|
92
|
+
* result's `structuredContent` or text blocks. Null for a refusal, an error, or words that aren't JSON.
|
|
93
|
+
*/
|
|
94
|
+
export function resultObject(result) {
|
|
95
|
+
if (!isObject(result) || result.deny !== undefined || result.isError === true)
|
|
96
|
+
return null;
|
|
97
|
+
if (isObject(result.structuredContent))
|
|
98
|
+
return result.structuredContent;
|
|
99
|
+
const texts = [];
|
|
100
|
+
const blocks = (value) => {
|
|
101
|
+
if (Array.isArray(value))
|
|
102
|
+
for (const block of value)
|
|
103
|
+
if (isObject(block) && typeof block.text === 'string')
|
|
104
|
+
texts.push(block.text);
|
|
105
|
+
};
|
|
106
|
+
if (typeof result.text === 'string')
|
|
107
|
+
texts.push(result.text);
|
|
108
|
+
if (typeof result.result === 'string')
|
|
109
|
+
texts.push(result.result);
|
|
110
|
+
else if (isObject(result.result)) {
|
|
111
|
+
if (isObject(result.result.structuredContent))
|
|
112
|
+
return result.result.structuredContent;
|
|
113
|
+
blocks(result.result.content);
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
blocks(result.result);
|
|
117
|
+
blocks(result.content);
|
|
118
|
+
for (const each of texts) {
|
|
119
|
+
const parsed = parseJson(each);
|
|
120
|
+
if (isObject(parsed))
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/** The agent's name on a call: `name`, or post_request's `session.label` (an object, or the same as JSON text). */
|
|
126
|
+
export function nameOf(tool, input) {
|
|
127
|
+
const name = text(input.name, 120);
|
|
128
|
+
if (name || tool !== 'post_request')
|
|
129
|
+
return name;
|
|
130
|
+
const session = typeof input.session === 'string' ? parseJson(input.session) : input.session;
|
|
131
|
+
return isObject(session) ? text(session.label, 120) : undefined;
|
|
132
|
+
}
|
|
133
|
+
/** A tool call's own arguments: the call without the keys Claude Code adds beside them. */
|
|
134
|
+
export function argumentsOf(call) {
|
|
135
|
+
const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...rest } = call;
|
|
136
|
+
return rest;
|
|
137
|
+
}
|
|
138
|
+
export const isClosed = (status) => CLOSED.has(status);
|
|
139
|
+
/** The agent's move: their answer or a message is waiting, a fallback ran, or the card closed. */
|
|
140
|
+
export const isReady = (state) => state.turn === 'agent' || isClosed(state.status);
|
|
141
|
+
export const isWaitingOnYou = (state) => state.turn === 'you' && OPEN.has(state.status);
|
|
142
|
+
/** One moment of a card: a new answer, message, status or version is a new moment. */
|
|
143
|
+
export const momentOf = (state) => `${state.status}/${state.turn}/${state.version}/${state.cursor}`;
|
|
144
|
+
/** A card's state from get_request's answer; null when it isn't one. */
|
|
145
|
+
export function stateOf(output) {
|
|
146
|
+
const status = text(output.status, 40);
|
|
147
|
+
const turn = output.turn === 'you' || output.turn === 'agent' ? output.turn : undefined;
|
|
148
|
+
const version = count(output.version);
|
|
149
|
+
if (!status || !turn || version === undefined)
|
|
150
|
+
return null;
|
|
151
|
+
return { status, turn, version, cursor: typeof output.cursor === 'string' ? output.cursor : '' };
|
|
152
|
+
}
|
|
153
|
+
export const emptyBook = (now = 0) => ({ v: 1, updatedAt: now, cards: {} });
|
|
154
|
+
/** A session's list as the store holds it, keeping only what reads as a card; empty for anything else. */
|
|
155
|
+
export function readBook(value) {
|
|
156
|
+
if (!isObject(value) || value.v !== 1 || !isObject(value.cards))
|
|
157
|
+
return emptyBook();
|
|
158
|
+
const cards = {};
|
|
159
|
+
for (const [requestId, card] of Object.entries(value.cards)) {
|
|
160
|
+
if (!isObject(card) || card.requestId !== requestId || !requestIdOf(requestId))
|
|
161
|
+
continue;
|
|
162
|
+
if (typeof card.server !== 'string' || !isPendingYou(card.server))
|
|
163
|
+
continue;
|
|
164
|
+
const learnedAt = count(card.learnedAt);
|
|
165
|
+
const changedAt = count(card.changedAt);
|
|
166
|
+
if (learnedAt === undefined || changedAt === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const state = isObject(card.state) ? stateOf(card.state) : null;
|
|
169
|
+
cards[requestId] = {
|
|
170
|
+
requestId,
|
|
171
|
+
server: card.server,
|
|
172
|
+
learnedAt,
|
|
173
|
+
changedAt,
|
|
174
|
+
...(text(card.name, 120) ? { name: text(card.name, 120) } : {}),
|
|
175
|
+
...(text(card.title, 400) ? { title: text(card.title, 400) } : {}),
|
|
176
|
+
...(state ? { state } : {}),
|
|
177
|
+
...(typeof card.told === 'string' ? { told: card.told } : {}),
|
|
178
|
+
...(typeof card.seen === 'string' ? { seen: card.seen } : {}),
|
|
179
|
+
...(count(card.failures) ? { failures: count(card.failures) } : {}),
|
|
180
|
+
...(count(card.nextAt) !== undefined ? { nextAt: count(card.nextAt) } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
v: 1,
|
|
185
|
+
updatedAt: count(value.updatedAt) ?? 0,
|
|
186
|
+
...(text(value.name, 120) ? { name: text(value.name, 120) } : {}),
|
|
187
|
+
...(typeof value.server === 'string' && isPendingYou(value.server)
|
|
188
|
+
? { server: value.server }
|
|
189
|
+
: {}),
|
|
190
|
+
cards,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** The book with this card in it, keeping MAX_CARDS (the least recently changed go). */
|
|
194
|
+
export function put(book, card) {
|
|
195
|
+
const cards = { ...book.cards, [card.requestId]: card };
|
|
196
|
+
const all = Object.values(cards);
|
|
197
|
+
if (all.length > MAX_CARDS)
|
|
198
|
+
for (const old of all
|
|
199
|
+
.sort((a, b) => a.changedAt - b.changedAt)
|
|
200
|
+
.slice(0, all.length - MAX_CARDS))
|
|
201
|
+
delete cards[old.requestId];
|
|
202
|
+
return { ...book, cards };
|
|
203
|
+
}
|
|
204
|
+
export function drop(book, requestId) {
|
|
205
|
+
if (!book.cards[requestId])
|
|
206
|
+
return book;
|
|
207
|
+
const { [requestId]: _gone, ...cards } = book.cards;
|
|
208
|
+
return { ...book, cards };
|
|
209
|
+
}
|
|
210
|
+
/** A card the session has just put in front of the person (posted, changed, reopened): checked soon. */
|
|
211
|
+
function tracked(book, facts, now) {
|
|
212
|
+
const card = book.cards[facts.requestId];
|
|
213
|
+
const name = facts.name ?? card?.name;
|
|
214
|
+
const title = facts.title ?? card?.title;
|
|
215
|
+
return put(book, {
|
|
216
|
+
requestId: facts.requestId,
|
|
217
|
+
server: facts.server,
|
|
218
|
+
...(name ? { name } : {}),
|
|
219
|
+
...(title ? { title } : {}),
|
|
220
|
+
learnedAt: card?.learnedAt ?? now,
|
|
221
|
+
changedAt: now,
|
|
222
|
+
state: facts.state,
|
|
223
|
+
nextAt: now + FAST_MS,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/** What the mod learns from one of the session's Pending You calls. Returns the same book when nothing changed. */
|
|
227
|
+
export function learn(book, call, now) {
|
|
228
|
+
const { server, tool, input, output } = call;
|
|
229
|
+
if (!output)
|
|
230
|
+
return book;
|
|
231
|
+
const name = nameOf(tool, input);
|
|
232
|
+
let next = book;
|
|
233
|
+
if (name && (book.name !== name || book.server !== server))
|
|
234
|
+
next = { ...next, name, server };
|
|
235
|
+
const requestId = requestIdOf(output.requestId) ?? requestIdOf(input.requestId);
|
|
236
|
+
if (!requestId)
|
|
237
|
+
return next;
|
|
238
|
+
const card = next.cards[requestId];
|
|
239
|
+
const version = count(output.version);
|
|
240
|
+
switch (tool) {
|
|
241
|
+
case 'post_request':
|
|
242
|
+
case 'update_request': {
|
|
243
|
+
const status = text(output.status, 40);
|
|
244
|
+
if (!status || !OPEN.has(status) || version === undefined)
|
|
245
|
+
return next;
|
|
246
|
+
const title = text(input.title, 400);
|
|
247
|
+
return tracked(next, {
|
|
248
|
+
requestId,
|
|
249
|
+
server,
|
|
250
|
+
...(name ? { name } : {}),
|
|
251
|
+
...(title ? { title: clip(title) } : {}),
|
|
252
|
+
state: { status, turn: 'you', version, cursor: card?.state?.cursor ?? '' },
|
|
253
|
+
}, now);
|
|
254
|
+
}
|
|
255
|
+
case 'reply_in_thread': {
|
|
256
|
+
const reopen = isObject(input.reopen) ? input.reopen : null;
|
|
257
|
+
if (version === undefined || (!reopen && output.turn !== 'you'))
|
|
258
|
+
return next;
|
|
259
|
+
const title = reopen ? text(reopen.title, 400) : undefined;
|
|
260
|
+
const status = reopen ? 'pending' : (card?.state?.status ?? 'pending');
|
|
261
|
+
const cursor = typeof output.messageId === 'string' ? output.messageId : '';
|
|
262
|
+
return tracked(next, {
|
|
263
|
+
requestId,
|
|
264
|
+
server,
|
|
265
|
+
...(name ? { name } : {}),
|
|
266
|
+
...(title ? { title: clip(title) } : {}),
|
|
267
|
+
state: { status: OPEN.has(status) ? status : 'pending', turn: 'you', version, cursor },
|
|
268
|
+
}, now);
|
|
269
|
+
}
|
|
270
|
+
case 'get_request': {
|
|
271
|
+
const state = stateOf(output);
|
|
272
|
+
if (!state)
|
|
273
|
+
return next;
|
|
274
|
+
if (!card)
|
|
275
|
+
return isWaitingOnYou(state)
|
|
276
|
+
? tracked(next, { requestId, server, ...(name ? { name } : {}), state }, now)
|
|
277
|
+
: next;
|
|
278
|
+
// The agent looked for itself: what it saw needs no turn of its own.
|
|
279
|
+
const moment = momentOf(state);
|
|
280
|
+
const changed = !card.state || momentOf(card.state) !== moment;
|
|
281
|
+
return put(next, {
|
|
282
|
+
...card,
|
|
283
|
+
state,
|
|
284
|
+
...(changed ? { changedAt: now } : {}),
|
|
285
|
+
...(isReady(state) ? { seen: moment } : {}),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
case 'ack_answer':
|
|
289
|
+
return output.status === 'resolved' ? drop(next, requestId) : next;
|
|
290
|
+
case 'cancel_request':
|
|
291
|
+
return output.status === 'cancelled' ? drop(next, requestId) : next;
|
|
292
|
+
default:
|
|
293
|
+
return next;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** The card after a check that answered: its new state, and when to look again. */
|
|
297
|
+
export function checked(card, output, now) {
|
|
298
|
+
const state = stateOf(output) ?? card.state;
|
|
299
|
+
if (!state)
|
|
300
|
+
return failed(card, now, false);
|
|
301
|
+
const changed = !card.state || momentOf(card.state) !== momentOf(state);
|
|
302
|
+
const changedAt = changed ? now : card.changedAt;
|
|
303
|
+
let wait = now - changedAt < RECENT_MS ? FAST_MS : SLOW_MS;
|
|
304
|
+
// Pending You's own hint, when it's sooner: an answer inside its 5-second hold (it reads as pending until then), or a
|
|
305
|
+
// fallback about to run. Never sooner than 5 seconds: a fallback that's due but hasn't run reads as 1.
|
|
306
|
+
const pollAfter = count(output.pollAfterSeconds);
|
|
307
|
+
if (!isReady(state) && pollAfter !== undefined && pollAfter * 1000 < wait)
|
|
308
|
+
wait = Math.max(HINT_FLOOR_MS, pollAfter * 1000 + 1000);
|
|
309
|
+
const { failures: _failures, ...rest } = card;
|
|
310
|
+
return { ...rest, state, changedAt, nextAt: now + wait };
|
|
311
|
+
}
|
|
312
|
+
/** The card after a check that failed: tried again later, backing off; `blocked` when Claude Code refused it. */
|
|
313
|
+
export function failed(card, now, blocked) {
|
|
314
|
+
if (blocked)
|
|
315
|
+
return { ...card, nextAt: now + BLOCKED_MS };
|
|
316
|
+
const failures = (card.failures ?? 0) + 1;
|
|
317
|
+
return {
|
|
318
|
+
...card,
|
|
319
|
+
failures,
|
|
320
|
+
nextAt: now + Math.min(MAX_BACKOFF_MS, FAST_MS * 2 ** (failures - 1)),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** The cards due a check now, the most overdue first. */
|
|
324
|
+
export function dueCards(book, now) {
|
|
325
|
+
return Object.values(book.cards)
|
|
326
|
+
.filter((card) => !(card.state && isClosed(card.state.status)) && (card.nextAt ?? 0) <= now)
|
|
327
|
+
.sort((a, b) => (a.nextAt ?? 0) - (b.nextAt ?? 0));
|
|
328
|
+
}
|
|
329
|
+
/** Whether the agent has yet to hear the card's moment: its move, and neither told by the mod nor seen itself. */
|
|
330
|
+
export function needsTelling(card) {
|
|
331
|
+
const state = card.state;
|
|
332
|
+
if (!state || !isReady(state))
|
|
333
|
+
return false;
|
|
334
|
+
const moment = momentOf(state);
|
|
335
|
+
if (moment === card.told || moment === card.seen)
|
|
336
|
+
return false;
|
|
337
|
+
// Handled or withdrawn after the agent already heard of it (its own ack or cancel, made elsewhere): nothing new.
|
|
338
|
+
if ((state.status === 'resolved' || state.status === 'cancelled') && (card.told || card.seen))
|
|
339
|
+
return false;
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
/** The cards to start a turn for, in the order they changed. */
|
|
343
|
+
export const toTell = (book) => Object.values(book.cards)
|
|
344
|
+
.filter(needsTelling)
|
|
345
|
+
.sort((a, b) => a.changedAt - b.changedAt);
|
|
346
|
+
/** The book once these cards' moments are told. */
|
|
347
|
+
export function told(book, cards) {
|
|
348
|
+
let next = book;
|
|
349
|
+
for (const card of cards) {
|
|
350
|
+
const current = next.cards[card.requestId];
|
|
351
|
+
if (current?.state)
|
|
352
|
+
next = put(next, { ...current, told: momentOf(current.state) });
|
|
353
|
+
}
|
|
354
|
+
return next;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The book with these cards' moments not told after all (the prompt didn't go), from the cards as they were before
|
|
358
|
+
* `told`: a later tick tries again. A card that moved on since keeps what it has.
|
|
359
|
+
*/
|
|
360
|
+
export function untold(book, before) {
|
|
361
|
+
let next = book;
|
|
362
|
+
for (const card of before) {
|
|
363
|
+
const current = next.cards[card.requestId];
|
|
364
|
+
if (!current?.state || current.told !== momentOf(current.state))
|
|
365
|
+
continue;
|
|
366
|
+
const { told: _told, ...rest } = current;
|
|
367
|
+
next = put(next, card.told === undefined ? rest : { ...rest, told: card.told });
|
|
368
|
+
}
|
|
369
|
+
return next;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The book without what's done: closed cards the agent has heard about (or needn't), and anything untouched for
|
|
373
|
+
* KEEP_MS. Returns the same book when nothing goes.
|
|
374
|
+
*/
|
|
375
|
+
export function settle(book, now) {
|
|
376
|
+
let next = book;
|
|
377
|
+
for (const card of Object.values(book.cards)) {
|
|
378
|
+
const done = card.state && isClosed(card.state.status) && !needsTelling(card);
|
|
379
|
+
if (done || now - Math.max(card.learnedAt, card.changedAt) > KEEP_MS)
|
|
380
|
+
next = drop(next, card.requestId);
|
|
381
|
+
}
|
|
382
|
+
return next;
|
|
383
|
+
}
|
|
384
|
+
/** Whether a session's list in the store is old enough to forget. */
|
|
385
|
+
export const isStale = (book, now) => now - book.updatedAt > KEEP_MS;
|
|
386
|
+
const titled = (card) => card.title ? `“${clip(card.title)}” (${card.requestId})` : card.requestId;
|
|
387
|
+
const named = (card) => (card.name ? `name “${card.name}”` : 'your name');
|
|
388
|
+
/** What happened to a card and what to do, for the prompt: [what happened, what to do]. */
|
|
389
|
+
function line(card) {
|
|
390
|
+
const state = card.state;
|
|
391
|
+
const get = `get_request (requestId ${card.requestId}, ${named(card)})`;
|
|
392
|
+
switch (state.status) {
|
|
393
|
+
case 'expired':
|
|
394
|
+
return [
|
|
395
|
+
`nobody answered ${titled(card)} in time, so its fallback is due`,
|
|
396
|
+
'do it now if you haven’t already; there’s nothing to acknowledge',
|
|
397
|
+
];
|
|
398
|
+
case 'cancelled':
|
|
399
|
+
return [`${titled(card)} was withdrawn`, 'nothing to do'];
|
|
400
|
+
case 'resolved':
|
|
401
|
+
return [`${titled(card)} is already handled`, 'nothing to do'];
|
|
402
|
+
case 'answered':
|
|
403
|
+
return [
|
|
404
|
+
`your person answered ${titled(card)}`,
|
|
405
|
+
`call ${get}, act on their words, then ack_answer with its version and a one-line outcome`,
|
|
406
|
+
];
|
|
407
|
+
default:
|
|
408
|
+
return [
|
|
409
|
+
`your person wrote to you on ${titled(card)}`,
|
|
410
|
+
`call ${get} to read it, then answer with reply_in_thread`,
|
|
411
|
+
];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const capital = (sentence) => sentence.charAt(0).toUpperCase() + sentence.slice(1);
|
|
415
|
+
/** The prompt that wakes the session: short, every card in it, and what to do with each. */
|
|
416
|
+
export function wakeText(cards) {
|
|
417
|
+
const asked = cards.some((card) => card.state?.turn === 'agent' && !isClosed(card.state.status));
|
|
418
|
+
const reopen = asked
|
|
419
|
+
? ' If they asked you something or you need more from them, reply_in_thread with reopen instead.'
|
|
420
|
+
: '';
|
|
421
|
+
if (cards.length === 1) {
|
|
422
|
+
const [happened, todo] = line(cards[0]);
|
|
423
|
+
return `Pending You: ${happened}. ${capital(todo)}.${reopen}`;
|
|
424
|
+
}
|
|
425
|
+
return [
|
|
426
|
+
`Pending You: ${cards.length} of your cards need you.`,
|
|
427
|
+
...cards.map((card) => {
|
|
428
|
+
const [happened, todo] = line(card);
|
|
429
|
+
return `- ${capital(happened)}: ${todo}.`;
|
|
430
|
+
}),
|
|
431
|
+
...(reopen ? [reopen.trim()] : []),
|
|
432
|
+
].join('\n');
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The line under the prompt: the cards waiting on the person, or how to let the mod check; none when there's neither.
|
|
436
|
+
* Claude Code draws it after the plugin's name ("⚠ pending-you: 1 waiting on you"), so it doesn't say Pending You again.
|
|
437
|
+
*/
|
|
438
|
+
export function statusText(book, blocked) {
|
|
439
|
+
if (blocked)
|
|
440
|
+
return `to hear answers here, allow mcp__${blocked} in /permissions`;
|
|
441
|
+
const waiting = Object.values(book.cards).filter((card) => card.state && isWaitingOnYou(card.state)).length;
|
|
442
|
+
return waiting ? `${waiting} waiting on you` : undefined;
|
|
443
|
+
}
|
|
444
|
+
/** What the agent reads, once, when Claude Code won't let the mod check its cards. */
|
|
445
|
+
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.`;
|
|
446
|
+
/** Whether a refused `$.mcp.call` was Claude Code's permission check, not the server. */
|
|
447
|
+
export const isRefusal = (error) => /refused|permission|not allowed|haven.t granted/i.test(error instanceof Error ? error.message : String(error));
|
|
448
|
+
/**
|
|
449
|
+
* A command line split into words the way a shell splits one simple command (quotes and backslashes, no expansion).
|
|
450
|
+
* Null when it's more than one simple command: a pipe, `&&`, `;`, `&`, a redirect, `$…`, a backtick or a newline.
|
|
451
|
+
*/
|
|
452
|
+
export function shellWords(command) {
|
|
453
|
+
const words = [];
|
|
454
|
+
let word = '';
|
|
455
|
+
let inWord = false;
|
|
456
|
+
let quote = null;
|
|
457
|
+
for (let index = 0; index < command.length; index++) {
|
|
458
|
+
const char = command[index];
|
|
459
|
+
if (quote === "'") {
|
|
460
|
+
if (char === "'")
|
|
461
|
+
quote = null;
|
|
462
|
+
else
|
|
463
|
+
word += char;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (quote === '"') {
|
|
467
|
+
if (char === '"')
|
|
468
|
+
quote = null;
|
|
469
|
+
else if (char === '$' || char === '`')
|
|
470
|
+
return null;
|
|
471
|
+
else if (char === '\\' && '"\\$`'.includes(command[index + 1] ?? ''))
|
|
472
|
+
word += command[++index];
|
|
473
|
+
else
|
|
474
|
+
word += char;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (char === "'" || char === '"') {
|
|
478
|
+
quote = char;
|
|
479
|
+
inWord = true;
|
|
480
|
+
}
|
|
481
|
+
else if (char === '\\') {
|
|
482
|
+
const escaped = command[++index];
|
|
483
|
+
if (escaped === undefined || escaped === '\n')
|
|
484
|
+
return null;
|
|
485
|
+
word += escaped;
|
|
486
|
+
inWord = true;
|
|
487
|
+
}
|
|
488
|
+
else if (char === ' ' || char === '\t') {
|
|
489
|
+
if (inWord)
|
|
490
|
+
words.push(word);
|
|
491
|
+
word = '';
|
|
492
|
+
inWord = false;
|
|
493
|
+
}
|
|
494
|
+
else if (char === '#' && !inWord)
|
|
495
|
+
break;
|
|
496
|
+
else if (';&|<>()`$\n\r'.includes(char))
|
|
497
|
+
return null;
|
|
498
|
+
else {
|
|
499
|
+
word += char;
|
|
500
|
+
inWord = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (quote)
|
|
504
|
+
return null;
|
|
505
|
+
if (inWord)
|
|
506
|
+
words.push(word);
|
|
507
|
+
return words;
|
|
508
|
+
}
|
|
509
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
510
|
+
const PACKAGE = /^pendingyou(@[\w.^~=<>*-]+)?$/;
|
|
511
|
+
const NPX_FLAGS = new Set(['-y', '--yes', '-q', '--quiet', '--prefer-offline', '--prefer-online']);
|
|
512
|
+
const OPTION = /^--(origin|timeout)=\S+$/;
|
|
513
|
+
/**
|
|
514
|
+
* The request a Bash command holds for: `npx pendingyou hold <id>` in the forms agents and the guide write it
|
|
515
|
+
* (`PENDINGYOU_ORIGIN=… npx -y pendingyou@latest hold <id> --origin … --timeout 4h`), `pendingyou hold <id>`, or the
|
|
516
|
+
* command line's own `"<node>" "…/pendingyou/dist/cli.js" hold <id>`. Null for anything else, a compound command
|
|
517
|
+
* included: that runs as it is.
|
|
518
|
+
*/
|
|
519
|
+
export function holdOf(command) {
|
|
520
|
+
const words = shellWords(command.trim());
|
|
521
|
+
if (!words?.length)
|
|
522
|
+
return null;
|
|
523
|
+
let index = 0;
|
|
524
|
+
const at = () => words[index] ?? '';
|
|
525
|
+
if (at() === 'env')
|
|
526
|
+
index++;
|
|
527
|
+
while (ASSIGNMENT.test(at()))
|
|
528
|
+
index++;
|
|
529
|
+
if (at() === 'npx') {
|
|
530
|
+
index++;
|
|
531
|
+
while (NPX_FLAGS.has(at()))
|
|
532
|
+
index++;
|
|
533
|
+
if (!PACKAGE.test(at()))
|
|
534
|
+
return null;
|
|
535
|
+
index++;
|
|
536
|
+
}
|
|
537
|
+
else if (at() === 'pnpm' && words[index + 1] === 'dlx') {
|
|
538
|
+
index += 2;
|
|
539
|
+
if (!PACKAGE.test(at()))
|
|
540
|
+
return null;
|
|
541
|
+
index++;
|
|
542
|
+
}
|
|
543
|
+
else if (at() === 'pendingyou')
|
|
544
|
+
index++;
|
|
545
|
+
else {
|
|
546
|
+
if (/(^|\/)node$/.test(at()))
|
|
547
|
+
index++;
|
|
548
|
+
if (!/(^|\/)pendingyou\/dist\/cli\.js$/.test(at()))
|
|
549
|
+
return null;
|
|
550
|
+
index++;
|
|
551
|
+
}
|
|
552
|
+
let requestId = null;
|
|
553
|
+
let held = false;
|
|
554
|
+
while (index < words.length) {
|
|
555
|
+
const word = at();
|
|
556
|
+
if (OPTION.test(word))
|
|
557
|
+
index++;
|
|
558
|
+
else if (word === '--origin' || word === '--timeout') {
|
|
559
|
+
if (index + 1 >= words.length)
|
|
560
|
+
return null;
|
|
561
|
+
index += 2;
|
|
562
|
+
}
|
|
563
|
+
else if (!held && word === 'hold') {
|
|
564
|
+
held = true;
|
|
565
|
+
index++;
|
|
566
|
+
}
|
|
567
|
+
else if (held && !requestId && REQUEST_ID.test(word)) {
|
|
568
|
+
requestId = word;
|
|
569
|
+
index++;
|
|
570
|
+
}
|
|
571
|
+
else
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
return held ? requestId : null;
|
|
575
|
+
}
|
|
576
|
+
/** What a hold the mod answered prints: there's nothing to wait on. */
|
|
577
|
+
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.`;
|
|
578
|
+
/** The lease's holder and how long it holds, from the environment variable's value. */
|
|
579
|
+
function leaseOf(value) {
|
|
580
|
+
const match = /^(\S+) (\d+)$/.exec(value ?? '');
|
|
581
|
+
return match ? { holder: match[1], until: Number(match[2]) } : null;
|
|
582
|
+
}
|
|
583
|
+
/** Who holds the lease now; null when nobody does. */
|
|
584
|
+
export function leaseHolder(value, now) {
|
|
585
|
+
const lease = leaseOf(value);
|
|
586
|
+
return lease && lease.until > now ? lease.holder : null;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* What to write to take or renew the lease: null when another copy holds it, or this one's needn't be renewed yet.
|
|
590
|
+
* Each copy is named for its plugin: two plugins of one name never load together in one session.
|
|
591
|
+
*/
|
|
592
|
+
export function claimLease(value, me, now) {
|
|
593
|
+
const lease = leaseOf(value);
|
|
594
|
+
if (lease && lease.until > now && lease.holder !== me)
|
|
595
|
+
return null;
|
|
596
|
+
if (lease && lease.holder === me && lease.until - now > LEASE_MS - RENEW_MS)
|
|
597
|
+
return null;
|
|
598
|
+
return `${me} ${now + LEASE_MS}`;
|
|
599
|
+
}
|