pendingyou 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +173 -56
- package/dist/api.js +60 -17
- package/dist/apps/claude-code.js +365 -0
- package/dist/apps/codex-wake.js +386 -0
- package/dist/apps/codex.js +615 -0
- package/dist/apps/ids.js +24 -0
- package/dist/apps/registry.js +12 -0
- package/dist/apps/types.js +1 -0
- package/dist/args.js +170 -33
- package/dist/claude.js +246 -199
- package/dist/confirm.js +5 -4
- package/dist/credentials.js +99 -19
- package/dist/environment.js +10 -3
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -16
- package/dist/hooks.js +117 -0
- package/dist/index.js +4 -1
- package/dist/init.js +280 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +39 -156
- package/dist/mod.js +89 -0
- package/dist/oauth.js +165 -48
- package/dist/pickup.js +67 -28
- package/dist/qr.js +365 -0
- package/dist/remote.js +347 -0
- package/dist/setup.js +109 -0
- package/dist/shim.js +93 -0
- package/dist/signin.js +227 -0
- package/dist/state.js +17 -28
- package/dist/stopcheck.js +45 -18
- package/dist/toml.js +277 -0
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +377 -0
- package/mod/hooks/wake.js +652 -0
- package/mod/tests/wake.test.ts +247 -0
- package/package.json +3 -2
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
// Codex CLI (0.11.0): what `pendingyou init` sets up for it, `status`'s section and `uninstall`. Facts from Codex's own
|
|
2
|
+
// source (0.160.0, 2026-10-04; docs/assistants/codex.md has the details):
|
|
3
|
+
//
|
|
4
|
+
// - Its MCP server is a table in ~/.codex/config.toml (or $CODEX_HOME's), [mcp_servers.pendingyou], edited in place with
|
|
5
|
+
// everything else in the file kept as it was (toml.ts). It signs in through this computer's own connection ("Codex on
|
|
6
|
+
// devbox"): `http_headers_helper` runs the headers helper with `--app codex` (sh -c, a stripped environment, 10
|
|
7
|
+
// seconds; stdout one JSON object of strings), which may give Authorization from Codex 0.152 (0.148–0.151 refused that
|
|
8
|
+
// header). `default_tools_approval_mode = "approve"` lets Pending You's tools run without asking each time. A stored
|
|
9
|
+
// OAuth token would win over the helper, so `codex mcp logout pendingyou` goes first. With `--oauth` the table has no
|
|
10
|
+
// helper and the person signs Codex in with `codex mcp login pendingyou`. `codex mcp add --url` isn't used: it starts
|
|
11
|
+
// an OAuth sign-in in the browser at once.
|
|
12
|
+
// - Its skill is ~/.agents/skills/pendingyou/SKILL.md: the guide's stub, as for Claude Code.
|
|
13
|
+
// - Its hooks are in ~/.codex/hooks.json, Claude Code's shape: SessionStart (pickup; startup, resume or clear),
|
|
14
|
+
// UserPromptSubmit (handoff), PostToolUse on Pending You's card tools (posted: codex-wake.ts) and Stop (stopcheck),
|
|
15
|
+
// each through the shim with `--app codex`. Codex runs a hook only while the person trusts its exact definition (a
|
|
16
|
+
// hash of it, under [hooks.state."<file>:<event>:<group>:<handler>"] in config.toml), and skips an untrusted or
|
|
17
|
+
// changed one without a word: the shim keeps definitions the same across versions, new entries go at the end (the
|
|
18
|
+
// key is positional), and uninstall moves the person's own hooks' trust when theirs move up. `status` checks each.
|
|
19
|
+
// - It's woken by `codex queue` (0.149; it starts a turn on an idle thread when Codex's shared background server runs,
|
|
20
|
+
// which it does by itself from 0.157): codex-wake.ts.
|
|
21
|
+
// - Codex's /import can copy Claude Code's Pending You server (without its headersHelper, so it never signs in) and its
|
|
22
|
+
// hooks (with Claude Code's app): init replaces both.
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { realpath, rm } from 'node:fs/promises';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { removeSkill, saveSkill } from "../claude.js";
|
|
27
|
+
import { readCredential } from "../credentials.js";
|
|
28
|
+
import { configDir, readJson, readText, writeWhole } from "../files.js";
|
|
29
|
+
import { isOurHook, mergeHooks, originArgs, removeHooks, } from "../hooks.js";
|
|
30
|
+
import { atLeast } from "../mod.js";
|
|
31
|
+
import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, } from "../remote.js";
|
|
32
|
+
import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
|
|
33
|
+
import { basicString, definedInline, headerOf, renameTable, tableSpans, tableValues, withTable, } from "../toml.js";
|
|
34
|
+
import { APP_NAMES } from "./ids.js";
|
|
35
|
+
const NAME = APP_NAMES.codex;
|
|
36
|
+
const SERVER = ['mcp_servers', 'pendingyou'];
|
|
37
|
+
/** From here its helper may give Authorization (and it runs again after a 401): the oldest Codex init sets up. */
|
|
38
|
+
export const CODEX_MIN = '0.152.0';
|
|
39
|
+
/** From here interactive Codex starts its shared background server by itself, so `codex queue` wakes an idle thread. */
|
|
40
|
+
export const CODEX_WAKE = '0.157.0';
|
|
41
|
+
/** The Pending You tools whose calls the PostToolUse hook reads (codex-wake.ts). */
|
|
42
|
+
const CARD_TOOLS = '^mcp__pendingyou__(post_request|update_request|reply_in_thread|cancel_request)$';
|
|
43
|
+
/** Codex's hooks, by event: the command each runs, and its matcher. */
|
|
44
|
+
export const CODEX_HOOKS = [
|
|
45
|
+
{ event: 'SessionStart', sub: 'pickup', matcher: 'startup|resume|clear' },
|
|
46
|
+
{ event: 'UserPromptSubmit', sub: 'handoff' },
|
|
47
|
+
{ event: 'PostToolUse', sub: 'posted', matcher: CARD_TOOLS },
|
|
48
|
+
{ event: 'Stop', sub: 'stopcheck' },
|
|
49
|
+
];
|
|
50
|
+
/** Each event as Codex names it in a hook's trust key. */
|
|
51
|
+
const EVENT_LABELS = {
|
|
52
|
+
SessionStart: 'session_start',
|
|
53
|
+
UserPromptSubmit: 'user_prompt_submit',
|
|
54
|
+
PostToolUse: 'post_tool_use',
|
|
55
|
+
Stop: 'stop',
|
|
56
|
+
};
|
|
57
|
+
/** What a hook's trust ignores the matcher of: events whose matcher Codex doesn't read. */
|
|
58
|
+
const NO_MATCHER = new Set(['UserPromptSubmit', 'Stop']);
|
|
59
|
+
/** Keys of the server's table that sign it in some other way: they go when it signs in through the helper. */
|
|
60
|
+
const SIGN_IN_KEYS = new Set([
|
|
61
|
+
'bearer_token_env_var',
|
|
62
|
+
'http_headers',
|
|
63
|
+
'env_http_headers',
|
|
64
|
+
'http_headers_helper',
|
|
65
|
+
'oauth',
|
|
66
|
+
'oauth_resource',
|
|
67
|
+
'scopes',
|
|
68
|
+
]);
|
|
69
|
+
/** Codex's own folder: $CODEX_HOME (as Codex resolves it) or ~/.codex. */
|
|
70
|
+
async function codexHome(io) {
|
|
71
|
+
const set = io.env.CODEX_HOME;
|
|
72
|
+
if (set)
|
|
73
|
+
return realpath(set).catch(() => set);
|
|
74
|
+
return join(io.home, '.codex');
|
|
75
|
+
}
|
|
76
|
+
const configPath = async (io) => join(await codexHome(io), 'config.toml');
|
|
77
|
+
const hooksPath = async (io) => join(await codexHome(io), 'hooks.json');
|
|
78
|
+
export const codexSkillPath = (io) => join(io.home, '.agents', 'skills', 'pendingyou', 'SKILL.md');
|
|
79
|
+
const manifestPath = (io) => join(configDir(io), 'codex.json');
|
|
80
|
+
export const readCodexManifest = (io) => readJson(manifestPath(io)).catch(() => null);
|
|
81
|
+
/** Reads Codex's pendingyou server from its config (read only); null when there's none or no config. */
|
|
82
|
+
export async function codexServer(io) {
|
|
83
|
+
const doc = await readText(await configPath(io)).catch(() => null);
|
|
84
|
+
if (doc === null)
|
|
85
|
+
return null;
|
|
86
|
+
if (definedInline(doc, SERVER))
|
|
87
|
+
return { url: null, helper: null, approval: null, inline: true };
|
|
88
|
+
if (tableSpans(doc, SERVER).length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
const values = tableValues(doc, SERVER);
|
|
91
|
+
const text = (key) => (typeof values[key] === 'string' ? values[key] : null);
|
|
92
|
+
return {
|
|
93
|
+
url: text('url'),
|
|
94
|
+
helper: text('http_headers_helper'),
|
|
95
|
+
approval: text('default_tools_approval_mode'),
|
|
96
|
+
inline: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const sameUrl = (a, b) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
|
|
100
|
+
/** The server's table as init writes it: ours first, then whatever else the person had in it (sign-in keys aside). */
|
|
101
|
+
function serverTable(doc, url, helper) {
|
|
102
|
+
const kept = [];
|
|
103
|
+
const span = tableSpans(doc, SERVER).find((each) => {
|
|
104
|
+
const first = doc.slice(each.start, each.end).split('\n')[0] ?? '';
|
|
105
|
+
return first.replace(/\s+/g, '').replace(/#.*$/, '') === headerOf(SERVER);
|
|
106
|
+
});
|
|
107
|
+
if (span)
|
|
108
|
+
for (const line of doc.slice(span.start, span.end).split('\n').slice(1)) {
|
|
109
|
+
const key = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line)?.[1];
|
|
110
|
+
if (key && (SIGN_IN_KEYS.has(key) || key === 'url' || key === 'default_tools_approval_mode'))
|
|
111
|
+
continue;
|
|
112
|
+
kept.push(line);
|
|
113
|
+
}
|
|
114
|
+
while (kept.length && kept.at(-1)?.trim() === '')
|
|
115
|
+
kept.pop();
|
|
116
|
+
return `${[
|
|
117
|
+
headerOf(SERVER),
|
|
118
|
+
`url = ${basicString(url)}`,
|
|
119
|
+
...(helper ? [`http_headers_helper = ${basicString(helper)}`] : []),
|
|
120
|
+
'default_tools_approval_mode = "approve"',
|
|
121
|
+
...kept,
|
|
122
|
+
].join('\n')}\n`;
|
|
123
|
+
}
|
|
124
|
+
/** The JSON a hook's trust hashes, its keys sorted at every level, as Codex serializes it. */
|
|
125
|
+
function sorted(value) {
|
|
126
|
+
if (Array.isArray(value))
|
|
127
|
+
return value.map(sorted);
|
|
128
|
+
if (value && typeof value === 'object')
|
|
129
|
+
return Object.fromEntries(Object.keys(value)
|
|
130
|
+
.sort()
|
|
131
|
+
.map((key) => [key, sorted(value[key])]));
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* What Codex trusts a hook by: "sha256:" and the hash of its definition (its event, its matcher where Codex reads one,
|
|
136
|
+
* and the handler: type, command, timeout, async, statusMessage), keys sorted, as Codex hashes it (0.129 on).
|
|
137
|
+
*/
|
|
138
|
+
export function hookHash(event, matcher, handler) {
|
|
139
|
+
const identity = {
|
|
140
|
+
event_name: EVENT_LABELS[event] ?? event,
|
|
141
|
+
...(matcher !== undefined && !NO_MATCHER.has(event) ? { matcher } : {}),
|
|
142
|
+
hooks: [
|
|
143
|
+
{
|
|
144
|
+
type: 'command',
|
|
145
|
+
command: handler.command,
|
|
146
|
+
timeout: handler.timeout ?? 600,
|
|
147
|
+
async: handler.async ?? false,
|
|
148
|
+
...(handler.statusMessage !== undefined ? { statusMessage: handler.statusMessage } : {}),
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
return `sha256:${createHash('sha256')
|
|
153
|
+
.update(JSON.stringify(sorted(identity)))
|
|
154
|
+
.digest('hex')}`;
|
|
155
|
+
}
|
|
156
|
+
/** A hook's trust key: its file, its event, and where it sits in that event's list. */
|
|
157
|
+
const trustKey = (file, event, group, handler) => `${file}:${EVENT_LABELS[event] ?? event}:${group}:${handler}`;
|
|
158
|
+
/** hooks.json as Codex reads it: an object of `description` and `hooks` only (it ignores a file with anything else). */
|
|
159
|
+
async function readHooksFile(io) {
|
|
160
|
+
const path = await hooksPath(io);
|
|
161
|
+
const text = await readText(path).catch(() => null);
|
|
162
|
+
if (text === null || text.trim() === '')
|
|
163
|
+
return { path, json: {} };
|
|
164
|
+
let parsed;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(text);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { path, invalid: `${path} isn’t valid JSON, so I left it alone.` };
|
|
170
|
+
}
|
|
171
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
172
|
+
return { path, invalid: `${path} isn’t a JSON object, so I left it alone.` };
|
|
173
|
+
const extra = Object.keys(parsed).filter((key) => key !== 'description' && key !== 'hooks');
|
|
174
|
+
if (extra.length)
|
|
175
|
+
return {
|
|
176
|
+
path,
|
|
177
|
+
invalid: `${path} has keys Codex doesn’t read (${extra.join(', ')}), so Codex ignores the whole file: I left it alone.`,
|
|
178
|
+
};
|
|
179
|
+
return { path, json: parsed };
|
|
180
|
+
}
|
|
181
|
+
async function hookTrust(io) {
|
|
182
|
+
const file = await readHooksFile(io);
|
|
183
|
+
const config = (await readText(await configPath(io)).catch(() => null)) ?? '';
|
|
184
|
+
const hooks = 'json' in file && file.json.hooks && typeof file.json.hooks === 'object'
|
|
185
|
+
? file.json.hooks
|
|
186
|
+
: {};
|
|
187
|
+
return CODEX_HOOKS.map((spec) => {
|
|
188
|
+
const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
|
|
189
|
+
for (const [g, group] of groups.entries())
|
|
190
|
+
for (const [h, handler] of (group?.hooks ?? []).entries()) {
|
|
191
|
+
if (typeof handler?.command !== 'string' || !isOurHook(handler.command, spec.sub))
|
|
192
|
+
continue;
|
|
193
|
+
const state = tableValues(config, ['hooks', 'state', trustKey(file.path, spec.event, g, h)]);
|
|
194
|
+
if (state.enabled === false)
|
|
195
|
+
return { event: spec.event, trust: 'off' };
|
|
196
|
+
const hash = hookHash(spec.event, group.matcher, {
|
|
197
|
+
command: handler.command,
|
|
198
|
+
...(typeof handler.timeout === 'number' ? { timeout: handler.timeout } : {}),
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
event: spec.event,
|
|
202
|
+
trust: state.trusted_hash === hash
|
|
203
|
+
? 'trusted'
|
|
204
|
+
: typeof state.trusted_hash === 'string'
|
|
205
|
+
? 'changed'
|
|
206
|
+
: 'untrusted',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return { event: spec.event, trust: 'missing' };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** 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);
|
|
215
|
+
if (result.code !== 0)
|
|
216
|
+
return null;
|
|
217
|
+
try {
|
|
218
|
+
const servers = JSON.parse(result.stdout);
|
|
219
|
+
const ours = Array.isArray(servers)
|
|
220
|
+
? servers.find((server) => server?.name === 'pendingyou')
|
|
221
|
+
: null;
|
|
222
|
+
return ours ? ours.auth_status === 'o_auth' : null;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Whether to move a server that signs in by itself to this computer's sign-in (the same rule as Claude Code's). */
|
|
229
|
+
async function switches(io, ctx) {
|
|
230
|
+
if (ctx.headless || ctx.yes)
|
|
231
|
+
return true;
|
|
232
|
+
if (!io.interactive)
|
|
233
|
+
return false;
|
|
234
|
+
io.out('Codex signs in to Pending You by itself here (codex mcp login). init can move it to this computer’s own sign-in, which its hooks use too: one sign-in for both. The connection it has now stays on your Assistants page until you remove it, and answers to cards it asked through that one won’t reach Codex by themselves.\n');
|
|
235
|
+
const answer = await io.ask('Switch Codex to this computer’s sign-in? [Y/n] ');
|
|
236
|
+
return !/^\s*no?\s*$/i.test(answer);
|
|
237
|
+
}
|
|
238
|
+
/** Writes Codex's config (its mode kept), or says why it couldn't. */
|
|
239
|
+
async function writeConfig(path, text) {
|
|
240
|
+
return writeWhole(path, text).then(() => true, () => false);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The config without Codex's trust of our hooks (they're going), and with the person's own hooks' trust moved where
|
|
244
|
+
* removing ours moves them: Codex keys trust by a hook's place in its event's list, so a hook that moves up would
|
|
245
|
+
* otherwise need trusting again. Every move is to a lower place, so going in order never lands on a key in use.
|
|
246
|
+
*/
|
|
247
|
+
function keepTheirTrust(config, file, before) {
|
|
248
|
+
let next = config;
|
|
249
|
+
const state = (event, group, handler) => [
|
|
250
|
+
'hooks',
|
|
251
|
+
'state',
|
|
252
|
+
trustKey(file, event, group, handler),
|
|
253
|
+
];
|
|
254
|
+
for (const spec of CODEX_HOOKS) {
|
|
255
|
+
const groups = Array.isArray(before[spec.event]) ? before[spec.event] : [];
|
|
256
|
+
for (const [g, group] of groups.entries())
|
|
257
|
+
for (const [h, hook] of (Array.isArray(group?.hooks) ? group.hooks : []).entries())
|
|
258
|
+
if (isOurHook(hook?.command, spec.sub))
|
|
259
|
+
next = withTable(next, state(spec.event, g, h), null);
|
|
260
|
+
let at = 0;
|
|
261
|
+
for (const [g, group] of groups.entries()) {
|
|
262
|
+
if (!Array.isArray(group?.hooks)) {
|
|
263
|
+
at++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const kept = group.hooks.flatMap((hook, h) => (isOurHook(hook?.command, spec.sub) ? [] : [h]));
|
|
267
|
+
if (kept.length === 0)
|
|
268
|
+
continue;
|
|
269
|
+
for (const [h, was] of kept.entries())
|
|
270
|
+
if (g !== at || was !== h)
|
|
271
|
+
next = renameTable(next, state(spec.event, g, was), state(spec.event, at, h));
|
|
272
|
+
at++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return next;
|
|
276
|
+
}
|
|
277
|
+
export const codex = {
|
|
278
|
+
id: 'codex',
|
|
279
|
+
name: NAME,
|
|
280
|
+
minVersion: CODEX_MIN,
|
|
281
|
+
async detect(io) {
|
|
282
|
+
const result = await io.run('codex', ['--version'], 20_000);
|
|
283
|
+
if (result.code !== 0)
|
|
284
|
+
return null;
|
|
285
|
+
return { version: /(\d+\.\d+\.\d+)/.exec(result.stdout)?.[1] ?? 'installed' };
|
|
286
|
+
},
|
|
287
|
+
async installed(io) {
|
|
288
|
+
return (await readCodexManifest(io)) !== null;
|
|
289
|
+
},
|
|
290
|
+
async usesHelper(io, origin) {
|
|
291
|
+
if (io.platform === 'win32')
|
|
292
|
+
return false;
|
|
293
|
+
const server = await codexServer(io);
|
|
294
|
+
return Boolean(server?.url && isOurHelper(server.helper) && new URL(server.url).origin === origin);
|
|
295
|
+
},
|
|
296
|
+
async prepare(io, ctx, detected) {
|
|
297
|
+
const url = `${ctx.origin}/mcp`;
|
|
298
|
+
const flag = originArgs(ctx.origin);
|
|
299
|
+
const skip = (step) => ({
|
|
300
|
+
app: codex,
|
|
301
|
+
detected,
|
|
302
|
+
signIn: null,
|
|
303
|
+
skipped: step,
|
|
304
|
+
install: async () => [],
|
|
305
|
+
next: () => null,
|
|
306
|
+
});
|
|
307
|
+
// Codex runs its helper and hooks with sh: not on Windows, where its setup message sets it up.
|
|
308
|
+
if (io.platform === 'win32')
|
|
309
|
+
return skip({
|
|
310
|
+
ok: true,
|
|
311
|
+
text: 'Codex on Windows isn’t set up by init yet: paste Pending You’s setup message for Codex into it instead.',
|
|
312
|
+
});
|
|
313
|
+
const helperMode = !ctx.oauth;
|
|
314
|
+
const server = await codexServer(io).catch(() => null);
|
|
315
|
+
if (server?.inline)
|
|
316
|
+
return skip({
|
|
317
|
+
ok: false,
|
|
318
|
+
text: `Codex’s config (${await configPath(io)}) defines pendingyou in a way I can’t edit safely (an inline table or dotted keys). Make it a [mcp_servers.pendingyou] table, or take it out, then run init again${flag ? ` with${flag}` : ''}.`,
|
|
319
|
+
});
|
|
320
|
+
let mode = helperMode ? 'helper' : 'oauth';
|
|
321
|
+
let action = 'add';
|
|
322
|
+
let note = null;
|
|
323
|
+
if (server?.url && !sameUrl(server.url, url)) {
|
|
324
|
+
ctx.progress(`Codex’s pendingyou MCP server points at ${server.url}, not ${url}.\n`);
|
|
325
|
+
const replace = ctx.yes ||
|
|
326
|
+
(io.interactive && /^\s*y(es)?\s*$/i.test(await io.ask(`Replace it with ${url}? [y/N] `)));
|
|
327
|
+
if (!replace)
|
|
328
|
+
return skip({
|
|
329
|
+
ok: false,
|
|
330
|
+
text: `Kept Codex’s pendingyou MCP server at ${server.url}, so Codex isn’t set up for ${ctx.origin}. To use ${url}, run init again with --yes.`,
|
|
331
|
+
});
|
|
332
|
+
action = 'write';
|
|
333
|
+
}
|
|
334
|
+
else if (server) {
|
|
335
|
+
const ours = isOurHelper(server.helper);
|
|
336
|
+
if (ours && helperMode)
|
|
337
|
+
action =
|
|
338
|
+
server.helper === helperCommand(io, ctx.origin, 'codex') && server.approval === 'approve'
|
|
339
|
+
? 'keep'
|
|
340
|
+
: 'write';
|
|
341
|
+
else if (ours)
|
|
342
|
+
action = 'write';
|
|
343
|
+
// Signing in by itself, as --oauth asks: kept, once its tools run without asking.
|
|
344
|
+
else if (!helperMode && !server.helper)
|
|
345
|
+
action = server.approval === 'approve' ? 'keep' : 'write';
|
|
346
|
+
else if (server.helper) {
|
|
347
|
+
mode = 'oauth';
|
|
348
|
+
action = 'keep';
|
|
349
|
+
note = {
|
|
350
|
+
ok: false,
|
|
351
|
+
text: `Kept Codex’s pendingyou MCP server: it signs in through a headers helper of its own (${server.helper}).`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
else if ((await signedInByItself(io)) !== true) {
|
|
355
|
+
// Never signed in: copied from Claude Code by /import (which leaves its helper behind), or left half-made.
|
|
356
|
+
action = 'write';
|
|
357
|
+
}
|
|
358
|
+
else if (await switches(io, ctx))
|
|
359
|
+
action = 'write';
|
|
360
|
+
else {
|
|
361
|
+
mode = 'oauth';
|
|
362
|
+
action = 'keep';
|
|
363
|
+
note = {
|
|
364
|
+
ok: true,
|
|
365
|
+
text: `Kept Codex’s own sign-in to Pending You (${url}). To move it to this computer’s, run: npx -y pendingyou@latest init --app codex --yes${flag}`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const version = detected.version;
|
|
370
|
+
return {
|
|
371
|
+
app: codex,
|
|
372
|
+
detected,
|
|
373
|
+
signIn: mode === 'helper' ? 'connection' : 'hear',
|
|
374
|
+
install: async (ictx) => {
|
|
375
|
+
const steps = [];
|
|
376
|
+
const report = (step) => {
|
|
377
|
+
steps.push(step);
|
|
378
|
+
ictx.report(step);
|
|
379
|
+
};
|
|
380
|
+
const before = await readCodexManifest(io);
|
|
381
|
+
const config = await configPath(io);
|
|
382
|
+
// The MCP server: a stored OAuth sign-in would win over the helper, so it goes first.
|
|
383
|
+
let added = before?.mcpAdded ?? false;
|
|
384
|
+
if (note)
|
|
385
|
+
report(note);
|
|
386
|
+
else if (action === 'keep')
|
|
387
|
+
report({
|
|
388
|
+
ok: true,
|
|
389
|
+
text: mode === 'helper'
|
|
390
|
+
? `The pendingyou MCP server (${url}) already signs in through this computer’s sign-in.`
|
|
391
|
+
: `The pendingyou MCP server (${url}) was already in Codex.`,
|
|
392
|
+
});
|
|
393
|
+
else if (mode === 'helper' && !ictx.helper)
|
|
394
|
+
report({
|
|
395
|
+
ok: false,
|
|
396
|
+
text: 'Didn’t add Codex’s pendingyou MCP server: it signs in through pendingyou’s own copy, which isn’t installed. Run init again.',
|
|
397
|
+
});
|
|
398
|
+
else {
|
|
399
|
+
if (mode === 'helper' && server)
|
|
400
|
+
await io.run('codex', ['mcp', 'logout', 'pendingyou'], 60_000);
|
|
401
|
+
const doc = (await readText(config).catch(() => null)) ?? '';
|
|
402
|
+
const next = withTable(doc, SERVER, serverTable(doc, url, mode === 'helper' ? ictx.helper : null));
|
|
403
|
+
if (await writeConfig(config, next)) {
|
|
404
|
+
added = true;
|
|
405
|
+
report({
|
|
406
|
+
ok: true,
|
|
407
|
+
text: !server
|
|
408
|
+
? `Added the pendingyou MCP server (${url}) to ${config}${mode === 'helper' ? ', signed in through this computer’s sign-in' : ''}; its tools run without asking.`
|
|
409
|
+
: server.url && !sameUrl(server.url, url)
|
|
410
|
+
? `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.`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
else
|
|
419
|
+
report({
|
|
420
|
+
ok: false,
|
|
421
|
+
text: `Couldn’t write ${config}, so Codex has no pendingyou MCP server. Run init again.`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const skill = await saveSkill(io, ictx.origin, codexSkillPath(io), before?.skill ?? null);
|
|
425
|
+
report(skill.step);
|
|
426
|
+
// The hooks, through the shim: new ones at the end of each list (Codex keys trust by position).
|
|
427
|
+
const lines = Object.fromEntries(CODEX_HOOKS.map((spec) => [spec.event, ictx.hookLine(spec.sub)]));
|
|
428
|
+
const file = await readHooksFile(io);
|
|
429
|
+
if ('invalid' in file)
|
|
430
|
+
report({ ok: false, text: file.invalid });
|
|
431
|
+
else {
|
|
432
|
+
try {
|
|
433
|
+
const hooks = file.json.hooks && typeof file.json.hooks === 'object'
|
|
434
|
+
? file.json.hooks
|
|
435
|
+
: {};
|
|
436
|
+
const merged = mergeHooks(hooks, CODEX_HOOKS, lines, file.path);
|
|
437
|
+
if (merged.changed)
|
|
438
|
+
await writeWhole(file.path, `${JSON.stringify({ ...file.json, hooks: merged.hooks }, null, 2)}\n`);
|
|
439
|
+
report({
|
|
440
|
+
ok: true,
|
|
441
|
+
text: merged.changed
|
|
442
|
+
? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks in ${file.path}. Codex asks you to trust them once.`
|
|
443
|
+
: 'Codex’s hooks were already installed.',
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
report({
|
|
448
|
+
ok: false,
|
|
449
|
+
text: error instanceof Error ? error.message : `Couldn’t update ${file.path}.`,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!atLeast(version, CODEX_WAKE))
|
|
454
|
+
report({
|
|
455
|
+
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.`,
|
|
457
|
+
});
|
|
458
|
+
const manifest = {
|
|
459
|
+
version: 1,
|
|
460
|
+
origin: ictx.origin,
|
|
461
|
+
codexHome: await codexHome(io),
|
|
462
|
+
mcpAdded: added,
|
|
463
|
+
skill: skill.skill,
|
|
464
|
+
hooks: lines,
|
|
465
|
+
helper: mode === 'helper' ? ictx.helper : null,
|
|
466
|
+
setup: { since: new Date(io.now()).toISOString() },
|
|
467
|
+
};
|
|
468
|
+
await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
469
|
+
secret: true,
|
|
470
|
+
});
|
|
471
|
+
return steps;
|
|
472
|
+
},
|
|
473
|
+
next: () => {
|
|
474
|
+
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)`;
|
|
476
|
+
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.`,
|
|
484
|
+
say: true,
|
|
485
|
+
};
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
async uninstall(io) {
|
|
490
|
+
const manifest = await readCodexManifest(io);
|
|
491
|
+
const steps = [];
|
|
492
|
+
const file = await readHooksFile(io);
|
|
493
|
+
const config = await configPath(io);
|
|
494
|
+
let doc = (await readText(config).catch(() => null)) ?? null;
|
|
495
|
+
if ('json' in file) {
|
|
496
|
+
const hooks = file.json.hooks && typeof file.json.hooks === 'object' ? file.json.hooks : {};
|
|
497
|
+
const removed = removeHooks(hooks, CODEX_HOOKS);
|
|
498
|
+
if (removed.changed) {
|
|
499
|
+
const rest = { ...file.json, hooks: removed.hooks };
|
|
500
|
+
if (Object.keys(removed.hooks).length === 0)
|
|
501
|
+
delete rest.hooks;
|
|
502
|
+
if (Object.keys(rest).length === 0)
|
|
503
|
+
await rm(file.path, { force: true });
|
|
504
|
+
else
|
|
505
|
+
await writeWhole(file.path, `${JSON.stringify(rest, null, 2)}\n`);
|
|
506
|
+
// The person's own hooks that moved up keep the trust they had.
|
|
507
|
+
if (doc !== null)
|
|
508
|
+
doc = keepTheirTrust(doc, file.path, hooks);
|
|
509
|
+
}
|
|
510
|
+
steps.push({
|
|
511
|
+
ok: true,
|
|
512
|
+
text: removed.changed
|
|
513
|
+
? 'Removed the Pending You hooks from Codex.'
|
|
514
|
+
: 'No Pending You hooks were in Codex.',
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (manifest?.mcpAdded && doc !== null && tableSpans(doc, SERVER).length) {
|
|
518
|
+
doc = withTable(doc, SERVER, null);
|
|
519
|
+
steps.push({ ok: true, text: 'Removed the pendingyou MCP server from Codex.' });
|
|
520
|
+
}
|
|
521
|
+
if (doc !== null && doc !== (await readText(config).catch(() => null)))
|
|
522
|
+
await writeConfig(config, doc);
|
|
523
|
+
const skill = await removeSkill(manifest?.skill ?? null);
|
|
524
|
+
if (skill)
|
|
525
|
+
steps.push(skill);
|
|
526
|
+
await rm(join(configDir(io), 'codex-threads.json'), { force: true });
|
|
527
|
+
await rm(join(configDir(io), 'codex-listen'), { recursive: true, force: true });
|
|
528
|
+
await rm(manifestPath(io), { force: true });
|
|
529
|
+
return steps;
|
|
530
|
+
},
|
|
531
|
+
async status(io, ctx) {
|
|
532
|
+
const { origin, signIn } = ctx;
|
|
533
|
+
const flag = originArgs(origin);
|
|
534
|
+
const [detected, server, manifest, trust, skill] = await Promise.all([
|
|
535
|
+
codex.detect(io),
|
|
536
|
+
codexServer(io),
|
|
537
|
+
readCodexManifest(io),
|
|
538
|
+
hookTrust(io),
|
|
539
|
+
readText(codexSkillPath(io)).catch(() => null),
|
|
540
|
+
]);
|
|
541
|
+
const credential = await readCredential(io, origin, 'codex');
|
|
542
|
+
const url = `${origin}/mcp`;
|
|
543
|
+
const why = headlessReason(io);
|
|
544
|
+
const mark = (ok) => (ok ? 'ok ' : 'missing');
|
|
545
|
+
const helped = Boolean(server?.url && sameUrl(server.url, url) && isOurHelper(server.helper));
|
|
546
|
+
const helper = helped || Boolean(manifest?.helper) || (!server && !manifest);
|
|
547
|
+
const fix = `npx pendingyou ${helped ? 'login --app codex' : 'init --app codex'}${flag}`;
|
|
548
|
+
const connected = credential?.kind === 'connection';
|
|
549
|
+
const signOk = signIn.state === 'ok' && (!helper || connected);
|
|
550
|
+
const title = signIn.state === 'ok' && signIn.for ? connectionTitle(signIn.for) : `${NAME} on this computer`;
|
|
551
|
+
const signText = signIn.state === 'ok'
|
|
552
|
+
? helper
|
|
553
|
+
? connected
|
|
554
|
+
? `this computer’s own connection, ${title}; Codex here asks and hears through it`
|
|
555
|
+
: `this computer’s sign-in for Codex only hears answers, so Codex here can’t use it; run ${fix}`
|
|
556
|
+
: `hears for ${signIn.connections} Codex connection${signIn.connections === 1 ? '' : 's'}`
|
|
557
|
+
: signIn.state === 'ended'
|
|
558
|
+
? `ended; run ${fix}`
|
|
559
|
+
: signIn.state === 'none'
|
|
560
|
+
? `not signed in; run ${fix}`
|
|
561
|
+
: 'Pending You couldn’t be reached';
|
|
562
|
+
const mcpOk = Boolean(server?.url && sameUrl(server.url, url) && (!helper || helped));
|
|
563
|
+
const mcpText = !server
|
|
564
|
+
? `not added; run npx pendingyou init --app codex${flag}`
|
|
565
|
+
: server.inline
|
|
566
|
+
? `defined in a way init can’t edit (an inline table or dotted keys) in ${await configPath(io)}`
|
|
567
|
+
: !server.url || !sameUrl(server.url, url)
|
|
568
|
+
? `points at ${server.url}, not ${url}; run npx pendingyou init --app codex${flag} to replace it`
|
|
569
|
+
: helped
|
|
570
|
+
? `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`;
|
|
574
|
+
const check = helper ? await checkHelper(io, origin, 'codex') : null;
|
|
575
|
+
const words = {
|
|
576
|
+
trusted: 'trusted',
|
|
577
|
+
changed: 'changed since you trusted it',
|
|
578
|
+
untrusted: 'not trusted yet',
|
|
579
|
+
off: 'turned off',
|
|
580
|
+
missing: 'missing',
|
|
581
|
+
};
|
|
582
|
+
const hooksOk = trust.every((hook) => hook.trust === 'trusted');
|
|
583
|
+
const hooksText = trust.some((hook) => hook.trust === 'missing')
|
|
584
|
+
? `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;
|
|
587
|
+
const lines = [
|
|
588
|
+
`Pending You for Codex · ${origin}${why ? ` · no browser here (${why})` : ''}`,
|
|
589
|
+
` ${mark(signOk)} Sign-in: ${signText}`,
|
|
590
|
+
` ${mark(detected !== null)} Codex: ${detected?.version ?? 'not found on PATH'}`,
|
|
591
|
+
` ${mark(mcpOk)} MCP server: ${mcpText}`,
|
|
592
|
+
...(check
|
|
593
|
+
? [
|
|
594
|
+
` ${mark(check.ok)} Sign-in helper: ${check.ok
|
|
595
|
+
? `${check.node} (${foundBy(check.from)}) runs pendingyou ${check.version}, even in Codex’s stripped environment`
|
|
596
|
+
: `${check.why}; run npx pendingyou init --app codex${flag}`}`,
|
|
597
|
+
]
|
|
598
|
+
: []),
|
|
599
|
+
` ${mark(skill !== null)} Skill: ${skill !== null ? `saved (${codexSkillPath(io)})` : `not saved; run npx pendingyou init --app codex${flag}`}`,
|
|
600
|
+
` ${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`}`,
|
|
602
|
+
];
|
|
603
|
+
const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
|
|
604
|
+
if (setup)
|
|
605
|
+
lines.push(` ${setup === 'finished' ? 'ok ' : ' '} Setup: ${setup}`);
|
|
606
|
+
const reaches = !helper || (signOk && mcpOk && check?.ok === true);
|
|
607
|
+
const ready = reaches && signIn.state === 'ok' && mcpOk && hooksOk;
|
|
608
|
+
lines.push(ready
|
|
609
|
+
? 'Ready: Codex hears answers right away (report_setup hears "instant").'
|
|
610
|
+
: reaches && mcpOk
|
|
611
|
+
? 'Not ready: Codex hears answers only while it’s working until the lines marked missing are fixed.'
|
|
612
|
+
: 'Not ready: Codex here can’t reach Pending You until the lines marked missing are fixed.');
|
|
613
|
+
return { lines, ready };
|
|
614
|
+
},
|
|
615
|
+
};
|
package/dist/apps/ids.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The apps `pendingyou init` sets up (0.11.0), by the id Pending You knows each by: a connection's `source`, and what
|
|
2
|
+
// the hooks send as `source` so a session only ever hears its own app's cards. Each has a module in this folder
|
|
3
|
+
// (registry.ts lists them; docs/apps.md says what a module does). Kept apart from the modules so the argument parser can
|
|
4
|
+
// read the list without loading them.
|
|
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'];
|
|
7
|
+
/** What people call each app: "Claude Code on devbox" is a connection's name on the Assistants page. */
|
|
8
|
+
export const APP_NAMES = {
|
|
9
|
+
'claude-code': 'Claude Code',
|
|
10
|
+
codex: 'Codex',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The app a command acts for when it names none: Claude Code, the only app before 0.11.0, so hook lines and helpers
|
|
14
|
+
* written by 0.10.0 keep meaning what they meant.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_APP = 'claude-code';
|
|
17
|
+
export const isAppId = (text) => APP_IDS.includes(text);
|
|
18
|
+
/** "Claude Code and Codex", "Claude Code, Codex and OpenCode" (or "… or …"). */
|
|
19
|
+
export function namesOf(apps, joiner = 'and') {
|
|
20
|
+
const names = apps.map((app) => APP_NAMES[app]);
|
|
21
|
+
if (names.length <= 1)
|
|
22
|
+
return names[0] ?? '';
|
|
23
|
+
return `${names.slice(0, -1).join(', ')} ${joiner} ${names.at(-1)}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Every app module, in the order init sets them up and status lists them (ids.ts has the same order). Adding an app is
|
|
2
|
+
// a module of its own beside these (docs/apps.md) and one line here.
|
|
3
|
+
import { claudeCode } from "./claude-code.js";
|
|
4
|
+
import { codex } from "./codex.js";
|
|
5
|
+
export { atLeast } from "../mod.js";
|
|
6
|
+
export const APPS = [claudeCode, codex];
|
|
7
|
+
export function appModule(id) {
|
|
8
|
+
const found = APPS.find((app) => app.id === id);
|
|
9
|
+
if (!found)
|
|
10
|
+
throw new Error(`no module for ${id}`);
|
|
11
|
+
return found;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|