pendingyou 0.11.0 → 0.13.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';
@@ -24,6 +31,7 @@ import { readCredential } from "../credentials.js";
24
31
  import { configDir, readJson, withLock, writeWhole } from "../files.js";
25
32
  import { clip, isClosed } from "../format.js";
26
33
  import { markHanded, momentOf, wasHanded } from "../state.js";
34
+ import { findCodex } from "./codex-app.js";
27
35
  /** How long a turn's record, and a thread untouched, are kept. */
28
36
  const KEEP_TURN_MS = 24 * 60 * 60_000;
29
37
  const KEEP_THREAD_MS = 7 * 24 * 60 * 60_000;
@@ -37,14 +45,37 @@ const LEASE_STALE_MS = 90_000;
37
45
  const PATIENT_AFTER_MS = 30 * 60_000;
38
46
  const PATIENT_PAUSE_MS = 30_000;
39
47
  const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
48
+ /** How long OpenCode's listener waits for the plugin to say whether it started the turn. */
49
+ const REPLY_MS = 60_000;
40
50
  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`);
51
+ /** A thread's id: Codex's (a UUID) or an OpenCode session's (`ses_…`). */
52
+ export const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
53
+ /** Pending You's card tools as Codex and Pi name them: `mcp__pendingyou__post_request`. */
54
+ const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request)$/i;
55
+ /**
56
+ * The card calls the hook reads, on a Pending You server: Codex and Pi name a tool `mcp__pendingyou__post_request`,
57
+ * OpenCode `pendingyou_post_request` (the server's name, `_`, the tool's).
58
+ */
59
+ const CARD_TOOLS = {
60
+ codex: MCP_CARD_TOOL,
61
+ opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request)$/i,
62
+ pi: MCP_CARD_TOOL,
63
+ };
64
+ /** The card tool a call was, for an app whose hooks read them; null for any other tool. */
65
+ export function cardTool(app, name) {
66
+ const pattern = CARD_TOOLS[app];
67
+ return pattern && typeof name === 'string' ? (pattern.exec(name)?.[2] ?? null) : null;
68
+ }
69
+ const statePath = (io, app) => join(configDir(io), `${app}-threads.json`);
70
+ const leasePath = (io, app, thread) => join(configDir(io), `${app}-listen`, `${thread}.lease`);
71
+ /** What uninstall takes away for an app: its threads' record and its listeners' leases. */
72
+ export const threadFiles = (io, app) => [
73
+ statePath(io, app),
74
+ join(configDir(io), `${app}-listen`),
75
+ ];
45
76
  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);
77
+ async function load(io, app) {
78
+ const file = await readJson(statePath(io, app)).catch(() => null);
48
79
  return {
49
80
  version: 1,
50
81
  threads: isObject(file?.threads) ? file.threads : {},
@@ -52,10 +83,10 @@ async function load(io) {
52
83
  };
53
84
  }
54
85
  /** Changes the state under its lock, dropping what's old. */
55
- async function change(io, edit) {
56
- const path = statePath(io);
86
+ async function change(io, app, edit) {
87
+ const path = statePath(io, app);
57
88
  return withLock(path, io, async () => {
58
- const state = await load(io);
89
+ const state = await load(io, app);
59
90
  edit(state);
60
91
  const now = io.now();
61
92
  for (const [id, turn] of Object.entries(state.turns))
@@ -115,26 +146,26 @@ function nameOf(input) {
115
146
  return text(input.name, 120) ?? text(session?.label, 120);
116
147
  }
117
148
  /**
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.
149
+ * `pendingyou posted --app <app>`: Codex's PostToolUse hook, and what OpenCode's plugin runs after a card call.
150
+ * Remembers what the turn put in front of the person and which cards the thread waits on, then (Codex) makes sure the
151
+ * thread has a listener; OpenCode's plugin starts its own. Prints nothing (Codex ignores it anyway); never fails.
121
152
  */
122
153
  export async function posted(io, options) {
154
+ const { app } = options;
123
155
  try {
124
156
  const input = JSON.parse(await io.readStdin(1000));
125
157
  if (!isObject(input))
126
158
  return 0;
127
- const tool = typeof input.tool_name === 'string' ? CARD_TOOL.exec(input.tool_name) : null;
159
+ const call = cardTool(app, input.tool_name);
128
160
  const thread = typeof input.session_id === 'string' ? input.session_id : null;
129
161
  const turn = typeof input.turn_id === 'string' ? input.turn_id : null;
130
- if (!tool || !thread)
162
+ if (!call || !thread)
131
163
  return 0;
132
- const call = tool[2];
133
164
  const args = isObject(input.tool_input) ? input.tool_input : {};
134
165
  const output = resultOf(input.tool_response);
135
166
  const requestId = [output?.requestId, args.requestId].find((id) => typeof id === 'string' && REQUEST_ID.test(id));
136
167
  const now = io.now();
137
- const state = await change(io, (state) => {
168
+ const state = await change(io, app, (state) => {
138
169
  if (turn && call !== 'cancel_request') {
139
170
  const record = state.turns[turn] ?? { at: now, cards: [] };
140
171
  record.cards = [...record.cards, wordsIn(args)].slice(-10);
@@ -162,7 +193,7 @@ export async function posted(io, options) {
162
193
  waiting.at = now;
163
194
  state.threads[thread] = waiting;
164
195
  });
165
- if (Object.keys(state.threads[thread]?.cards ?? {}).length > 0)
196
+ if (app === 'codex' && Object.keys(state.threads[thread]?.cards ?? {}).length > 0)
166
197
  await keepListening(io, options.origin, thread);
167
198
  return 0;
168
199
  }
@@ -171,31 +202,32 @@ export async function posted(io, options) {
171
202
  }
172
203
  }
173
204
  /** 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];
205
+ export async function turnRecord(io, turn, app = 'codex') {
206
+ const record = (await load(io, app)).turns[turn];
176
207
  return record ? { cards: record.cards, nudged: record.nudged === true } : null;
177
208
  }
178
209
  /** Remembers that the Stop check asked this turn to go on: at most once a turn. */
179
- export async function markNudged(io, turn) {
210
+ export async function markNudged(io, turn, app = 'codex') {
180
211
  const now = io.now();
181
- await change(io, (state) => {
212
+ await change(io, app, (state) => {
182
213
  state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, nudged: true };
183
214
  });
184
215
  }
185
216
  /** 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);
217
+ async function listening(io, app, thread) {
218
+ return stat(leasePath(io, app, thread)).then((info) => Date.now() - info.mtimeMs < LEASE_STALE_MS, () => false);
188
219
  }
189
220
  /**
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.
221
+ * Makes sure a Codex thread with cards waiting on the person has a listener: starts one, detached, unless one is there
222
+ * or Codex isn't signed in. Quick (three small reads) and never throws: the session-start and next-message hooks call
223
+ * it too.
192
224
  */
193
225
  export async function keepListening(io, origin, thread) {
194
226
  try {
195
- if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/.test(thread))
227
+ if (!THREAD.test(thread))
196
228
  return;
197
- const cards = (await load(io)).threads[thread]?.cards ?? {};
198
- if (Object.keys(cards).length === 0 || (await listening(io, thread)))
229
+ const cards = (await load(io, 'codex')).threads[thread]?.cards ?? {};
230
+ if (Object.keys(cards).length === 0 || (await listening(io, 'codex', thread)))
199
231
  return;
200
232
  if (!(await readCredential(io, origin, 'codex')))
201
233
  return;
@@ -265,10 +297,10 @@ export function wakeMessage(ready) {
265
297
  * Takes the thread's lease, a file holding this listener's own id: null when a live listener holds it. Two listeners
266
298
  * starting at once may both write it; the one whose id isn't there at its next renewal stops.
267
299
  */
268
- async function takeLease(io, thread) {
269
- const path = leasePath(io, thread);
300
+ async function takeLease(io, app, thread) {
301
+ const path = leasePath(io, app, thread);
270
302
  await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
271
- if (await listening(io, thread))
303
+ if (await listening(io, app, thread))
272
304
  return null;
273
305
  const id = randomBytes(8).toString('hex');
274
306
  await rm(path, { recursive: true, force: true });
@@ -281,8 +313,8 @@ async function takeLease(io, thread) {
281
313
  }
282
314
  }
283
315
  /** 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);
316
+ async function renew(io, app, thread, id) {
317
+ const path = leasePath(io, app, thread);
286
318
  if ((await readFile(path, 'utf8').catch(() => null)) !== id)
287
319
  return false;
288
320
  const now = new Date();
@@ -290,27 +322,99 @@ async function renew(io, thread, id) {
290
322
  return true;
291
323
  }
292
324
  /**
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.
325
+ * Codex: `codex queue`, which starts the turn itself: the codex on PATH, or the Codex app's own (0.13.0, codex-app.ts),
326
+ * found once, when the listener first wakes the thread.
327
+ */
328
+ function byCodexQueue(io, thread) {
329
+ let program = null;
330
+ return async (message) => {
331
+ program ??= findCodex(io).then((found) => found?.command ?? 'codex');
332
+ const queued = await io.run(await program, ['queue', '--thread', thread, '--message', message], 30_000);
333
+ return queued.code === 0;
334
+ };
335
+ }
336
+ /**
337
+ * OpenCode and Pi: the plugin (Pi: the extension) that started this listener reads the wake on stdout and starts the
338
+ * turn, then says on stdin whether it did. stdin closing means the plugin (the app) is gone: `gone` stops the listener.
339
+ */
340
+ function byPlugin(io, gone) {
341
+ const queue = [];
342
+ let waiting = null;
343
+ let closed = false;
344
+ const settle = (line) => {
345
+ const resolve = waiting;
346
+ waiting = null;
347
+ resolve?.(line);
348
+ };
349
+ void (async () => {
350
+ try {
351
+ for await (const line of io.lines()) {
352
+ if (waiting)
353
+ settle(line);
354
+ else
355
+ queue.push(line);
356
+ }
357
+ }
358
+ catch { }
359
+ closed = true;
360
+ gone();
361
+ settle(null);
362
+ })();
363
+ const reply = () => {
364
+ if (queue.length)
365
+ return Promise.resolve(queue.shift());
366
+ if (closed)
367
+ return Promise.resolve(null);
368
+ return new Promise((resolve) => {
369
+ const timer = setTimeout(() => settle(null), REPLY_MS);
370
+ timer.unref?.();
371
+ waiting = (line) => {
372
+ clearTimeout(timer);
373
+ resolve(line);
374
+ };
375
+ });
376
+ };
377
+ return async (message, requests) => {
378
+ io.out(`${JSON.stringify({ wake: message, requests })}\n`);
379
+ const answered = await reply();
380
+ try {
381
+ return answered !== null && JSON.parse(answered).delivered === true;
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ };
387
+ }
388
+ /**
389
+ * `pendingyou listen --app <app> --thread <id>`: waits on Pending You for the thread's cards, and wakes the thread
390
+ * (Codex: `codex queue`; OpenCode and Pi: through the plugin or extension) when one is the agent's move. Quiet but for
391
+ * 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,
392
+ * or after LISTEN_MS.
296
393
  */
297
394
  export async function listen(io, options) {
298
- const { origin, thread } = options;
299
- const lease = await takeLease(io, thread);
395
+ const { origin, app, thread } = options;
396
+ const lease = await takeLease(io, app, thread);
300
397
  if (!lease)
301
398
  return 0;
399
+ const controller = new AbortController();
400
+ const stop = () => controller.abort();
401
+ if (io.signal.aborted)
402
+ stop();
403
+ io.signal.addEventListener('abort', stop, { once: true });
404
+ const signal = controller.signal;
405
+ const deliver = app === 'codex' ? byCodexQueue(io, thread) : byPlugin(io, stop);
302
406
  try {
303
407
  const started = io.now();
304
408
  let since;
305
409
  let failures = 0;
306
- while (io.now() - started < LISTEN_MS && !io.signal.aborted) {
307
- const cards = (await load(io)).threads[thread]?.cards ?? {};
410
+ while (io.now() - started < LISTEN_MS && !signal.aborted) {
411
+ const cards = (await load(io, app)).threads[thread]?.cards ?? {};
308
412
  if (Object.keys(cards).length === 0)
309
413
  return 0;
310
- if (!(await renew(io, thread, lease)))
414
+ if (!(await renew(io, app, thread, lease)))
311
415
  return 0;
312
416
  const query = new URLSearchParams({
313
- source: 'codex',
417
+ source: app,
314
418
  limit: '20',
315
419
  wait: String(LONG_POLL_SECONDS),
316
420
  });
@@ -318,19 +422,19 @@ export async function listen(io, options) {
318
422
  query.set('since', since);
319
423
  let requests;
320
424
  try {
321
- const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, io.signal, { app: 'codex' });
425
+ const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, signal, { app });
322
426
  if (status !== 200)
323
427
  throw new Unavailable(`Pending You answered ${status}.`);
324
428
  requests = Array.isArray(body.requests) ? body.requests : [];
325
429
  failures = 0;
326
430
  }
327
431
  catch (error) {
328
- if (error instanceof SignInNeeded || io.signal.aborted)
432
+ if (error instanceof SignInNeeded || signal.aborted)
329
433
  return 0;
330
434
  // Renewed first: no pause here outlasts the lease.
331
- if (!(await renew(io, thread, lease)))
435
+ if (!(await renew(io, app, thread, lease)))
332
436
  return 0;
333
- await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], io.signal);
437
+ await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], signal);
334
438
  continue;
335
439
  }
336
440
  for (const heard of requests)
@@ -342,7 +446,7 @@ export async function listen(io, options) {
342
446
  const card = cards[heard.requestId];
343
447
  if (!card || !heard.ready)
344
448
  continue;
345
- // Handed over already (the person wrote in Codex since): nothing to wake it for.
449
+ // Handed over already (the person wrote in the app since): nothing to wake it for.
346
450
  if (await wasHanded(io, origin, heard.requestId, momentOf(heard)))
347
451
  done.push(heard.requestId);
348
452
  else
@@ -350,10 +454,9 @@ export async function listen(io, options) {
350
454
  }
351
455
  const message = wakeMessage(ready);
352
456
  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) => {
457
+ // The app doesn't know the thread (archived, gone), or can't be reached from here: the hooks hand answers over.
458
+ if (!(await deliver(message, ready.map(({ heard }) => heard.requestId)))) {
459
+ await change(io, app, (state) => {
357
460
  delete state.threads[thread];
358
461
  });
359
462
  return 0;
@@ -367,20 +470,21 @@ export async function listen(io, options) {
367
470
  if (cards[heard.requestId] && isClosed(heard) && !done.includes(heard.requestId))
368
471
  done.push(heard.requestId);
369
472
  if (done.length)
370
- await change(io, (state) => {
473
+ await change(io, app, (state) => {
371
474
  const waiting = state.threads[thread];
372
475
  if (waiting)
373
476
  for (const id of done)
374
477
  delete waiting.cards[id];
375
478
  });
376
479
  if (io.now() - started > PATIENT_AFTER_MS)
377
- await io.sleep(PATIENT_PAUSE_MS, io.signal);
480
+ await io.sleep(PATIENT_PAUSE_MS, signal);
378
481
  }
379
482
  return 0;
380
483
  }
381
484
  finally {
485
+ io.signal.removeEventListener('abort', stop);
382
486
  // 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(() => { });
487
+ if ((await readFile(leasePath(io, app, thread), 'utf8').catch(() => null)) === lease)
488
+ await rm(leasePath(io, app, thread), { force: true }).catch(() => { });
385
489
  }
386
490
  }
@@ -20,17 +20,22 @@
20
20
  // which it does by itself from 0.157): codex-wake.ts.
21
21
  // - Codex's /import can copy Claude Code's Pending You server (without its headersHelper, so it never signs in) and its
22
22
  // hooks (with Claude Code's app): init replaces both.
23
+ // - The Codex app for macOS (0.13.0, codex-app.ts) shares all of ~/.codex with Codex CLI, and carries its own codex:
24
+ // with none on PATH, init, status and the listener run the app's. Its trust is "Review hooks › Allow all", and Save on
25
+ // pendingyou in its MCP settings writes the table again without http_headers_helper (and the approval mode): init's
26
+ // last lines say both, status names the dropped helper, and running init again puts it back.
23
27
  import { createHash } from 'node:crypto';
24
28
  import { realpath, rm } from 'node:fs/promises';
25
29
  import { join } from 'node:path';
26
30
  import { removeSkill, saveSkill } from "../claude.js";
27
31
  import { readCredential } from "../credentials.js";
28
32
  import { configDir, readJson, readText, writeWhole } from "../files.js";
29
- import { isOurHook, mergeHooks, originArgs, removeHooks, } from "../hooks.js";
33
+ import { isOurHook, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
30
34
  import { atLeast } from "../mod.js";
31
35
  import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, } from "../remote.js";
32
36
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
33
37
  import { basicString, definedInline, headerOf, renameTable, tableSpans, tableValues, withTable, } from "../toml.js";
38
+ import { findCodex } from "./codex-app.js";
34
39
  import { APP_NAMES } from "./ids.js";
35
40
  const NAME = APP_NAMES.codex;
36
41
  const SERVER = ['mcp_servers', 'pendingyou'];
@@ -38,6 +43,18 @@ const SERVER = ['mcp_servers', 'pendingyou'];
38
43
  export const CODEX_MIN = '0.152.0';
39
44
  /** From here interactive Codex starts its shared background server by itself, so `codex queue` wakes an idle thread. */
40
45
  export const CODEX_WAKE = '0.157.0';
46
+ /**
47
+ * From here `codex queue` is there (0.149), which is all the Codex app needs: its own server reads the queue while the
48
+ * app is open (codex-app.ts).
49
+ */
50
+ export const CODEX_APP_WAKE = '0.149.0';
51
+ /** Where init found Codex when it isn't on PATH. */
52
+ const FROM_APP = 'the Codex app’s';
53
+ /** How the person trusts Codex's hooks, in Codex CLI and in the Codex app (0.13.0). */
54
+ const TRUST_CLI = 'in Codex CLI choose “Trust all and continue” (or trust them in /hooks)';
55
+ const TRUST_APP = 'in the Codex app click Review hooks › Allow all (or Settings › Hooks › Trust all)';
56
+ /** What undoes init in the Codex app: its MCP settings' Save writes the table again without the helper. */
57
+ 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).';
41
58
  /** The Pending You tools whose calls the PostToolUse hook reads (codex-wake.ts). */
42
59
  const CARD_TOOLS = '^mcp__pendingyou__(post_request|update_request|reply_in_thread|cancel_request)$';
43
60
  /** Codex's hooks, by event: the command each runs, and its matcher. */
@@ -210,8 +227,8 @@ async function hookTrust(io) {
210
227
  });
211
228
  }
212
229
  /** Whether `codex mcp list` says Codex holds an OAuth sign-in for its pendingyou server; null when it can't say. */
213
- async function signedInByItself(io) {
214
- const result = await io.run('codex', ['mcp', 'list', '--json'], 30_000);
230
+ async function signedInByItself(io, program) {
231
+ const result = await io.run(program, ['mcp', 'list', '--json'], 30_000);
215
232
  if (result.code !== 0)
216
233
  return null;
217
234
  try {
@@ -279,10 +296,14 @@ export const codex = {
279
296
  name: NAME,
280
297
  minVersion: CODEX_MIN,
281
298
  async detect(io) {
282
- const result = await io.run('codex', ['--version'], 20_000);
283
- if (result.code !== 0)
299
+ // `codex` on PATH, else the Codex app's own (codex-app.ts): init, status and the listener run that one.
300
+ const found = await findCodex(io);
301
+ if (!found)
284
302
  return null;
285
- return { version: /(\d+\.\d+\.\d+)/.exec(result.stdout)?.[1] ?? 'installed' };
303
+ return {
304
+ version: found.version,
305
+ ...(found.app ? { command: found.command, from: FROM_APP } : {}),
306
+ };
286
307
  },
287
308
  async installed(io) {
288
309
  return (await readCodexManifest(io)) !== null;
@@ -311,6 +332,8 @@ export const codex = {
311
332
  text: 'Codex on Windows isn’t set up by init yet: paste Pending You’s setup message for Codex into it instead.',
312
333
  });
313
334
  const helperMode = !ctx.oauth;
335
+ // The codex init runs: the one on PATH, or the Codex app's own.
336
+ const program = detected.command ?? 'codex';
314
337
  const server = await codexServer(io).catch(() => null);
315
338
  if (server?.inline)
316
339
  return skip({
@@ -351,7 +374,7 @@ export const codex = {
351
374
  text: `Kept Codex’s pendingyou MCP server: it signs in through a headers helper of its own (${server.helper}).`,
352
375
  };
353
376
  }
354
- else if ((await signedInByItself(io)) !== true) {
377
+ else if ((await signedInByItself(io, program)) !== true) {
355
378
  // Never signed in: copied from Claude Code by /import (which leaves its helper behind), or left half-made.
356
379
  action = 'write';
357
380
  }
@@ -397,7 +420,7 @@ export const codex = {
397
420
  });
398
421
  else {
399
422
  if (mode === 'helper' && server)
400
- await io.run('codex', ['mcp', 'logout', 'pendingyou'], 60_000);
423
+ await io.run(program, ['mcp', 'logout', 'pendingyou'], 60_000);
401
424
  const doc = (await readText(config).catch(() => null)) ?? '';
402
425
  const next = withTable(doc, SERVER, serverTable(doc, url, mode === 'helper' ? ictx.helper : null));
403
426
  if (await writeConfig(config, next)) {
@@ -408,11 +431,13 @@ export const codex = {
408
431
  ? `Added the pendingyou MCP server (${url}) to ${config}${mode === 'helper' ? ', signed in through this computer’s sign-in' : ''}; its tools run without asking.`
409
432
  : server.url && !sameUrl(server.url, url)
410
433
  ? `Replaced Codex’s pendingyou MCP server (was ${server.url}) with ${url}.`
411
- : mode === 'helper'
412
- ? `Switched Codex’s pendingyou MCP server (${url}) to this computer’s sign-in: one sign-in for it and its hooks.`
413
- : server.helper
414
- ? `Switched Codex’s pendingyou MCP server (${url}) to Codex’s own sign-in: run codex mcp login pendingyou.`
415
- : `Codex’s pendingyou MCP server (${url}) signs in by itself, and its tools now run without asking.`,
434
+ : mode === 'helper' && before?.helper && !server.helper
435
+ ? `Put this computer’s sign-in back on Codex’s pendingyou MCP server (${url}): it had lost its http_headers_helper, which saving it in the Codex app’s MCP settings does.`
436
+ : mode === 'helper'
437
+ ? `Switched Codex’s pendingyou MCP server (${url}) to this computer’s sign-in: one sign-in for it and its hooks.`
438
+ : server.helper
439
+ ? `Switched Codex’s pendingyou MCP server (${url}) to Codex’s own sign-in: run codex mcp login pendingyou.`
440
+ : `Codex’s pendingyou MCP server (${url}) signs in by itself, and its tools now run without asking.`,
416
441
  });
417
442
  }
418
443
  else
@@ -450,10 +475,12 @@ export const codex = {
450
475
  });
451
476
  }
452
477
  }
453
- if (!atLeast(version, CODEX_WAKE))
478
+ if (!atLeast(version, detected.command ? CODEX_APP_WAKE : CODEX_WAKE))
454
479
  report({
455
480
  ok: true,
456
- text: `Codex ${version} wakes an idle thread with your answer only while its background server runs, which Codex starts by itself from ${CODEX_WAKE}. Until you update, an answer reaches a thread when you next write in it.`,
481
+ text: detected.command
482
+ ? `The Codex app’s codex ${version} has no codex queue, so an answer reaches a chat only when you next write in it. Update the Codex app, then run init again.`
483
+ : `Codex ${version} wakes an idle thread with your answer only while its background server runs, which Codex starts by itself from ${CODEX_WAKE}. Until you update, an answer reaches a thread when you next write in it.`,
457
484
  });
458
485
  const manifest = {
459
486
  version: 1,
@@ -472,15 +499,14 @@ export const codex = {
472
499
  },
473
500
  next: () => {
474
501
  const login = mode === 'oauth' && action !== 'keep';
475
- const start = `start codex here, or in the folder you work in, and choose “Trust all and continue” when it asks about hooks (or trust Pending You’s in /hooks)`;
502
+ const first = login ? `run ${shWord(program)} mcp login pendingyou, then ` : '';
503
+ // Codex CLI and the Codex app share ~/.codex: name both ways to trust the hooks (0.13.0).
504
+ const start = detected.command
505
+ ? `open the Codex app, start a chat in the folder you work in, and trust Pending You’s hooks: click Review hooks › Allow all (or Settings › Hooks › Trust all)`
506
+ : `start codex here, or in the folder you work in, and trust Pending You’s hooks: ${TRUST_CLI}; ${TRUST_APP}`;
476
507
  return {
477
- lines: [
478
- `Next: ${login ? 'run codex mcp login pendingyou, then ' : ''}${start}.`,
479
- 'Then say this to it:',
480
- '',
481
- FINISH_SAY,
482
- ],
483
- together: `- Codex: ${login ? 'run codex mcp login pendingyou, then ' : ''}${start}, then say the line below to it.`,
508
+ lines: [`Next: ${first}${start}.`, DONT_SAVE, 'Then say this to it:', '', FINISH_SAY],
509
+ together: `- Codex: ${first}${start}, then say the line below to it. ${DONT_SAVE}`,
484
510
  say: true,
485
511
  };
486
512
  },
@@ -560,6 +586,8 @@ export const codex = {
560
586
  ? `not signed in; run ${fix}`
561
587
  : 'Pending You couldn’t be reached';
562
588
  const mcpOk = Boolean(server?.url && sameUrl(server.url, url) && (!helper || helped));
589
+ // init gave it the helper, and it's gone: saving it in the Codex app's MCP settings writes the table again without.
590
+ const dropped = Boolean(server?.url && sameUrl(server.url, url) && !server.helper && manifest?.helper);
563
591
  const mcpText = !server
564
592
  ? `not added; run npx pendingyou init --app codex${flag}`
565
593
  : server.inline
@@ -568,9 +596,11 @@ export const codex = {
568
596
  ? `points at ${server.url}, not ${url}; run npx pendingyou init --app codex${flag} to replace it`
569
597
  : helped
570
598
  ? `pendingyou (${server.url}), signed in through ${helperPath(io)} --app codex${server.approval === 'approve' ? '; its tools run without asking' : ''}`
571
- : helper
572
- ? `pendingyou (${server.url}) signs in by itself; run npx pendingyou init --app codex${flag} to use this computer’s sign-in`
573
- : `pendingyou (${server.url}), signed in by Codex itself`;
599
+ : dropped
600
+ ? `pendingyou (${server.url}) has lost its http_headers_helper${server.approval === 'approve' ? '' : ' and default_tools_approval_mode'} (saving it in the Codex app’s MCP settings does that), so Codex can’t sign in through this computer’s sign-in; run npx pendingyou init --app codex${flag} again`
601
+ : helper
602
+ ? `pendingyou (${server.url}) signs in by itself; run npx pendingyou init --app codex${flag} to use this computer’s sign-in`
603
+ : `pendingyou (${server.url}), signed in by Codex itself`;
574
604
  const check = helper ? await checkHelper(io, origin, 'codex') : null;
575
605
  const words = {
576
606
  trusted: 'trusted',
@@ -582,12 +612,14 @@ export const codex = {
582
612
  const hooksOk = trust.every((hook) => hook.trust === 'trusted');
583
613
  const hooksText = trust.some((hook) => hook.trust === 'missing')
584
614
  ? `not installed; run npx pendingyou init --app codex${flag}`
585
- : `${trust.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : '. Start codex and choose “Trust all and continue”, or trust them in /hooks: Codex skips a hook you haven’t trusted'}`;
586
- const wakes = detected ? atLeast(detected.version, CODEX_WAKE) : false;
615
+ : `${trust.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
616
+ const wakes = detected
617
+ ? atLeast(detected.version, detected.command ? CODEX_APP_WAKE : CODEX_WAKE)
618
+ : false;
587
619
  const lines = [
588
620
  `Pending You for Codex · ${origin}${why ? ` · no browser here (${why})` : ''}`,
589
621
  ` ${mark(signOk)} Sign-in: ${signText}`,
590
- ` ${mark(detected !== null)} Codex: ${detected?.version ?? 'not found on PATH'}`,
622
+ ` ${mark(detected !== null)} Codex: ${detected ? `${detected.version}${detected.command ? ` (${FROM_APP}: ${detected.command})` : ''}` : 'not found on PATH, nor the Codex app'}`,
591
623
  ` ${mark(mcpOk)} MCP server: ${mcpText}`,
592
624
  ...(check
593
625
  ? [
@@ -598,7 +630,11 @@ export const codex = {
598
630
  : []),
599
631
  ` ${mark(skill !== null)} Skill: ${skill !== null ? `saved (${codexSkillPath(io)})` : `not saved; run npx pendingyou init --app codex${flag}`}`,
600
632
  ` ${mark(hooksOk)} Hooks: ${hooksText}`,
601
- ` ${mark(wakes)} Wake: ${wakes ? 'codex queue wakes a thread when you answer, even while it’s idle' : `needs Codex ${CODEX_WAKE} or later${detected ? ` (this is ${detected.version})` : ''}; until then an answer reaches a thread when you next write in it`}`,
633
+ ` ${mark(wakes)} Wake: ${wakes
634
+ ? detected?.command
635
+ ? 'codex queue wakes a chat the Codex app has open when you answer, while the app runs (one it let go, idle 3 hours or behind 10 newer idle chats, hears it when you open it again)'
636
+ : 'codex queue wakes a thread when you answer, even while it’s idle'
637
+ : `needs Codex ${detected?.command ? CODEX_APP_WAKE : CODEX_WAKE} or later${detected ? ` (this is ${detected.version})` : ''}; until then an answer reaches a thread when you next write in it`}`,
602
638
  ];
603
639
  const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
604
640
  if (setup)
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