pendingyou 0.20.1 → 0.22.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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0 (2026-10-06)
4
+
5
+ - Trusting Codex's hooks is one command now. When `init` or `status` finds Pending You's hooks in Codex but not trusted
6
+ yet, it prints the command that opens a Codex here (`codex` on PATH, else the Codex command line the ChatGPT app
7
+ brings, by its full path, quoted) and says to type `/hooks` there, look over Pending You's hooks and trust them. At a
8
+ terminal it then waits, looking again every 3 seconds for up to 10 minutes, and says "Ready: Codex hears answers
9
+ right away" once they're trusted (Ctrl-C stops the wait; `npx pendingyou status` checks later). Run by an agent, it
10
+ prints the same steps without waiting. It never trusts them for you.
11
+ - The instructions no longer send you to a Hooks page in the Codex app's Settings, which people couldn't find, nor to
12
+ other ways of trusting the hooks that haven't been seen working: init's last lines and `status` lead with `/hooks`.
13
+ - Looking for the Codex inside the ChatGPT or Codex app, the command line tries the `CodexCLI.app` it carries first,
14
+ then its `bin` folder.
15
+
16
+ ## 0.21.0 (2026-10-06)
17
+
18
+ - Answer from Herdr. The Pending You plugin for Herdr (0.2) can now answer your cards from its popup, once you sign
19
+ Herdr in. The command line itself still never answers anything: the plugin's popup does, with Herdr's own sign-in
20
+ and its own key, only when you press a key in it, and never a high-stakes card.
21
+ - `pendingyou app login herdr` (also the plugin's setup) signs Herdr in to Pending You with a code, as `login --device`
22
+ does: your phone or browser shows one card naming Herdr and this computer, with a tick for all your cards (off: it
23
+ sees this computer's agents' cards only). This computer's key (0.18.0) vouches for the sign-in, naming Herdr and the
24
+ new sign-in's own key, so run `npx -y pendingyou@latest init` here first. The sign-in and its private key are kept
25
+ in `~/.config/pendingyou/apps/herdr.json` (readable only by you), which none of the agents' hooks, helpers or
26
+ `hold` read; its tokens are useless without that key (DPoP). Signing in again on the same computer replaces it.
27
+ `pendingyou app status` says what each app signed in here may do and until when (and warns in its last 3 days);
28
+ `pendingyou app logout herdr` ends it at Pending You and removes it. Another registered app signs in with
29
+ `--client-id` (and `--scope`).
30
+ - The popup (`prefix+y`), signed in: your cards from Pending You, the chosen one open with what it asks, how many wait
31
+ elsewhere, and a key per decision: 1–9 for a choice (and Enter to send several), y or n to approve or decline, Enter
32
+ to write an answer, y or n for a step done or not, Tab between a grouped card's questions. r writes to the agent, l
33
+ puts the card in Later, d hands it to another of your agents, u undoes for 5 seconds, t goes to the agent's pane
34
+ (from its badges, or its session running in this Herdr), o opens the card in Pending You (high-stakes cards are
35
+ answered there), a opens all your cards. It follows Pending You's change feed while it's open. Not signed in, it's
36
+ the read-only list it was, with a line saying how to sign in. `herdr open --demo` shows made-up cards, for
37
+ screenshots.
38
+ - Your phone stays quiet while you're active in Herdr (moving between panes, tabs and workspaces, or pressing keys in
39
+ the popup), for the cards Herdr can show, as it does while Pending You is open on your desk. The plugin's watcher
40
+ also says in a toast when Herdr's sign-in has ended, and in its last 3 days that it's about to.
41
+ - The plugin's `unconfigure` signs Herdr out too. `doctor` says whether it's signed in.
42
+ - The plugin 0.2 runs the command line `init` put here only once that one can answer (`pendingyou app` exists);
43
+ until then it runs 0.21.0 through npx. Run `npx pendingyou@latest init` once to switch.
44
+ - Answering works once Pending You turns on its person API for its own apps; until then sign-in says Pending You
45
+ doesn't let Herdr sign in yet.
46
+
3
47
  ## 0.20.1 (2026-10-06)
4
48
 
5
49
  - An older answer is no longer hidden behind newer ones. The session-start and next-message hooks asked for a folder's
package/README.md CHANGED
@@ -41,8 +41,8 @@ Finish setting up Pending You: it’s connected here already.
41
41
  ```
42
42
 
43
43
  The first session finishes setting up on its own: it checks in with Pending You, reports what's set up and sends you a
44
- test card. Claude Code 2.1.287 or later starts that turn by itself; otherwise (and in Codex, after you trust its hooks:
45
- “Trust all and continue” in Codex CLI, Review hooks › Allow all in the Codex app) the session start adds one line with
44
+ test card. Claude Code 2.1.287 or later starts that turn by itself; otherwise (and in Codex, after you trust its hooks in
45
+ `/hooks`) the session start adds one line with
46
46
  the three calls that are left, and you say the sentence.
47
47
  `npx pendingyou status` says what's set up for each agent, what's missing with the command that fixes it, and how setup
48
48
  stands. `npx pendingyou uninstall` removes exactly what `init` added.
@@ -99,9 +99,11 @@ default_tools_approval_mode = "approve"
99
99
  and hooks in `~/.codex/hooks.json`: a session start, each message, Pending You's card tools (so the Stop check knows a
100
100
  turn posted, and so the thread is woken), and the Stop check; and (0.15.0) Codex's permission prompt and every tool
101
101
  call that ran (a card when Codex waits for your OK, [below](#when-claude-code-waits-for-your-ok)) and the session's end
102
- ([below](#which-sessions-are-open)). **Codex runs a hook only once you trust it**: in Codex CLI, start `codex` and
103
- choose “Trust all and continue” (or trust them in `/hooks`); in the Codex app, click Review hooks › Allow all (or
104
- Settings › Hooks › Trust all). `status` says which are trusted. Upgrading from 0.14.0 adds three hooks, which Codex
102
+ ([below](#which-sessions-are-open)). **Codex runs a hook only once you trust it**, in Codex's `/hooks`. When `init` or
103
+ `status` finds Pending You's hooks not trusted yet, it prints the one command that opens a Codex here (`codex`, or
104
+ the one the ChatGPT app brings, by its full path) and says to type `/hooks` there and trust them; at a terminal it
105
+ then waits (every 3 seconds, for up to 10 minutes; Ctrl-C stops it) until Codex trusts them, and says whether Codex is
106
+ ready. It never trusts them for you. `status` says which are trusted. Upgrading from 0.14.0 adds three hooks, which Codex
105
107
  skips until you trust them once; `init` says so, and the ones you trusted stay trusted. A `pendingyou` server or
106
108
  hooks Codex's `/import` copied from Claude Code (without its sign-in) are replaced.
107
109
 
@@ -316,8 +318,30 @@ them after 15 minutes unless told again.
316
318
  The Pending You plugin for Herdr puts them to use: the card on each waiting agent's row in the sidebar, a toast when a
317
319
  pane gains one, a key to the next pane waiting on you, a popup that lists them (Enter opens the card in Pending You), and
318
320
  an Agents view with them first. Its commands are this command line's: `npx pendingyou herdr doctor` says what's set
319
- up. Its `unconfigure` takes the badges off every pane and stops writing them on this computer; its setup turns them on
320
- again.
321
+ up. Its `unconfigure` takes the badges off every pane and stops writing them on this computer (and signs Herdr out);
322
+ its setup turns them on again.
323
+
324
+ ### Answering from Herdr (0.21.0)
325
+
326
+ Sign Herdr in, from the plugin's setup or here:
327
+
328
+ ```sh
329
+ npx pendingyou app login herdr
330
+ ```
331
+
332
+ Your phone or browser shows one card naming Herdr and this computer: press and hold Allow. Herdr then sees the cards
333
+ of this computer's agents (tick "all" on that card for every card of yours), and its popup (`prefix+y`) answers them
334
+ with a key: 1–9 for a choice (Enter sends several), y or n to approve or decline, Enter to write an answer, y or n for
335
+ a step done or not, Tab between a grouped card's questions; r writes to the agent, l puts it in Later, d hands it to
336
+ another of your agents, u undoes for 5 seconds, t goes to the agent's pane, o opens the card in Pending You. A
337
+ high-stakes card (it spends money, can't be undone or goes public) is never answered from Herdr: o opens it where you
338
+ hold to approve. Pending You says on the card and to the agent "Answered in Herdr on build-01". While you're active in
339
+ Herdr (moving between panes, tabs and workspaces, or pressing keys in the popup) your phone stays quiet for those cards,
340
+ as it does while Pending You is open on your desk.
341
+
342
+ `npx pendingyou app status` says what each app signed in here may do and until when (a sign-in lasts 30 days; it warns
343
+ in the last 3), and `npx pendingyou app logout herdr` ends Herdr's sign-in. Settings › Apps and devices in Pending You
344
+ lists it too, with Remove. It needs `init` to have run here first: this computer's key vouches for the sign-in.
321
345
 
322
346
  ## How Claude Code hears your answer
323
347
 
@@ -472,6 +496,7 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
472
496
  | `mcp --app <app>` | The stdio bridge: a local MCP server for an agent that can't run a headers helper (OpenCode, Pi), which passes each message on to Pending You with that agent's own sign-in here (`--check`) |
473
497
  | `herdr doctor` | Pending You in Herdr: the badges, the plugin, its toasts and its sidebar row. The plugin runs `herdr next`, `open`, `setup`, `view` and `unconfigure`; the agents' writers `herdr report` |
474
498
  | `machine status` / `attest` / `rotate` | This computer's key: whether Pending You knows each agent's connection by it (exits 0 when it does), a proof of it for an app's own sign-in (`--client-id`, `--cnf`, `--name`), or a new key (`--yes`) |
499
+ | `app login` / `logout` / `status` | An app that answers your cards when you press a key in it, signed in on this computer (`herdr`, or another app with `--client-id` and `--scope`; `--device`, `--browser`, `--name`), out again, or what each may do and until when |
475
500
 
476
501
  Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`). `--app` takes
477
502
  `claude-code`, `codex`, `opencode` or `pi` (several with commas where a command sets up or reports several); with none,
@@ -479,6 +504,12 @@ a command that acts for one agent acts for Claude Code. Options can come before
479
504
 
480
505
  ## What it can see, and where it keeps things
481
506
 
507
+ **The command line never answers for you.** None of its commands, hooks or helpers answers, replies to, snoozes or
508
+ hands over a card, and no agent's sign-in can. An app you sign in with `pendingyou app login` (the Pending You plugin
509
+ for Herdr) answers only from its own sign-in and its own key, kept apart in `apps/<app>.json`, which nothing an agent
510
+ runs here reads, and only when you press a key in it; never a high-stakes card, and Pending You labels every answer
511
+ with the app and the computer.
512
+
482
513
  Each agent's sign-in is that agent's own connection on Pending You, signed in from this computer: it can post cards,
483
514
  change or cancel its own, read the answers to them and reply in their threads, and it can't answer anything for you.
484
515
  The hooks use the same sign-in, and hand a session only the cards its own app posted from its folder. An agent that
@@ -488,7 +519,10 @@ answer or change anything, and doesn't show up as an assistant. They're stored i
488
519
  themselves, and end after 30 days or on `logout`. The headers helper hands an agent's token only to that agent's
489
520
  Pending You (Claude Code says which server it's for; Codex's is the one `init` wrote). `clients.json` remembers which
490
521
  sign-in each agent's connection used, so signing in again keeps it. `machine.json` (0.18.0) holds this computer's key
491
- pair, private like the rest; only its proofs leave the computer, each for one sign-in, once.
522
+ pair, private like the rest; only its proofs leave the computer, each for one sign-in, once. `apps/<app>.json` (0.21.0)
523
+ holds an app's own sign-in and the private key its tokens are bound to (DPoP), private like the rest; the Pending You
524
+ plugin for Herdr also keeps, in Herdr's state folder for it, when you last pressed a key in its popup (a time, nothing
525
+ else).
492
526
 
493
527
  `state.json` beside them remembers which answers were already handed over (ids only) and how many session starts
494
528
  reminded an unfinished setup, and when; `permission-cards/` each Claude Code session's (and Codex thread's) prompts
@@ -0,0 +1,304 @@
1
+ import { readdir, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { ignoreFolder } from "./credentials.js";
4
+ import { PlainError } from "./errors.js";
5
+ import { configDir } from "./files.js";
6
+ import { originArgs } from "./hooks.js";
7
+ import { attest, clockOffset, readMachine } from "./machine.js";
8
+ import { showDevices } from "./oauth.js";
9
+ import { computerName, headlessReason } from "./remote.js";
10
+ import { ApiError, createClient, deviceSignIn, SignedOutError, SignInError, signOut, } from "./sdk/index.js";
11
+ import { fileStore } from "./sdk/node.js";
12
+ import { memoryStore } from "./sdk/store.js";
13
+ export const APP_SCOPES = [
14
+ 'cards:read',
15
+ 'cards:read:all',
16
+ 'cards:answer',
17
+ 'cards:reply',
18
+ 'cards:later',
19
+ 'cards:delegate',
20
+ 'assistants:read',
21
+ 'presence:desk',
22
+ ];
23
+ export const FIRST_PARTY_APPS = {
24
+ herdr: { name: 'Herdr', scopes: APP_SCOPES },
25
+ };
26
+ export const DEFAULT_SCOPES = ['cards:read'];
27
+ export const APP_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
28
+ export const RENEW_WITHIN_MS = 3 * 24 * 60 * 60 * 1000;
29
+ export const appsDir = (io) => join(configDir(io), 'apps');
30
+ export const appPath = (io, app) => join(appsDir(io), `${app}.json`);
31
+ export const appStore = (io, app) => fileStore(appPath(io, app));
32
+ export const appLabel = (app) => FIRST_PARTY_APPS[app]?.name ?? app;
33
+ export async function readAppSignIn(io, app) {
34
+ try {
35
+ return await appStore(io, app).load();
36
+ }
37
+ catch {
38
+ throw new PlainError(`${appLabel(app)}’s sign-in (${appPath(io, app)}) can’t be read. Sign in again to replace it: npx pendingyou app login ${app}`);
39
+ }
40
+ }
41
+ export function appClient(io, app) {
42
+ return createClient({
43
+ store: appStore(io, app),
44
+ fetch: io.fetch,
45
+ now: () => io.now(),
46
+ sleep: (ms, signal) => io.sleep(ms, signal),
47
+ });
48
+ }
49
+ const MAY = {
50
+ 'cards:read': 'see your cards',
51
+ 'cards:read:all': 'see all your cards',
52
+ 'cards:answer': 'answer them',
53
+ 'cards:reply': 'write to the assistant that asked',
54
+ 'cards:later': 'put them in Later',
55
+ 'cards:delegate': 'hand them to another assistant',
56
+ 'assistants:read': 'see your assistants',
57
+ 'presence:desk': 'keep your phone quiet while you use it',
58
+ };
59
+ const listed = (items) => items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
60
+ export function reachWords(me) {
61
+ return me.grant.reach === 'all' || !me.grant.machine
62
+ ? 'all your cards'
63
+ : `cards from ${me.grant.machine.name}’s agents`;
64
+ }
65
+ export function signedInAs(app, me) {
66
+ const name = FIRST_PARTY_APPS[app] ? appLabel(app) : (me?.app.name ?? app);
67
+ return me?.grant.machine ? `${name} on ${me.grant.machine.name}` : name;
68
+ }
69
+ function signInWords(error, app) {
70
+ const name = appLabel(app);
71
+ switch (error.code) {
72
+ case 'access_denied':
73
+ return `You pressed Cancel, so ${name} isn’t signed in.`;
74
+ case 'expired_token':
75
+ return 'The code ran out. Run the command again for a new one.';
76
+ case 'invalid_client':
77
+ return FIRST_PARTY_APPS[app]
78
+ ? `Pending You doesn’t let ${name} sign in yet.`
79
+ : 'Pending You doesn’t know that app, or it doesn’t sign in with a code.';
80
+ case 'invalid_request':
81
+ return `Pending You didn’t take this computer’s key. Run npx -y pendingyou@latest init, then try again.`;
82
+ case 'invalid_scope':
83
+ return `Pending You doesn’t let ${name} ask for that.`;
84
+ case 'slow_down':
85
+ return 'Too many sign-ins from here. Wait 10 minutes, then try again.';
86
+ case 'aborted':
87
+ return 'Signing in was stopped.';
88
+ default:
89
+ return error.message;
90
+ }
91
+ }
92
+ export async function signInApp(io, options) {
93
+ const { app, origin } = options;
94
+ const name = appLabel(app);
95
+ const firstParty = FIRST_PARTY_APPS[app];
96
+ if (!firstParty && !options.clientId)
97
+ return {
98
+ ok: false,
99
+ said: `Name ${app}’s client with --client-id, or sign in Pending You’s own: ${Object.keys(FIRST_PARTY_APPS).join(', ')}.`,
100
+ };
101
+ const machine = await readMachine(io);
102
+ if (!machine)
103
+ return {
104
+ ok: false,
105
+ said: `This computer isn’t set up with Pending You yet: run npx -y pendingyou@latest init${originArgs(origin)} first, then sign ${name} in.`,
106
+ };
107
+ const computer = (await computerName(io, options.machine)).name;
108
+ const offset = await clockOffset(io, origin);
109
+ const before = await readAppSignIn(io, app).catch(() => null);
110
+ await ignoreFolder(io).catch(() => { });
111
+ let started;
112
+ try {
113
+ started = await deviceSignIn({
114
+ origin,
115
+ ...(options.clientId ? { clientId: options.clientId } : { app }),
116
+ scopes: options.scopes ?? firstParty?.scopes ?? DEFAULT_SCOPES,
117
+ machine: computer,
118
+ attest: (request) => attest(machine, {
119
+ aud: request.audience,
120
+ ...(request.clientId ? { client_id: request.clientId } : { app }),
121
+ name: computer,
122
+ cnf: { jkt: request.jkt },
123
+ }, io.now() + offset),
124
+ store: appStore(io, app),
125
+ fetch: io.fetch,
126
+ now: () => io.now(),
127
+ sleep: (ms, signal) => io.sleep(ms, signal),
128
+ signal: io.signal,
129
+ });
130
+ }
131
+ catch (error) {
132
+ if (error instanceof SignInError)
133
+ return { ok: false, said: signInWords(error, app) };
134
+ return { ok: false, said: `Pending You at ${origin} couldn’t be reached. Try again.` };
135
+ }
136
+ io.err(`Signing ${name} in on ${computer}. Once you allow it, ${name} can answer your cards when you press a key in it; high-stakes cards still open in Pending You.\n`);
137
+ await showDevices(io, [
138
+ {
139
+ flow: {
140
+ page: started.verificationUri,
141
+ direct: started.verificationUriComplete,
142
+ userCode: started.userCode,
143
+ },
144
+ },
145
+ ], options.open);
146
+ try {
147
+ await started.signedIn;
148
+ }
149
+ catch (error) {
150
+ if (error instanceof SignInError)
151
+ return { ok: false, said: signInWords(error, app) };
152
+ return { ok: false, said: `Pending You at ${origin} couldn’t be reached. Try again.` };
153
+ }
154
+ if (before && before.origin !== origin)
155
+ await signOut({ store: memoryStore(before), fetch: io.fetch }).catch(() => 'unreachable');
156
+ const me = await appClient(io, app)
157
+ .me()
158
+ .catch(() => null);
159
+ return { ok: true, me };
160
+ }
161
+ async function login(io, command) {
162
+ const headless = headlessReason(io);
163
+ const open = command.browser && (command.device === false || (command.device === null && !headless));
164
+ const result = await signInApp(io, {
165
+ app: command.app,
166
+ origin: command.origin,
167
+ ...(command.clientId ? { clientId: command.clientId } : {}),
168
+ ...(command.machine ? { machine: command.machine } : {}),
169
+ ...(command.scopes ? { scopes: command.scopes } : {}),
170
+ open,
171
+ });
172
+ if (!result.ok) {
173
+ io.err(`pendingyou: ${result.said}\n`);
174
+ return 1;
175
+ }
176
+ io.out(`${signedInWords(io, command.app, result.me)}\n`);
177
+ return 0;
178
+ }
179
+ export function signedInWords(io, app, me) {
180
+ const name = appLabel(app);
181
+ const answers = me ? me.grant.scopes.includes('cards:answer') : true;
182
+ return [
183
+ me
184
+ ? `${signedInAs(app, me)} is signed in: it ${answers ? 'answers' : 'sees'} ${reachWords(me)}${answers ? ', only when you press a key in it' : ''}.`
185
+ : `${name} is signed in.`,
186
+ `Its sign-in is in ${appPath(io, app)}, with a key that never leaves this computer.`,
187
+ `To remove it: npx pendingyou app logout ${app}, or Settings › Apps and devices in Pending You.`,
188
+ ].join('\n');
189
+ }
190
+ export async function signOutApp(io, app) {
191
+ const stored = await readAppSignIn(io, app).catch(() => undefined);
192
+ const path = appPath(io, app);
193
+ if (!stored) {
194
+ const there = await rm(path).then(() => true, () => false);
195
+ return { said: there ? 'unreadable' : 'none', origin: null };
196
+ }
197
+ const said = await signOut({ store: memoryStore(stored), fetch: io.fetch });
198
+ await rm(path, { force: true });
199
+ return { said, origin: stored.origin };
200
+ }
201
+ async function logout(io, app) {
202
+ const { said, origin } = await signOutApp(io, app);
203
+ const name = appLabel(app);
204
+ io.out(said === 'none'
205
+ ? `${name} wasn’t signed in here.\n`
206
+ : said === 'signed-out'
207
+ ? `Signed ${name} out of Pending You${origin ? ` at ${origin}` : ''}.\n`
208
+ : said === 'unreadable'
209
+ ? `Removed ${name}’s sign-in here, which couldn’t be read: remove it in Settings › Apps and devices too, or it ends by itself within 30 days.\n`
210
+ : `Signed ${name} out on this computer. Pending You couldn’t be reached to end the sign-in there: remove it in Settings › Apps and devices, or it ends by itself within 30 days.\n`);
211
+ return 0;
212
+ }
213
+ export async function signedInApps(io) {
214
+ const names = await readdir(appsDir(io)).catch(() => []);
215
+ return names
216
+ .filter((name) => name.endsWith('.json'))
217
+ .map((name) => name.slice(0, -'.json'.length))
218
+ .filter((name) => APP_NAME.test(name))
219
+ .sort();
220
+ }
221
+ export async function appStatusLines(io, app) {
222
+ const name = appLabel(app);
223
+ let stored;
224
+ try {
225
+ stored = await readAppSignIn(io, app);
226
+ }
227
+ catch (error) {
228
+ return { ok: false, lines: [`${name}: ${error.message}`] };
229
+ }
230
+ if (!stored)
231
+ return {
232
+ ok: false,
233
+ lines: [`${name}: not signed in here. To sign it in: npx pendingyou app login ${app}`],
234
+ };
235
+ const again = `npx pendingyou app login ${app}${originArgs(stored.origin)}`;
236
+ let me;
237
+ try {
238
+ me = await appClient(io, app).me();
239
+ }
240
+ catch (error) {
241
+ if (error instanceof SignedOutError)
242
+ return {
243
+ ok: false,
244
+ lines: [
245
+ `${name}: signed out at ${stored.origin} (removed in Settings, replaced by another sign-in, or ended). To sign in again: ${again}`,
246
+ ],
247
+ };
248
+ if (error instanceof ApiError && error.code === 'app_not_available')
249
+ return {
250
+ ok: false,
251
+ lines: [`${name}: signed in at ${stored.origin}, but Pending You doesn’t let it in now.`],
252
+ };
253
+ return {
254
+ ok: false,
255
+ lines: [
256
+ `${name}: signed in at ${stored.origin}; Pending You couldn’t be reached to say more.`,
257
+ ],
258
+ };
259
+ }
260
+ const scopes = me.grant.scopes;
261
+ const may = scopes
262
+ .filter((scope) => scope !== 'cards:read' || !scopes.includes('cards:read:all'))
263
+ .map((scope) => MAY[scope] ?? scope);
264
+ const ends = Date.parse(me.grant.expiresAt);
265
+ const left = ends - io.now();
266
+ const days = Math.max(0, Math.ceil(left / 86_400_000));
267
+ return {
268
+ ok: true,
269
+ lines: [
270
+ `${name}: signed in at ${stored.origin} as ${signedInAs(app, me)}`,
271
+ ` It reaches ${reachWords(me)}. It may ${listed(may)}; never a high-stakes card.`,
272
+ left <= RENEW_WITHIN_MS
273
+ ? ` ! It ends in ${days} day${days === 1 ? '' : 's'} (${me.grant.expiresAt.slice(0, 10)}): sign in again before then: ${again}`
274
+ : ` It ends on ${me.grant.expiresAt.slice(0, 10)}; sign in again then.`,
275
+ ` Kept in ${appPath(io, app)}`,
276
+ ],
277
+ };
278
+ }
279
+ async function status(io, app) {
280
+ const apps = app ? [app] : await signedInApps(io);
281
+ if (apps.length === 0) {
282
+ io.out('No app is signed in on this computer. The Pending You plugin for Herdr signs in from its setup, or: npx pendingyou app login herdr\n');
283
+ return 1;
284
+ }
285
+ let ok = true;
286
+ const sections = [];
287
+ for (const each of apps) {
288
+ const said = await appStatusLines(io, each);
289
+ ok &&= said.ok;
290
+ sections.push(said.lines.join('\n'));
291
+ }
292
+ io.out(`${sections.join('\n\n')}\n`);
293
+ return ok ? 0 : 1;
294
+ }
295
+ export async function appCommand(io, command) {
296
+ switch (command.sub) {
297
+ case 'login':
298
+ return login(io, { ...command, app: command.app });
299
+ case 'logout':
300
+ return logout(io, command.app);
301
+ case 'status':
302
+ return status(io, command.app);
303
+ }
304
+ }
@@ -4,7 +4,7 @@ const APP_NAMES = ['ChatGPT.app', 'Codex.app'];
4
4
  export function appCodexPaths(home, system = '/Applications') {
5
5
  return [system, join(home, 'Applications')].flatMap((root) => APP_NAMES.flatMap((name) => {
6
6
  const cli = join(root, name, 'Contents', 'Resources', 'codex-cli');
7
- return [join(cli, 'bin', 'codex'), join(cli, 'CodexCLI.app', 'Contents', 'MacOS', 'codex')];
7
+ return [join(cli, 'CodexCLI.app', 'Contents', 'MacOS', 'codex'), join(cli, 'bin', 'codex')];
8
8
  }));
9
9
  }
10
10
  const versionOf = (stdout) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? 'installed';
@@ -19,8 +19,22 @@ export const CODEX_MIN = '0.152.0';
19
19
  export const CODEX_WAKE = '0.157.0';
20
20
  export const CODEX_APP_WAKE = '0.149.0';
21
21
  const FROM_APP = 'the Codex app’s';
22
- const TRUST_CLI = 'in Codex CLI choose “Trust all and continue” (or trust them in /hooks)';
23
- const TRUST_APP = 'in the Codex app click Review hooks › Allow all (or Settings › Hooks › Trust all)';
22
+ const TRUST = 'type /hooks in Codex and trust';
23
+ export function trustSteps(found) {
24
+ const why = 'Codex runs Pending You’s hooks only once you trust them.';
25
+ if (!found)
26
+ return [
27
+ `${why} Open Codex in a terminal (codex, or the one that comes inside the ChatGPT app), type /hooks, look over Pending You’s hooks, and trust them.`,
28
+ ];
29
+ const command = found.app ? `'${found.command.replaceAll("'", `'\\''`)}'` : found.command;
30
+ return [
31
+ `${why} Open Codex in a terminal:`,
32
+ '',
33
+ ` ${command}`,
34
+ '',
35
+ 'Type /hooks, look over Pending You’s hooks, and trust them.',
36
+ ];
37
+ }
24
38
  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).';
25
39
  const CARD_TOOLS = '^mcp__pendingyou__(post_request|update_request|reply_in_thread|cancel_request)$';
26
40
  export const CODEX_HOOKS = [
@@ -161,7 +175,7 @@ async function readHooksFile(io) {
161
175
  };
162
176
  return { path, json: parsed };
163
177
  }
164
- async function hookTrust(io) {
178
+ async function readHookTrust(io) {
165
179
  const file = await readHooksFile(io);
166
180
  const config = (await readText(await configPath(io)).catch(() => null)) ?? '';
167
181
  const hooks = 'json' in file && file.json.hooks && typeof file.json.hooks === 'object'
@@ -193,6 +207,7 @@ async function hookTrust(io) {
193
207
  return { event: spec.event, sub: spec.sub, trust: 'missing' };
194
208
  });
195
209
  }
210
+ const untrusted = async (io) => (await readHookTrust(io)).filter((hook) => hook.trust === 'untrusted' || hook.trust === 'changed');
196
211
  async function signedInByItself(io, program) {
197
212
  const result = await io.run(program, ['mcp', 'list', '--json'], 30_000);
198
213
  if (result.code !== 0)
@@ -433,7 +448,7 @@ export const codex = {
433
448
  report({
434
449
  ok: true,
435
450
  text: merged.changed
436
- ? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks${extra ? `, ${cards ? 'its permission-prompt hooks (a card when Codex waits for your OK; --no-permission-cards leaves them out) ' : ''}and its session-end hook (Pending You hears when a thread is open)` : ''} in ${file.path}. Codex asks you to trust them once.`
451
+ ? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks${extra ? `, ${cards ? 'its permission-prompt hooks (a card when Codex waits for your OK; --no-permission-cards leaves them out) ' : ''}and its session-end hook (Pending You hears when a thread is open)` : ''} in ${file.path}. Codex runs them once you trust them (how: below).`
437
452
  : removed.changed
438
453
  ? `Took out Codex’s permission-prompt hooks, as you asked: no card when Codex waits for your OK. To turn them on: npx pendingyou init --app codex --permission-cards${originArgs(ictx.origin)}`
439
454
  : 'Codex’s hooks were already installed.',
@@ -469,14 +484,17 @@ export const codex = {
469
484
  });
470
485
  return steps;
471
486
  },
472
- next: () => {
487
+ next: async () => {
473
488
  const login = mode === 'oauth' && action !== 'keep';
474
489
  const first = login ? `run ${shWord(program)} mcp login pendingyou, then ` : '';
475
- const start = detected.command
476
- ? `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)`
477
- : `start codex here, or in the folder you work in, and trust Pending You’s hooks: ${TRUST_CLI}; ${TRUST_APP}`;
478
- const again = newHooks
479
- ? `Codex has new Pending You hooks (${newHooks}), which it skips until you trust them once: ${detected.command ? TRUST_APP : `${TRUST_CLI}; ${TRUST_APP}`}.`
490
+ const waiting = (await untrusted(io)).length > 0;
491
+ const start = waiting
492
+ ? 'open Codex as above, type /hooks and trust Pending You’s hooks'
493
+ : detected.command
494
+ ? 'open the Codex app and start a chat in the folder you work in'
495
+ : 'start codex here, or in the folder you work in';
496
+ const again = newHooks && waiting
497
+ ? `Codex has new Pending You hooks (${newHooks}), which it skips until you trust them once.`
480
498
  : null;
481
499
  return {
482
500
  lines: [
@@ -542,7 +560,7 @@ export const codex = {
542
560
  codex.detect(io),
543
561
  codexServer(io),
544
562
  readCodexManifest(io),
545
- hookTrust(io),
563
+ readHookTrust(io),
546
564
  readText(codexSkillPath(io)).catch(() => null),
547
565
  ]);
548
566
  const credential = await readCredential(io, origin, 'codex');
@@ -593,7 +611,7 @@ export const codex = {
593
611
  const hooksOk = core.every((hook) => hook.trust === 'trusted');
594
612
  const hooksText = core.some((hook) => hook.trust === 'missing')
595
613
  ? `not installed; run npx pendingyou init --app codex${flag}`
596
- : `${core.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
614
+ : `${core.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST} them`}`;
597
615
  const extraLine = (label, what, subs, off) => {
598
616
  const hooks = trust.filter((hook) => subs.includes(hook.sub));
599
617
  if (off)
@@ -603,7 +621,7 @@ export const codex = {
603
621
  const trusted = hooks.every((hook) => hook.trust === 'trusted');
604
622
  return trusted
605
623
  ? ` ok ${label} ${what} (${hooks.map((hook) => `${hook.event} trusted`).join(', ')})`
606
- : ` missing ${label} ${what}, once you trust ${hooks.length === 1 ? 'its hook' : 'its hooks'} (${hooks.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}): ${TRUST_CLI}; ${TRUST_APP}`;
624
+ : ` missing ${label} ${what}, once you trust ${hooks.length === 1 ? 'its hook' : 'its hooks'} (${hooks.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}): ${TRUST} ${hooks.length === 1 ? 'it' : 'them'}`;
607
625
  };
608
626
  const ownConnection = credential?.kind === 'connection' && helper;
609
627
  const notOwn = 'off: it needs Codex signed in through this computer’s own connection';
@@ -650,4 +668,12 @@ export const codex = {
650
668
  : 'Not ready: Codex here can’t reach Pending You until the lines marked missing are fixed.');
651
669
  return { lines, ready };
652
670
  },
671
+ async untrustedHooks(io) {
672
+ if ((await untrusted(io)).length === 0)
673
+ return null;
674
+ return {
675
+ lines: trustSteps(await findCodex(io)),
676
+ trusted: async () => (await untrusted(io)).length === 0,
677
+ };
678
+ },
653
679
  };