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.
- package/CHANGELOG.md +142 -0
- package/README.md +131 -26
- package/dist/api.js +10 -8
- package/dist/apps/claude-code.js +20 -2
- package/dist/apps/codex-app.js +44 -0
- package/dist/apps/codex-wake.js +169 -65
- package/dist/apps/codex.js +66 -30
- package/dist/apps/ids.js +3 -1
- package/dist/apps/opencode-plugin.js +440 -0
- package/dist/apps/opencode.js +734 -0
- package/dist/apps/pi-extension.js +396 -0
- package/dist/apps/pi.js +679 -0
- package/dist/apps/registry.js +3 -1
- package/dist/args.js +58 -13
- package/dist/bridge.js +648 -0
- package/dist/claude.js +78 -8
- package/dist/environment.js +9 -2
- package/dist/files.js +5 -0
- package/dist/hold.js +15 -0
- package/dist/hooks.js +8 -3
- package/dist/init.js +50 -20
- package/dist/io.js +36 -2
- package/dist/json.js +281 -0
- package/dist/jsonc.js +315 -0
- package/dist/main.js +20 -5
- package/dist/permission.js +738 -0
- package/dist/pickup.js +23 -11
- package/dist/redact.js +369 -0
- package/dist/remote.js +24 -10
- package/dist/setup.js +29 -9
- package/dist/shim.js +8 -1
- package/dist/state.js +7 -2
- package/dist/stopcheck.js +79 -6
- package/dist/version.js +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/README.md +1 -1
- package/mod/hooks/register.js +7 -3
- package/mod/hooks/wake.js +17 -4
- package/package.json +2 -2
package/dist/apps/codex-wake.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
// Codex hearing answers (0.11.0)
|
|
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
|
|
10
|
-
// does, and when one of the thread's cards is the agent's move (an answer, a message, a fallback that ran) it
|
|
11
|
-
// `codex queue --thread <id> --message "Pending You: your person answered …"`, in the words the Claude Code wake
|
|
12
|
-
// uses. Never the answer itself: a program's arguments are there for any other program on the computer to read,
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
|
119
|
-
*
|
|
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
|
|
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 (!
|
|
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
|
|
191
|
-
* Codex isn't signed in. Quick (three small reads) and never throws: the session-start and next-message hooks call
|
|
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 (
|
|
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
|
-
* `
|
|
294
|
-
*
|
|
295
|
-
|
|
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 && !
|
|
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:
|
|
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,
|
|
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 ||
|
|
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)],
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
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
|
}
|
package/dist/apps/codex.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
283
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
? `
|
|
413
|
-
:
|
|
414
|
-
? `Switched Codex’s pendingyou MCP server (${url}) to
|
|
415
|
-
:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
572
|
-
? `pendingyou (${server.url})
|
|
573
|
-
:
|
|
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 ? '' :
|
|
586
|
-
const wakes = detected
|
|
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
|
|
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
|
|
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
|