pendingyou 0.11.0 → 0.12.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.
@@ -1,20 +1,27 @@
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.
1
+ // Codex hearing answers (0.11.0), and OpenCode and Pi the same way (0.12.0): what an app's hooks remember about the
2
+ // cards a thread (OpenCode, Pi: a session) posted, and the listener that wakes the thread when one is answered.
3
3
  //
4
4
  // - `pendingyou posted --app codex` is Codex's PostToolUse hook for Pending You's card tools (post_request,
5
5
  // update_request, reply_in_thread, cancel_request; Codex runs it only for a call that didn't fail). It records that
6
6
  // the turn put something in front of the person (with the card's words, for the Stop check: stopcheck.ts), and which
7
7
  // thread is waiting on which card (`session_id` is the thread's id), then makes sure that thread has a listener.
8
+ // OpenCode's plugin runs it from `tool.execute.after` (apps/opencode-plugin.ts), and Pi's extension from Pi's
9
+ // `tool_result` (apps/pi-extension.ts), with the same fields.
8
10
  // - `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).
11
+ // hook's output (Codex would wait for it otherwise). It long-polls Pending You with the app's own connection, as
12
+ // `watch` does, and when one of the thread's cards is the agent's move (an answer, a message, a fallback that ran) it
13
+ // runs `codex queue --thread <id> --message "Pending You: your person answered …"`, in the words the Claude Code wake
14
+ // mod uses. Never the answer itself: a program's arguments are there for any other program on the computer to read,
15
+ // so the agent reads the answer with get_request, as the message says. Codex starts a turn at once when the thread is
16
+ // loaded and idle, after the current one when it's working, or when it's next resumed. It stops when the thread has
17
+ // no card left waiting, or after 8 hours; the next session start or message starts it again while one is (pickup.ts).
18
+ // - OpenCode's listener is the plugin's child, not detached, since only the plugin can start a turn
19
+ // (client.session.promptAsync): it writes each wake as one JSON line on stdout, `{"wake": "<the words>",
20
+ // "requests": ["req_…"]}`, and the plugin answers on its stdin, `{"delivered": true}` once the turn is started (or
21
+ // false). Only then is it marked handed over. It stops when its stdin closes (OpenCode went), as well as when Codex's
22
+ // would. Pi's listener is its extension's child in just the same way (`pi.sendMessage` starts the turn).
16
23
  //
17
- // Ids, names and titles only, in ~/.config/pendingyou/codex-threads.json: never an answer.
24
+ // Ids, names and titles only, in ~/.config/pendingyou/<app>-threads.json: never an answer.
18
25
  import { randomBytes } from 'node:crypto';
19
26
  import { mkdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
20
27
  import { join } from 'node:path';
@@ -37,14 +44,37 @@ const LEASE_STALE_MS = 90_000;
37
44
  const PATIENT_AFTER_MS = 30 * 60_000;
38
45
  const PATIENT_PAUSE_MS = 30_000;
39
46
  const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
47
+ /** How long OpenCode's listener waits for the plugin to say whether it started the turn. */
48
+ const REPLY_MS = 60_000;
40
49
  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`);
50
+ /** A thread's id: Codex's (a UUID) or an OpenCode session's (`ses_…`). */
51
+ export const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
52
+ /** Pending You's card tools as Codex and Pi name them: `mcp__pendingyou__post_request`. */
53
+ const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request)$/i;
54
+ /**
55
+ * The card calls the hook reads, on a Pending You server: Codex and Pi name a tool `mcp__pendingyou__post_request`,
56
+ * OpenCode `pendingyou_post_request` (the server's name, `_`, the tool's).
57
+ */
58
+ const CARD_TOOLS = {
59
+ codex: MCP_CARD_TOOL,
60
+ opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request)$/i,
61
+ pi: MCP_CARD_TOOL,
62
+ };
63
+ /** The card tool a call was, for an app whose hooks read them; null for any other tool. */
64
+ export function cardTool(app, name) {
65
+ const pattern = CARD_TOOLS[app];
66
+ return pattern && typeof name === 'string' ? (pattern.exec(name)?.[2] ?? null) : null;
67
+ }
68
+ const statePath = (io, app) => join(configDir(io), `${app}-threads.json`);
69
+ const leasePath = (io, app, thread) => join(configDir(io), `${app}-listen`, `${thread}.lease`);
70
+ /** What uninstall takes away for an app: its threads' record and its listeners' leases. */
71
+ export const threadFiles = (io, app) => [
72
+ statePath(io, app),
73
+ join(configDir(io), `${app}-listen`),
74
+ ];
45
75
  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);
76
+ async function load(io, app) {
77
+ const file = await readJson(statePath(io, app)).catch(() => null);
48
78
  return {
49
79
  version: 1,
50
80
  threads: isObject(file?.threads) ? file.threads : {},
@@ -52,10 +82,10 @@ async function load(io) {
52
82
  };
53
83
  }
54
84
  /** Changes the state under its lock, dropping what's old. */
55
- async function change(io, edit) {
56
- const path = statePath(io);
85
+ async function change(io, app, edit) {
86
+ const path = statePath(io, app);
57
87
  return withLock(path, io, async () => {
58
- const state = await load(io);
88
+ const state = await load(io, app);
59
89
  edit(state);
60
90
  const now = io.now();
61
91
  for (const [id, turn] of Object.entries(state.turns))
@@ -115,26 +145,26 @@ function nameOf(input) {
115
145
  return text(input.name, 120) ?? text(session?.label, 120);
116
146
  }
117
147
  /**
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.
148
+ * `pendingyou posted --app <app>`: Codex's PostToolUse hook, and what OpenCode's plugin runs after a card call.
149
+ * Remembers what the turn put in front of the person and which cards the thread waits on, then (Codex) makes sure the
150
+ * thread has a listener; OpenCode's plugin starts its own. Prints nothing (Codex ignores it anyway); never fails.
121
151
  */
122
152
  export async function posted(io, options) {
153
+ const { app } = options;
123
154
  try {
124
155
  const input = JSON.parse(await io.readStdin(1000));
125
156
  if (!isObject(input))
126
157
  return 0;
127
- const tool = typeof input.tool_name === 'string' ? CARD_TOOL.exec(input.tool_name) : null;
158
+ const call = cardTool(app, input.tool_name);
128
159
  const thread = typeof input.session_id === 'string' ? input.session_id : null;
129
160
  const turn = typeof input.turn_id === 'string' ? input.turn_id : null;
130
- if (!tool || !thread)
161
+ if (!call || !thread)
131
162
  return 0;
132
- const call = tool[2];
133
163
  const args = isObject(input.tool_input) ? input.tool_input : {};
134
164
  const output = resultOf(input.tool_response);
135
165
  const requestId = [output?.requestId, args.requestId].find((id) => typeof id === 'string' && REQUEST_ID.test(id));
136
166
  const now = io.now();
137
- const state = await change(io, (state) => {
167
+ const state = await change(io, app, (state) => {
138
168
  if (turn && call !== 'cancel_request') {
139
169
  const record = state.turns[turn] ?? { at: now, cards: [] };
140
170
  record.cards = [...record.cards, wordsIn(args)].slice(-10);
@@ -162,7 +192,7 @@ export async function posted(io, options) {
162
192
  waiting.at = now;
163
193
  state.threads[thread] = waiting;
164
194
  });
165
- if (Object.keys(state.threads[thread]?.cards ?? {}).length > 0)
195
+ if (app === 'codex' && Object.keys(state.threads[thread]?.cards ?? {}).length > 0)
166
196
  await keepListening(io, options.origin, thread);
167
197
  return 0;
168
198
  }
@@ -171,31 +201,32 @@ export async function posted(io, options) {
171
201
  }
172
202
  }
173
203
  /** 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];
204
+ export async function turnRecord(io, turn, app = 'codex') {
205
+ const record = (await load(io, app)).turns[turn];
176
206
  return record ? { cards: record.cards, nudged: record.nudged === true } : null;
177
207
  }
178
208
  /** Remembers that the Stop check asked this turn to go on: at most once a turn. */
179
- export async function markNudged(io, turn) {
209
+ export async function markNudged(io, turn, app = 'codex') {
180
210
  const now = io.now();
181
- await change(io, (state) => {
211
+ await change(io, app, (state) => {
182
212
  state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, nudged: true };
183
213
  });
184
214
  }
185
215
  /** 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);
216
+ async function listening(io, app, thread) {
217
+ return stat(leasePath(io, app, thread)).then((info) => Date.now() - info.mtimeMs < LEASE_STALE_MS, () => false);
188
218
  }
189
219
  /**
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.
220
+ * Makes sure a Codex thread with cards waiting on the person has a listener: starts one, detached, unless one is there
221
+ * or Codex isn't signed in. Quick (three small reads) and never throws: the session-start and next-message hooks call
222
+ * it too.
192
223
  */
193
224
  export async function keepListening(io, origin, thread) {
194
225
  try {
195
- if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/.test(thread))
226
+ if (!THREAD.test(thread))
196
227
  return;
197
- const cards = (await load(io)).threads[thread]?.cards ?? {};
198
- if (Object.keys(cards).length === 0 || (await listening(io, thread)))
228
+ const cards = (await load(io, 'codex')).threads[thread]?.cards ?? {};
229
+ if (Object.keys(cards).length === 0 || (await listening(io, 'codex', thread)))
199
230
  return;
200
231
  if (!(await readCredential(io, origin, 'codex')))
201
232
  return;
@@ -265,10 +296,10 @@ export function wakeMessage(ready) {
265
296
  * Takes the thread's lease, a file holding this listener's own id: null when a live listener holds it. Two listeners
266
297
  * starting at once may both write it; the one whose id isn't there at its next renewal stops.
267
298
  */
268
- async function takeLease(io, thread) {
269
- const path = leasePath(io, thread);
299
+ async function takeLease(io, app, thread) {
300
+ const path = leasePath(io, app, thread);
270
301
  await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
271
- if (await listening(io, thread))
302
+ if (await listening(io, app, thread))
272
303
  return null;
273
304
  const id = randomBytes(8).toString('hex');
274
305
  await rm(path, { recursive: true, force: true });
@@ -281,36 +312,98 @@ async function takeLease(io, thread) {
281
312
  }
282
313
  }
283
314
  /** 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);
315
+ async function renew(io, app, thread, id) {
316
+ const path = leasePath(io, app, thread);
286
317
  if ((await readFile(path, 'utf8').catch(() => null)) !== id)
287
318
  return false;
288
319
  const now = new Date();
289
320
  await utimes(path, now, now).catch(() => { });
290
321
  return true;
291
322
  }
323
+ /** Codex: `codex queue`, which starts the turn itself. */
324
+ const byCodexQueue = (io, thread) => async (message) => (await io.run('codex', ['queue', '--thread', thread, '--message', message], 30_000)).code === 0;
325
+ /**
326
+ * OpenCode and Pi: the plugin (Pi: the extension) that started this listener reads the wake on stdout and starts the
327
+ * turn, then says on stdin whether it did. stdin closing means the plugin (the app) is gone: `gone` stops the listener.
328
+ */
329
+ function byPlugin(io, gone) {
330
+ const queue = [];
331
+ let waiting = null;
332
+ let closed = false;
333
+ const settle = (line) => {
334
+ const resolve = waiting;
335
+ waiting = null;
336
+ resolve?.(line);
337
+ };
338
+ void (async () => {
339
+ try {
340
+ for await (const line of io.lines()) {
341
+ if (waiting)
342
+ settle(line);
343
+ else
344
+ queue.push(line);
345
+ }
346
+ }
347
+ catch { }
348
+ closed = true;
349
+ gone();
350
+ settle(null);
351
+ })();
352
+ const reply = () => {
353
+ if (queue.length)
354
+ return Promise.resolve(queue.shift());
355
+ if (closed)
356
+ return Promise.resolve(null);
357
+ return new Promise((resolve) => {
358
+ const timer = setTimeout(() => settle(null), REPLY_MS);
359
+ timer.unref?.();
360
+ waiting = (line) => {
361
+ clearTimeout(timer);
362
+ resolve(line);
363
+ };
364
+ });
365
+ };
366
+ return async (message, requests) => {
367
+ io.out(`${JSON.stringify({ wake: message, requests })}\n`);
368
+ const answered = await reply();
369
+ try {
370
+ return answered !== null && JSON.parse(answered).delivered === true;
371
+ }
372
+ catch {
373
+ return false;
374
+ }
375
+ };
376
+ }
292
377
  /**
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.
378
+ * `pendingyou listen --app <app> --thread <id>`: waits on Pending You for the thread's cards, and wakes the thread
379
+ * (Codex: `codex queue`; OpenCode and Pi: through the plugin or extension) when one is the agent's move. Quiet but for
380
+ * the wake lines it hands a plugin; exits when the thread has none left, the app can't take the wake, the sign-in ends,
381
+ * or after LISTEN_MS.
296
382
  */
297
383
  export async function listen(io, options) {
298
- const { origin, thread } = options;
299
- const lease = await takeLease(io, thread);
384
+ const { origin, app, thread } = options;
385
+ const lease = await takeLease(io, app, thread);
300
386
  if (!lease)
301
387
  return 0;
388
+ const controller = new AbortController();
389
+ const stop = () => controller.abort();
390
+ if (io.signal.aborted)
391
+ stop();
392
+ io.signal.addEventListener('abort', stop, { once: true });
393
+ const signal = controller.signal;
394
+ const deliver = app === 'codex' ? byCodexQueue(io, thread) : byPlugin(io, stop);
302
395
  try {
303
396
  const started = io.now();
304
397
  let since;
305
398
  let failures = 0;
306
- while (io.now() - started < LISTEN_MS && !io.signal.aborted) {
307
- const cards = (await load(io)).threads[thread]?.cards ?? {};
399
+ while (io.now() - started < LISTEN_MS && !signal.aborted) {
400
+ const cards = (await load(io, app)).threads[thread]?.cards ?? {};
308
401
  if (Object.keys(cards).length === 0)
309
402
  return 0;
310
- if (!(await renew(io, thread, lease)))
403
+ if (!(await renew(io, app, thread, lease)))
311
404
  return 0;
312
405
  const query = new URLSearchParams({
313
- source: 'codex',
406
+ source: app,
314
407
  limit: '20',
315
408
  wait: String(LONG_POLL_SECONDS),
316
409
  });
@@ -318,19 +411,19 @@ export async function listen(io, options) {
318
411
  query.set('since', since);
319
412
  let requests;
320
413
  try {
321
- const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, io.signal, { app: 'codex' });
414
+ const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, signal, { app });
322
415
  if (status !== 200)
323
416
  throw new Unavailable(`Pending You answered ${status}.`);
324
417
  requests = Array.isArray(body.requests) ? body.requests : [];
325
418
  failures = 0;
326
419
  }
327
420
  catch (error) {
328
- if (error instanceof SignInNeeded || io.signal.aborted)
421
+ if (error instanceof SignInNeeded || signal.aborted)
329
422
  return 0;
330
423
  // Renewed first: no pause here outlasts the lease.
331
- if (!(await renew(io, thread, lease)))
424
+ if (!(await renew(io, app, thread, lease)))
332
425
  return 0;
333
- await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], io.signal);
426
+ await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], signal);
334
427
  continue;
335
428
  }
336
429
  for (const heard of requests)
@@ -342,7 +435,7 @@ export async function listen(io, options) {
342
435
  const card = cards[heard.requestId];
343
436
  if (!card || !heard.ready)
344
437
  continue;
345
- // Handed over already (the person wrote in Codex since): nothing to wake it for.
438
+ // Handed over already (the person wrote in the app since): nothing to wake it for.
346
439
  if (await wasHanded(io, origin, heard.requestId, momentOf(heard)))
347
440
  done.push(heard.requestId);
348
441
  else
@@ -350,10 +443,9 @@ export async function listen(io, options) {
350
443
  }
351
444
  const message = wakeMessage(ready);
352
445
  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) => {
446
+ // The app doesn't know the thread (archived, gone), or can't be reached from here: the hooks hand answers over.
447
+ if (!(await deliver(message, ready.map(({ heard }) => heard.requestId)))) {
448
+ await change(io, app, (state) => {
357
449
  delete state.threads[thread];
358
450
  });
359
451
  return 0;
@@ -367,20 +459,21 @@ export async function listen(io, options) {
367
459
  if (cards[heard.requestId] && isClosed(heard) && !done.includes(heard.requestId))
368
460
  done.push(heard.requestId);
369
461
  if (done.length)
370
- await change(io, (state) => {
462
+ await change(io, app, (state) => {
371
463
  const waiting = state.threads[thread];
372
464
  if (waiting)
373
465
  for (const id of done)
374
466
  delete waiting.cards[id];
375
467
  });
376
468
  if (io.now() - started > PATIENT_AFTER_MS)
377
- await io.sleep(PATIENT_PAUSE_MS, io.signal);
469
+ await io.sleep(PATIENT_PAUSE_MS, signal);
378
470
  }
379
471
  return 0;
380
472
  }
381
473
  finally {
474
+ io.signal.removeEventListener('abort', stop);
382
475
  // 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(() => { });
476
+ if ((await readFile(leasePath(io, app, thread), 'utf8').catch(() => null)) === lease)
477
+ await rm(leasePath(io, app, thread), { force: true }).catch(() => { });
385
478
  }
386
479
  }
package/dist/apps/ids.js CHANGED
@@ -3,11 +3,13 @@
3
3
  // (registry.ts lists them; docs/apps.md says what a module does). Kept apart from the modules so the argument parser can
4
4
  // read the list without loading them.
5
5
  /** Every app with a module, in the order init sets them up and status lists them. */
6
- export const APP_IDS = ['claude-code', 'codex'];
6
+ export const APP_IDS = ['claude-code', 'codex', 'opencode', 'pi'];
7
7
  /** What people call each app: "Claude Code on devbox" is a connection's name on the Assistants page. */
8
8
  export const APP_NAMES = {
9
9
  'claude-code': 'Claude Code',
10
10
  codex: 'Codex',
11
+ opencode: 'OpenCode',
12
+ pi: 'Pi',
11
13
  };
12
14
  /**
13
15
  * The app a command acts for when it names none: Claude Code, the only app before 0.11.0, so hook lines and helpers