pendingyou 0.9.0 → 0.11.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.
@@ -0,0 +1,386 @@
1
+ // Codex hearing answers (0.11.0): what its hooks remember about the cards a thread posted, and the listener that wakes
2
+ // the thread when one is answered.
3
+ //
4
+ // - `pendingyou posted --app codex` is Codex's PostToolUse hook for Pending You's card tools (post_request,
5
+ // update_request, reply_in_thread, cancel_request; Codex runs it only for a call that didn't fail). It records that
6
+ // the turn put something in front of the person (with the card's words, for the Stop check: stopcheck.ts), and which
7
+ // thread is waiting on which card (`session_id` is the thread's id), then makes sure that thread has a listener.
8
+ // - `pendingyou listen --app codex --thread <id>` is that listener: one per thread (a lease file), detached, holding no
9
+ // hook's output (Codex would wait for it otherwise). It long-polls Pending You with Codex's own connection, as `watch`
10
+ // does, and when one of the thread's cards is the agent's move (an answer, a message, a fallback that ran) it runs
11
+ // `codex queue --thread <id> --message "Pending You: your person answered …"`, in the words the Claude Code wake mod
12
+ // uses. Never the answer itself: a program's arguments are there for any other program on the computer to read, so
13
+ // the agent reads the answer with get_request, as the message says. Codex starts a turn at once when the thread is
14
+ // loaded and idle, after the current one when it's working, or when it's next resumed. It stops when the thread has no card left waiting, or after 8
15
+ // hours; the next session start or message starts it again while one is (pickup.ts).
16
+ //
17
+ // Ids, names and titles only, in ~/.config/pendingyou/codex-threads.json: never an answer.
18
+ import { randomBytes } from 'node:crypto';
19
+ import { mkdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { getJson, SignInNeeded, Unavailable } from "../api.js";
22
+ import { DEFAULT_ORIGIN } from "../args.js";
23
+ import { readCredential } from "../credentials.js";
24
+ import { configDir, readJson, withLock, writeWhole } from "../files.js";
25
+ import { clip, isClosed } from "../format.js";
26
+ import { markHanded, momentOf, wasHanded } from "../state.js";
27
+ /** How long a turn's record, and a thread untouched, are kept. */
28
+ const KEEP_TURN_MS = 24 * 60 * 60_000;
29
+ const KEEP_THREAD_MS = 7 * 24 * 60 * 60_000;
30
+ /** The most of one card's words a turn keeps, to match an ask against (stopcheck.ts). */
31
+ const CARD_WORDS = 20_000;
32
+ /** How long a listener waits on one thread, at most, and how often it renews its lease. */
33
+ export const LISTEN_MS = 8 * 60 * 60_000;
34
+ const LONG_POLL_SECONDS = 25;
35
+ /** A lease untouched for this long belongs to a listener that died. */
36
+ const LEASE_STALE_MS = 90_000;
37
+ const PATIENT_AFTER_MS = 30 * 60_000;
38
+ const PATIENT_PAUSE_MS = 30_000;
39
+ const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
40
+ const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
41
+ /** Codex's tools for the card calls the hook reads, on a Pending You server (`mcp__pendingyou__post_request`). */
42
+ const CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request)$/i;
43
+ const statePath = (io) => join(configDir(io), 'codex-threads.json');
44
+ const leasePath = (io, thread) => join(configDir(io), 'codex-listen', `${thread}.lease`);
45
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ async function load(io) {
47
+ const file = await readJson(statePath(io)).catch(() => null);
48
+ return {
49
+ version: 1,
50
+ threads: isObject(file?.threads) ? file.threads : {},
51
+ turns: isObject(file?.turns) ? file.turns : {},
52
+ };
53
+ }
54
+ /** Changes the state under its lock, dropping what's old. */
55
+ async function change(io, edit) {
56
+ const path = statePath(io);
57
+ return withLock(path, io, async () => {
58
+ const state = await load(io);
59
+ edit(state);
60
+ const now = io.now();
61
+ for (const [id, turn] of Object.entries(state.turns))
62
+ if (!(now - turn.at < KEEP_TURN_MS))
63
+ delete state.turns[id];
64
+ for (const [id, thread] of Object.entries(state.threads))
65
+ if (!(now - thread.at < KEEP_THREAD_MS) || Object.keys(thread.cards).length === 0)
66
+ delete state.threads[id];
67
+ await writeWhole(path, `${JSON.stringify(state, null, 2)}\n`, { secret: true });
68
+ return state;
69
+ });
70
+ }
71
+ /** A tool's answer as Codex hands it to PostToolUse (a CallToolResult): its JSON object, or null. */
72
+ export function resultOf(response) {
73
+ if (!isObject(response) || response.isError === true)
74
+ return null;
75
+ if (isObject(response.structuredContent))
76
+ return response.structuredContent;
77
+ const blocks = Array.isArray(response.content) ? response.content : [];
78
+ for (const block of blocks) {
79
+ if (!isObject(block) || typeof block.text !== 'string')
80
+ continue;
81
+ try {
82
+ const parsed = JSON.parse(block.text);
83
+ if (isObject(parsed))
84
+ return parsed;
85
+ }
86
+ catch { }
87
+ }
88
+ return null;
89
+ }
90
+ /** Every string a call carried (a card's title, summary, steps…), up to `max` characters: what a card says. */
91
+ export function wordsIn(value, max = CARD_WORDS) {
92
+ const found = [];
93
+ let left = max;
94
+ const walk = (item, depth) => {
95
+ if (left <= 0 || depth > 8)
96
+ return;
97
+ if (typeof item === 'string') {
98
+ found.push(item.slice(0, left));
99
+ left -= item.length;
100
+ }
101
+ else if (Array.isArray(item))
102
+ for (const each of item)
103
+ walk(each, depth + 1);
104
+ else if (isObject(item))
105
+ for (const each of Object.values(item))
106
+ walk(each, depth + 1);
107
+ };
108
+ walk(value, 0);
109
+ return found.join('\n');
110
+ }
111
+ const text = (value, max) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined;
112
+ /** The agent's name on a call: `name`, or post_request's `session.label`. */
113
+ function nameOf(input) {
114
+ const session = isObject(input.session) ? input.session : null;
115
+ return text(input.name, 120) ?? text(session?.label, 120);
116
+ }
117
+ /**
118
+ * `pendingyou posted --app codex`: Codex's PostToolUse hook. Remembers what the turn put in front of the person and
119
+ * which cards the thread waits on, then makes sure the thread has a listener. Prints nothing (Codex ignores it anyway);
120
+ * never fails.
121
+ */
122
+ export async function posted(io, options) {
123
+ try {
124
+ const input = JSON.parse(await io.readStdin(1000));
125
+ if (!isObject(input))
126
+ return 0;
127
+ const tool = typeof input.tool_name === 'string' ? CARD_TOOL.exec(input.tool_name) : null;
128
+ const thread = typeof input.session_id === 'string' ? input.session_id : null;
129
+ const turn = typeof input.turn_id === 'string' ? input.turn_id : null;
130
+ if (!tool || !thread)
131
+ return 0;
132
+ const call = tool[2];
133
+ const args = isObject(input.tool_input) ? input.tool_input : {};
134
+ const output = resultOf(input.tool_response);
135
+ const requestId = [output?.requestId, args.requestId].find((id) => typeof id === 'string' && REQUEST_ID.test(id));
136
+ const now = io.now();
137
+ const state = await change(io, (state) => {
138
+ if (turn && call !== 'cancel_request') {
139
+ const record = state.turns[turn] ?? { at: now, cards: [] };
140
+ record.cards = [...record.cards, wordsIn(args)].slice(-10);
141
+ record.at = now;
142
+ state.turns[turn] = record;
143
+ }
144
+ if (!requestId)
145
+ return;
146
+ const waiting = state.threads[thread] ?? { origin: options.origin, cards: {}, at: now };
147
+ const reopen = isObject(args.reopen) ? args.reopen : null;
148
+ if (call === 'cancel_request')
149
+ delete waiting.cards[requestId];
150
+ // A reply that leaves it the agent's turn (an answer to their message) isn't waiting on them.
151
+ else if (call !== 'reply_in_thread' || reopen || output?.turn === 'you') {
152
+ const before = waiting.cards[requestId];
153
+ const name = nameOf(args) ?? before?.name;
154
+ const title = text(args.title, 400) ?? text(reopen?.title, 400) ?? before?.title;
155
+ waiting.cards[requestId] = {
156
+ ...(name ? { name } : {}),
157
+ ...(title ? { title } : {}),
158
+ at: now,
159
+ };
160
+ }
161
+ waiting.origin = options.origin;
162
+ waiting.at = now;
163
+ state.threads[thread] = waiting;
164
+ });
165
+ if (Object.keys(state.threads[thread]?.cards ?? {}).length > 0)
166
+ await keepListening(io, options.origin, thread);
167
+ return 0;
168
+ }
169
+ catch {
170
+ return 0;
171
+ }
172
+ }
173
+ /** Whether this turn put something in front of the person, the words of each card, and whether it was nudged. */
174
+ export async function turnRecord(io, turn) {
175
+ const record = (await load(io)).turns[turn];
176
+ return record ? { cards: record.cards, nudged: record.nudged === true } : null;
177
+ }
178
+ /** Remembers that the Stop check asked this turn to go on: at most once a turn. */
179
+ export async function markNudged(io, turn) {
180
+ const now = io.now();
181
+ await change(io, (state) => {
182
+ state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, nudged: true };
183
+ });
184
+ }
185
+ /** Whether a listener holds this thread now: its lease, touched within LEASE_STALE_MS. */
186
+ async function listening(io, thread) {
187
+ return stat(leasePath(io, thread)).then((info) => Date.now() - info.mtimeMs < LEASE_STALE_MS, () => false);
188
+ }
189
+ /**
190
+ * Makes sure a thread with cards waiting on the person has a listener: starts one, detached, unless one is there or
191
+ * Codex isn't signed in. Quick (three small reads) and never throws: the session-start and next-message hooks call it too.
192
+ */
193
+ export async function keepListening(io, origin, thread) {
194
+ try {
195
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/.test(thread))
196
+ return;
197
+ const cards = (await load(io)).threads[thread]?.cards ?? {};
198
+ if (Object.keys(cards).length === 0 || (await listening(io, thread)))
199
+ return;
200
+ if (!(await readCredential(io, origin, 'codex')))
201
+ return;
202
+ io.background([
203
+ 'listen',
204
+ '--app',
205
+ 'codex',
206
+ '--thread',
207
+ thread,
208
+ ...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
209
+ ]);
210
+ }
211
+ catch { }
212
+ }
213
+ /** What happened to a card and what to do, in the words the Claude Code wake mod uses (wake.ts's line). */
214
+ function line(heard, card) {
215
+ const titled = `“${clip(card.title ?? heard.title, 80)
216
+ .text.replaceAll('“', '‘')
217
+ .replaceAll('”', '’')}” (${heard.requestId})`;
218
+ const named = card.name ?? heard.sessionLabel;
219
+ const get = `get_request (requestId ${heard.requestId}, ${named ? `name “${named}”` : 'your name'})`;
220
+ switch (heard.status) {
221
+ case 'expired':
222
+ return [
223
+ `nobody answered ${titled} in time, so its fallback is due`,
224
+ 'do it now if you haven’t already; there’s nothing to acknowledge',
225
+ ];
226
+ case 'cancelled':
227
+ case 'resolved':
228
+ return null;
229
+ case 'answered':
230
+ return [
231
+ `your person answered ${titled}`,
232
+ `call ${get}, act on their words, then ack_answer with its version and a one-line outcome`,
233
+ ];
234
+ default:
235
+ return [
236
+ `your person wrote to you on ${titled}`,
237
+ `call ${get} to read it, then answer with reply_in_thread`,
238
+ ];
239
+ }
240
+ }
241
+ const capital = (sentence) => sentence.charAt(0).toUpperCase() + sentence.slice(1);
242
+ /** The message that wakes the thread: every card that needs it, and what to do with each; null when none does. */
243
+ export function wakeMessage(ready) {
244
+ const lines = ready.flatMap(({ heard, card }) => {
245
+ const said = line(heard, card);
246
+ return said ? [{ heard, said }] : [];
247
+ });
248
+ if (lines.length === 0)
249
+ return null;
250
+ const asked = lines.some(({ heard }) => heard.turn === 'agent' && heard.status !== 'expired');
251
+ const reopen = asked
252
+ ? ' If they asked you something or you need more from them, reply_in_thread with reopen instead.'
253
+ : '';
254
+ if (lines.length === 1) {
255
+ const [happened, todo] = lines[0].said;
256
+ return `Pending You: ${happened}. ${capital(todo)}.${reopen}`;
257
+ }
258
+ return [
259
+ `Pending You: ${lines.length} of your cards need you.`,
260
+ ...lines.map(({ said: [happened, todo] }) => `- ${capital(happened)}: ${todo}.`),
261
+ ...(reopen ? [reopen.trim()] : []),
262
+ ].join('\n');
263
+ }
264
+ /**
265
+ * Takes the thread's lease, a file holding this listener's own id: null when a live listener holds it. Two listeners
266
+ * starting at once may both write it; the one whose id isn't there at its next renewal stops.
267
+ */
268
+ async function takeLease(io, thread) {
269
+ const path = leasePath(io, thread);
270
+ await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
271
+ if (await listening(io, thread))
272
+ return null;
273
+ const id = randomBytes(8).toString('hex');
274
+ await rm(path, { recursive: true, force: true });
275
+ try {
276
+ await writeFile(path, id, { flag: 'wx', mode: 0o600 });
277
+ return id;
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ /** Renews the lease while it's still this listener's (`id`); false once another listener has taken it. */
284
+ async function renew(io, thread, id) {
285
+ const path = leasePath(io, thread);
286
+ if ((await readFile(path, 'utf8').catch(() => null)) !== id)
287
+ return false;
288
+ const now = new Date();
289
+ await utimes(path, now, now).catch(() => { });
290
+ return true;
291
+ }
292
+ /**
293
+ * `pendingyou listen --app codex --thread <id>`: waits on Pending You for the thread's cards, and wakes the thread
294
+ * with `codex queue` when one is the agent's move. Quiet; exits when the thread has none left, Codex can't be reached,
295
+ * the sign-in ends, or after LISTEN_MS.
296
+ */
297
+ export async function listen(io, options) {
298
+ const { origin, thread } = options;
299
+ const lease = await takeLease(io, thread);
300
+ if (!lease)
301
+ return 0;
302
+ try {
303
+ const started = io.now();
304
+ let since;
305
+ let failures = 0;
306
+ while (io.now() - started < LISTEN_MS && !io.signal.aborted) {
307
+ const cards = (await load(io)).threads[thread]?.cards ?? {};
308
+ if (Object.keys(cards).length === 0)
309
+ return 0;
310
+ if (!(await renew(io, thread, lease)))
311
+ return 0;
312
+ const query = new URLSearchParams({
313
+ source: 'codex',
314
+ limit: '20',
315
+ wait: String(LONG_POLL_SECONDS),
316
+ });
317
+ if (since)
318
+ query.set('since', since);
319
+ let requests;
320
+ try {
321
+ const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, io.signal, { app: 'codex' });
322
+ if (status !== 200)
323
+ throw new Unavailable(`Pending You answered ${status}.`);
324
+ requests = Array.isArray(body.requests) ? body.requests : [];
325
+ failures = 0;
326
+ }
327
+ catch (error) {
328
+ if (error instanceof SignInNeeded || io.signal.aborted)
329
+ return 0;
330
+ // Renewed first: no pause here outlasts the lease.
331
+ if (!(await renew(io, thread, lease)))
332
+ return 0;
333
+ await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], io.signal);
334
+ continue;
335
+ }
336
+ for (const heard of requests)
337
+ if (!since || heard.changedAt > since)
338
+ since = heard.changedAt;
339
+ const ready = [];
340
+ const done = [];
341
+ for (const heard of requests) {
342
+ const card = cards[heard.requestId];
343
+ if (!card || !heard.ready)
344
+ continue;
345
+ // Handed over already (the person wrote in Codex since): nothing to wake it for.
346
+ if (await wasHanded(io, origin, heard.requestId, momentOf(heard)))
347
+ done.push(heard.requestId);
348
+ else
349
+ ready.push({ heard, card });
350
+ }
351
+ const message = wakeMessage(ready);
352
+ if (message) {
353
+ const queued = await io.run('codex', ['queue', '--thread', thread, '--message', message], 30_000);
354
+ // Codex doesn't know the thread (archived, gone), or can't be run from here: the hooks hand answers over.
355
+ if (queued.code !== 0) {
356
+ await change(io, (state) => {
357
+ delete state.threads[thread];
358
+ });
359
+ return 0;
360
+ }
361
+ await markHanded(io, origin, ready.map(({ heard }) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
362
+ }
363
+ for (const { heard } of ready)
364
+ done.push(heard.requestId);
365
+ // Closed cards nobody needs telling about go too.
366
+ for (const heard of requests)
367
+ if (cards[heard.requestId] && isClosed(heard) && !done.includes(heard.requestId))
368
+ done.push(heard.requestId);
369
+ if (done.length)
370
+ await change(io, (state) => {
371
+ const waiting = state.threads[thread];
372
+ if (waiting)
373
+ for (const id of done)
374
+ delete waiting.cards[id];
375
+ });
376
+ if (io.now() - started > PATIENT_AFTER_MS)
377
+ await io.sleep(PATIENT_PAUSE_MS, io.signal);
378
+ }
379
+ return 0;
380
+ }
381
+ finally {
382
+ // Only its own: a listener that took over keeps the thread.
383
+ if ((await readFile(leasePath(io, thread), 'utf8').catch(() => null)) === lease)
384
+ await rm(leasePath(io, thread), { force: true }).catch(() => { });
385
+ }
386
+ }