pendingyou 0.14.0 → 0.15.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,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 (2026-10-05)
4
+
5
+ - Presence: Pending You hears which of your agents' sessions are open right now. Each app tells it with its own
6
+ connection here, `POST <origin>/mcp/cli/presence` with `{"source", "name", "sessionId", "cwd", "state"}` (`name` the
7
+ name the session goes by with Pending You, else null; `cwd` only as `~/…`, else null; `state` `live` or `closed`).
8
+ Pending You counts a session live for 15 minutes after its last `live`, so an open session says so every 5 minutes,
9
+ with no model involved:
10
+ - Claude Code: its session-start hook says live; the wake mod (Claude Code 2.1.287 or later) says live as the
11
+ session starts, after /clear or /resume, and every 5 minutes while it's open, by running the hooks' script; a new
12
+ SessionEnd hook, `presence`, through the same script, says closed. Without the wake mod, the next-message hook
13
+ says live again once the last was 4 minutes ago. Only `init`'s copy of the mod does it, never the plugin's.
14
+ - Codex: its session-start and next-message hooks start a keeper for the thread (`presence --keep`, detached, a lease
15
+ of its own), which says live every 5 minutes while the Codex process the hooks ran under is running (Codex CLI, or
16
+ the Codex app's own codex), and closed once it's gone. A new SessionEnd hook (Codex fires it when a conversation
17
+ is archived or deleted, when Codex closes, or after 30 idle minutes) says closed and stops the keeper; its timeout
18
+ is 3 seconds, the most Codex gives one. Where the Codex process can't be found, the hooks say live as they run.
19
+ - OpenCode's plugin and Pi's extension say live as a session starts (OpenCode: when it's created, or its first
20
+ message) and every 5 minutes while the app runs, and closed when OpenCode deletes the session or goes, or Pi shuts
21
+ the session down.
22
+ A hook only starts the send in the background; a send gives up after 3 seconds; a Pending You from before presence
23
+ answers 404, which is taken quietly. A sign-in that only hears (`--oauth`) sends nothing. Events are written down in
24
+ `~/.config/pendingyou/presence/<app>.json` (ids, states and times), so a late `live` never overrides a newer
25
+ `closed`, and a reload's `live` goes after its `closed`. `status` has a line for each app: “Presence: tells Pending
26
+ You when this session is open”.
27
+ - Codex's permission prompts on a card, as Claude Code's since 0.13.0. `init` adds Codex's PermissionRequest hook (it
28
+ fires when Codex is about to ask for approval: a shell command that needs more than the sandbox gives, an
29
+ `apply_patch`, an MCP tool; `tool_name` Bash, apply_patch or the tool's, and `tool_input.description` when Codex has
30
+ a reason), through the shim with `--app codex`. Codex reads that hook's output as its decision, so it prints nothing
31
+ and exits 0: it declines, and Codex asks as before. Once a prompt has waited 10 seconds, the same detached worker
32
+ posts one card with Codex's own connection: “Codex is waiting for your OK: git push origin main”, “…: edit
33
+ src/app.ts and 1 more” for a patch (the files its `*** Update File:` lines name), “…: create_issue on github” for an
34
+ MCP tool, with Codex's reason (redacted) in the summary, under the name the thread went by on Pending You's card tools,
35
+ else “Codex”, and the same redaction. It's withdrawn when the call runs (a PostToolUse hook for every tool, which
36
+ also settles a prompt for the same tool when Codex gives the call that ran its input in another form), with your
37
+ next message, at the end of the turn, or when the thread ends (SessionEnd). `--no-permission-cards` leaves Codex's
38
+ out too. The Codex app's browser-use prompts run no PermissionRequest hook, so they get no card.
39
+ - Presence keeps to Pending You's contract: the name trimmed to one line of at most 60 characters, a folder over 300
40
+ characters sent as null, a 401 tried once more after a refresh in the background, a 429 waited out once (its
41
+ `retry-after`, 60 seconds) by a send in its own process (never a hook), and 400, 403 and 404 taken quietly; Codex's
42
+ keeper stops once a send is refused, its sign-in is gone, or the server has no presence.
43
+ - A question you handed an agent, when the assistant that asked answers what the helper asked it on the card (or you
44
+ write in to them both, guide 2.30), wakes the helper with those words, not the handed question again: “Pending You:
45
+ billing-webhooks replied on the question handed to you, “Which queue?” (req_…): “It's the jobs queue.”. Call
46
+ get_request (requestId req_…, name “infra”) to read what you've said to each other. If you know now,
47
+ answer_delegated …; if you need more, reply_in_thread to “asker”; if it's your person's to decide, reply_in_thread
48
+ with escalate true; if not, hand_back …” (“your person wrote to you both …” for yours). The same from the hooks, the
49
+ wake mod, OpenCode's plugin and Pi's extension; Codex's `codex queue` leaves the words out of its arguments, where any
50
+ program could read them, and get_request has them. The wake mod now wakes a session for such a reply after it read
51
+ the question itself (before, a question it had read never woke it again), and the Stop check and the hooks know a
52
+ turn such a wake started.
53
+ - Switching an agent's own sign-in to this computer's says what becomes of the old one: once the agent shows up on this
54
+ computer's sign-in, Pending You moves the old sign-in's agents over, with their cards and the questions you delegated
55
+ to them, and signs it out. Where there's no browser (an SSH session) init says what it switched instead of switching
56
+ without a word.
57
+ - Codex runs a hook only once you trust it, so the three new hooks (PermissionRequest, PostToolUse for every tool,
58
+ SessionEnd) need trusting once. They go at the end of their lists, so the ones you trusted stay trusted. `init` says
59
+ so when it adds them to a Codex set up before (“Codex has new Pending You hooks (session end and permission prompts),
60
+ which it skips until you trust them once: …”), and `status` has a line for each, with each hook's trust:
61
+ “Permission prompts: a card when Codex waits for your OK” and “Presence: …”. Neither keeps `status` from saying
62
+ Ready. Codex's record of its hooks (`codex.json`) now names each by its event and command.
63
+
3
64
  ## 0.14.0 (2026-10-05)
4
65
 
5
66
  - Questions your person hands one of your agents from another assistant (Delegate: Pending You's card link, or D on the
package/README.md CHANGED
@@ -24,8 +24,9 @@ Run it once on each computer, in a terminal. It finds the agents on the computer
24
24
  Pi reads Codex's, else `~/.pi/agent/skills/pendingyou/SKILL.md`),
25
25
  - adds the hooks that hand it your answers: a new session picks up answers that arrived while nothing was running,
26
26
  your next message brings a waiting answer with it, and a check when it finishes a turn catches what it left you only
27
- in chat ([below](#nothing-left-only-in-chat)); and, for Claude Code, a card when it waits for your OK to run something
28
- ([below](#when-claude-code-waits-for-your-ok)),
27
+ in chat ([below](#nothing-left-only-in-chat)); for Claude Code and Codex, a card when it waits for your OK to run
28
+ something ([below](#when-claude-code-waits-for-your-ok)); and they tell Pending You which sessions are open
29
+ ([below](#which-sessions-are-open)),
29
30
  - and wakes it when you answer: Claude Code 2.1.287 or later through the wake mod, Codex through `codex queue`, OpenCode
30
31
  through its plugin and Pi through its extension (which also do the hooks' work there); and when you hand it a
31
32
  question from another of your assistants ([below](#when-you-hand-an-agent-a-question)).
@@ -54,10 +55,11 @@ in each, every other byte as it was), and running it again changes nothing it do
54
55
  **A pendingyou server that's there already** is decided on before anyone signs in. One at another Pending You
55
56
  (staging, say) is named and replaced only once you say so (`--yes` replaces it without asking); keep it and that agent
56
57
  is left as it is. One at this Pending You that signs in by itself (Claude Code's `/mcp` → Authenticate, Codex's
57
- `codex mcp login`, or the Pending You plugin for Claude) is moved to this computer's sign-in: without asking where
58
- there's no browser (it can't finish there anyway), on Yes (the default) at a terminal, or with `--yes`; with nobody to
59
- ask it's kept, and `init` prints the command that switches it. The connection it had stays on your Assistants page for
60
- you to remove, and answers to the cards it asked through that one won't reach the agent by themselves. A kept plugin
58
+ `codex mcp login`, or the Pending You plugin for Claude) is moved to this computer's sign-in: where there's no browser
59
+ (it can't finish there anyway) without asking, saying what it switched; on Yes (the default) at a terminal; or with
60
+ `--yes`; with nobody to ask it's kept, and `init` prints the command that switches it. Once the agent shows up on this
61
+ computer's sign-in, Pending You moves the old sign-in's agents over to it, with their cards and the questions you
62
+ delegated to them, and signs the old one out. A kept plugin
61
63
  carries the skill, so none is saved. `--oauth` keeps every agent signing in by itself, as before 0.11.0.
62
64
 
63
65
  The hooks run a copy of this version kept in `~/.config/pendingyou/cli/<version>/`, through a small script with a path
@@ -94,10 +96,13 @@ http_headers_helper = "/Users/ann/.config/pendingyou/bin/pendingyou-mcp-headers
94
96
  default_tools_approval_mode = "approve"
95
97
  ```
96
98
 
97
- and four hooks in `~/.codex/hooks.json`: a session start, each message, Pending You's card tools (so the Stop check
98
- knows a turn posted, and so the thread is woken), and the Stop check. **Codex runs a hook only once you trust it**: in
99
- Codex CLI, start `codex` and choose “Trust all and continue” (or trust them in `/hooks`); in the Codex app, click
100
- Review hooks › Allow all (or Settings › Hooks › Trust all). `status` says which are trusted. A `pendingyou` server or
99
+ and hooks in `~/.codex/hooks.json`: a session start, each message, Pending You's card tools (so the Stop check knows a
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
+ 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
105
+ skips until you trust them once; `init` says so, and the ones you trusted stay trusted. A `pendingyou` server or
101
106
  hooks Codex's `/import` copied from Claude Code (without its sign-in) are replaced.
102
107
 
103
108
  **The Codex app** (on a Mac, in the ChatGPT app) shares all of this with Codex CLI: the same `~/.codex`, server, hooks
@@ -245,6 +250,32 @@ It needs Claude Code 2.1.119 or later, signing in through this computer's sign-i
245
250
  Windows yet. Don't want it? `npx pendingyou init --no-permission-cards` (later runs keep it off; `--permission-cards`
246
251
  turns it back on). `npx pendingyou status` says which.
247
252
 
253
+ **Codex** (0.15.0) gets the same: when Codex stops to ask for your approval (a command that needs more than its sandbox
254
+ gives, a patch, an MCP tool) and you don't answer within 10 seconds, a card says “Codex is waiting for your OK: git push
255
+ origin main” (“…: edit src/app.ts and 1 more” for a patch), with Codex's reason for asking, blanked like the rest. Its
256
+ PermissionRequest hook prints nothing, so Codex asks exactly as before; the card goes once the call runs, you write to
257
+ Codex, the turn ends or the thread closes. It needs Codex signing in through this computer's sign-in, and trusting the
258
+ new hooks once. The Codex app's browser-use prompts don't run that hook, so they get no card.
259
+
260
+ ## Which sessions are open
261
+
262
+ Since 0.15.0 Pending You hears which of your agents' sessions are open right now, so it can say so and offer an open
263
+ one a question. Each app tells it with its own connection here: the session's id, the name it goes by with Pending You,
264
+ its folder (under `~` only) and `live` or `closed`. Pending You counts a session open for 15 minutes after its last
265
+ `live`, so an open session says so every 5 minutes, with no model involved:
266
+
267
+ - **Claude Code**: as a session starts (its session-start hook, and the wake mod), then every 5 minutes from the wake
268
+ mod while it's open (without the mod, with your messages); closed as it ends (a SessionEnd hook).
269
+ - **Codex**: as a thread starts or you write to it, a small process in the background says so every 5 minutes while
270
+ Codex (or the Codex app) runs, and closed once it's gone; its SessionEnd hook (a conversation archived or closed, or
271
+ idle 30 minutes) says closed.
272
+ - **OpenCode and Pi**: the plugin and the extension, as a session starts and every 5 minutes while the app runs, and
273
+ closed as it goes.
274
+
275
+ It's never in your way: the hooks only start it in the background, each send gives up after 3 seconds, and a Pending
276
+ You from before presence is taken quietly. A sign-in that only hears (`--oauth`) sends nothing. `npx pendingyou status`
277
+ has a Presence line for each agent.
278
+
248
279
  ## How Claude Code hears your answer
249
280
 
250
281
  **Claude Code 2.1.287 or later: the wake mod.** There's nothing to run. In each session the mod learns the cards
@@ -386,8 +417,9 @@ Pending You (Claude Code says which server it's for; Codex's is the one `init` w
386
417
  sign-in each agent's connection used, so signing in again keeps it.
387
418
 
388
419
  `state.json` beside them remembers which answers were already handed over (ids only) and how many session starts
389
- reminded an unfinished setup, and when; `permission-cards/` each Claude Code session's prompts still waiting on your OK
390
- and its card (ids, hashes and the card's redacted words: never what the tool was given), gone once nothing waits;
420
+ reminded an unfinished setup, and when; `permission-cards/` each Claude Code session's (and Codex thread's) prompts
421
+ still waiting on your OK and its card (ids, hashes and the card's redacted words: never what the tool was given), gone once nothing waits;
422
+ `presence/` the last presence each session said (ids, states and times), and Codex threads' keepers;
391
423
  `claude-code.json`, `codex.json`, `opencode.json` and `pi.json` what `init`
392
424
  added; `codex-threads.json`, `opencode-threads.json` and `pi-threads.json` which cards each Codex thread or OpenCode or
393
425
  Pi session waits on, and (0.14.0) the names it went by and its folder, kept 12 hours for a question you may hand it
@@ -398,8 +430,9 @@ whether the agent has a `pendingyou` MCP server and where it points. With a sign
398
430
  listeners tell this computer's handed questions from another's by its names: its hostname, the names its sign-ins by
399
431
  code were given, a Mac's own name (`scutil --get ComputerName`) and, outside a hook, Tailscale's (`tailscale status`). For a permission prompt's card, the worker reads
400
432
  the end of the Claude Code session's transcript only for the name the session gave Pending You, and `git remote
401
- get-url origin` in its folder (sent without any user name or password in it). Nothing is sent anywhere but Pending
402
- You. OpenCode's plugin reads its session's messages only when it goes idle, for the check that
433
+ get-url origin` in its folder (sent without any user name or password in it). To tell when a Codex thread's Codex has quit, its
434
+ keeper asks `ps` about the Codex process its hooks ran under (its id and program name, nothing else). Nothing is sent
435
+ anywhere but Pending You. OpenCode's plugin reads its session's messages only when it goes idle, for the check that
403
436
  nothing was left in chat, and hands the hooks' script just that turn's words and tool names.
404
437
 
405
438
  The wake mod runs inside Claude Code and reaches Pending You only through the session's own connection, with
@@ -407,7 +440,9 @@ The wake mod runs inside Claude Code and reaches Pending You only through the se
407
440
  `list_pending` with the session's name and `get_request` on a question you handed it. It keeps each session's cards (id,
408
441
  title, the agent's name, where each stands), and which handed questions a session was told of (ids and when), in Claude
409
442
  Code's store for mods, `~/.claude/plugins/store/`, and forgets them once they close or after 7 days. It marks the copy
410
- acting for a session in that session's environment (`PENDINGYOU_WAKE_OWNER`).
443
+ acting for a session in that session's environment (`PENDINGYOU_WAKE_OWNER`). `init`'s copy (0.15.0) also reads
444
+ `~/.config/pendingyou/claude-code.json` for the Pending You it's for, and runs the hooks' script to say the session is
445
+ open.
411
446
 
412
447
  ## Development
413
448
 
package/dist/api.js CHANGED
@@ -172,3 +172,30 @@ export async function getJson(io, origin, path, timeoutMs, signal, options = {})
172
172
  return { status: response.status, body };
173
173
  }
174
174
  }
175
+ export async function postJson(io, origin, path, body, timeoutMs, options = {}) {
176
+ const app = options.app ?? DEFAULT_APP;
177
+ const token = await hookToken(io, origin, options.force === true, options.signal, app);
178
+ let response;
179
+ try {
180
+ response = await io.fetch(`${origin}${path}`, {
181
+ method: 'POST',
182
+ headers: {
183
+ authorization: `Bearer ${token}`,
184
+ 'content-type': 'application/json',
185
+ accept: 'application/json',
186
+ },
187
+ body: JSON.stringify(body),
188
+ signal: either(options.signal, timeoutMs),
189
+ redirect: 'manual',
190
+ });
191
+ }
192
+ catch {
193
+ throw new Unavailable('Pending You couldn’t be reached.');
194
+ }
195
+ await response.body?.cancel().catch(() => { });
196
+ const retry = Number(response.headers.get('retry-after'));
197
+ return {
198
+ status: response.status,
199
+ ...(Number.isFinite(retry) && retry > 0 ? { retryAfter: retry } : {}),
200
+ };
201
+ }
@@ -7,16 +7,11 @@ import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurH
7
7
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
8
8
  import { shimPath } from "../shim.js";
9
9
  import { APP_NAMES } from "./ids.js";
10
+ import { switches } from "./switch.js";
10
11
  const NAME = APP_NAMES['claude-code'];
11
12
  const RESTART = 'restart Claude Code here (/exit, then claude --continue), or start it in the folder you work in';
12
- async function switches(io, ctx, what) {
13
- if (ctx.headless || ctx.yes)
14
- return true;
15
- if (!io.interactive)
16
- return false;
17
- io.out(`${what} init can move Claude Code to this computer’s own sign-in, which the hooks use too: one sign-in for both, and nothing to Authenticate. 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 Claude Code by themselves.\n`);
18
- const answer = await io.ask('Switch Claude Code to this computer’s sign-in? [Y/n] ');
19
- return !/^\s*no?\s*$/i.test(answer);
13
+ async function switchesFor(io, ctx, what) {
14
+ return switches(io, ctx, { name: 'Claude Code', what, uses: 'the hooks use', headless: true });
20
15
  }
21
16
  async function replaces(io, ctx, server, url) {
22
17
  ctx.progress(`Claude Code’s pendingyou MCP server points at ${server.url}, not ${url}.\n`);
@@ -117,7 +112,7 @@ export const claudeCode = {
117
112
  };
118
113
  }
119
114
  else if (helperMode &&
120
- (await switches(io, ctx, 'Claude Code signs in to Pending You by itself here (/mcp → Authenticate).')))
115
+ (await switchesFor(io, ctx, 'Claude Code signs in to Pending You by itself here (/mcp → Authenticate).')))
121
116
  plan = { action: 'switch', server };
122
117
  else {
123
118
  mode = 'oauth';
@@ -132,7 +127,7 @@ export const claudeCode = {
132
127
  }
133
128
  else if (plugin) {
134
129
  if (helperMode &&
135
- (await switches(io, ctx, 'The Pending You plugin signs Claude Code in to Pending You by itself here.')))
130
+ (await switchesFor(io, ctx, 'The Pending You plugin signs Claude Code in to Pending You by itself here.')))
136
131
  plan = { action: 'add', plugin: true };
137
132
  else {
138
133
  mode = 'oauth';
@@ -215,6 +210,14 @@ function permissionLine(io, claude, manifest, helper, flag) {
215
210
  ? ` ${label} no card when Claude Code waits for your OK: ${blocker}`
216
211
  : ` missing ${label} no card when Claude Code waits for your OK; run npx pendingyou@latest init${flag}`;
217
212
  }
213
+ function presenceLine(claude, connected, mod, flag) {
214
+ const label = 'Presence:';
215
+ if (!connected)
216
+ return ` ${label} off: it needs Claude Code signed in through this computer’s own connection`;
217
+ if (!claude.presence || !claude.hooks)
218
+ return ` missing ${label} Pending You isn’t told when a session closes; run npx pendingyou@latest init${flag}`;
219
+ return ` ok ${label} tells Pending You when this session is open (${mod ? 'as it starts, then every 5 minutes from the wake mod' : 'as it starts and with your messages'}; closed as it ends)`;
220
+ }
218
221
  async function claudeStatus(io, ctx) {
219
222
  const { origin, signIn } = ctx;
220
223
  const [claude, here, manifest] = await Promise.all([
@@ -322,7 +325,7 @@ async function claudeStatus(io, ctx) {
322
325
  ? [
323
326
  ` ok Plugin: ${plugin} is enabled${pluginServer ? '' : mcp !== false && mcp !== 'unknown' ? '; Claude Code uses the server added by hand instead of its own' : ''}`,
324
327
  ]
325
- : []), ` ${mark(skillOk)} Skill: ${skillText}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(modOk)} Wake mod: ${modText}`, permissionLine(io, claude, manifest, helper, flag));
328
+ : []), ` ${mark(skillOk)} Skill: ${skillText}`, ` ${mark(claude.hooks && hooksRun)} Hooks: ${claude.hooks ? `session-start pickup and next-message hand-off; ${formText}` : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : `not installed; run npx pendingyou init${flag}`}`, ` ${mark(modOk)} Wake mod: ${modText}`, permissionLine(io, claude, manifest, helper, flag), presenceLine(claude, helper && connected, modOk, flag));
326
329
  const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
327
330
  if (setup)
328
331
  lines.push(` ${setup === 'finished' ? 'ok ' : ' '} Setup: ${setup}`);
@@ -636,7 +636,7 @@ export async function listenHanded(io, options) {
636
636
  ]);
637
637
  }
638
638
  for (const [thread, list] of byThread) {
639
- const message = handedMessage(list.map(({ card }) => card));
639
+ const message = handedMessage(list.map(({ card }) => card), { words: false });
640
640
  if (message && (await queue(thread, message)))
641
641
  continue;
642
642
  await giveBack(io, origin, list);
@@ -4,13 +4,15 @@ import { join } from 'node:path';
4
4
  import { removeSkill, saveSkill } from "../claude.js";
5
5
  import { readCredential } from "../credentials.js";
6
6
  import { configDir, readJson, readText, writeWhole } from "../files.js";
7
- import { isOurHook, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
7
+ import { isOurHook, lineKey, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
8
8
  import { atLeast } from "../mod.js";
9
+ import { removePresenceFiles } from "../presence.js";
9
10
  import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, } from "../remote.js";
10
11
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
11
12
  import { basicString, definedInline, headerOf, renameTable, tableSpans, tableValues, withTable, } from "../toml.js";
12
13
  import { findCodex } from "./codex-app.js";
13
14
  import { APP_NAMES } from "./ids.js";
15
+ import { switches } from "./switch.js";
14
16
  const NAME = APP_NAMES.codex;
15
17
  const SERVER = ['mcp_servers', 'pendingyou'];
16
18
  export const CODEX_MIN = '0.152.0';
@@ -27,11 +29,25 @@ export const CODEX_HOOKS = [
27
29
  { event: 'PostToolUse', sub: 'posted', matcher: CARD_TOOLS },
28
30
  { event: 'Stop', sub: 'stopcheck' },
29
31
  ];
32
+ export const CODEX_PERMISSION_HOOKS = [
33
+ { event: 'PermissionRequest', sub: 'permission' },
34
+ { event: 'PostToolUse', sub: 'permission-done' },
35
+ ];
36
+ export const CODEX_PRESENCE_HOOKS = [
37
+ { event: 'SessionEnd', sub: 'presence', timeout: 3 },
38
+ ];
39
+ export const ALL_CODEX_HOOKS = [
40
+ ...CODEX_HOOKS,
41
+ ...CODEX_PERMISSION_HOOKS,
42
+ ...CODEX_PRESENCE_HOOKS,
43
+ ];
30
44
  const EVENT_LABELS = {
31
45
  SessionStart: 'session_start',
32
46
  UserPromptSubmit: 'user_prompt_submit',
33
47
  PostToolUse: 'post_tool_use',
34
48
  Stop: 'stop',
49
+ PermissionRequest: 'permission_request',
50
+ SessionEnd: 'session_end',
35
51
  };
36
52
  const NO_MATCHER = new Set(['UserPromptSubmit', 'Stop']);
37
53
  const SIGN_IN_KEYS = new Set([
@@ -151,7 +167,7 @@ async function hookTrust(io) {
151
167
  const hooks = 'json' in file && file.json.hooks && typeof file.json.hooks === 'object'
152
168
  ? file.json.hooks
153
169
  : {};
154
- return CODEX_HOOKS.map((spec) => {
170
+ return ALL_CODEX_HOOKS.map((spec) => {
155
171
  const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
156
172
  for (const [g, group] of groups.entries())
157
173
  for (const [h, handler] of (group?.hooks ?? []).entries()) {
@@ -159,13 +175,14 @@ async function hookTrust(io) {
159
175
  continue;
160
176
  const state = tableValues(config, ['hooks', 'state', trustKey(file.path, spec.event, g, h)]);
161
177
  if (state.enabled === false)
162
- return { event: spec.event, trust: 'off' };
178
+ return { event: spec.event, sub: spec.sub, trust: 'off' };
163
179
  const hash = hookHash(spec.event, group.matcher, {
164
180
  command: handler.command,
165
181
  ...(typeof handler.timeout === 'number' ? { timeout: handler.timeout } : {}),
166
182
  });
167
183
  return {
168
184
  event: spec.event,
185
+ sub: spec.sub,
169
186
  trust: state.trusted_hash === hash
170
187
  ? 'trusted'
171
188
  : typeof state.trusted_hash === 'string'
@@ -173,7 +190,7 @@ async function hookTrust(io) {
173
190
  : 'untrusted',
174
191
  };
175
192
  }
176
- return { event: spec.event, trust: 'missing' };
193
+ return { event: spec.event, sub: spec.sub, trust: 'missing' };
177
194
  });
178
195
  }
179
196
  async function signedInByItself(io, program) {
@@ -191,15 +208,12 @@ async function signedInByItself(io, program) {
191
208
  return null;
192
209
  }
193
210
  }
194
- async function switches(io, ctx) {
195
- if (ctx.headless || ctx.yes)
196
- return true;
197
- if (!io.interactive)
198
- return false;
199
- 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');
200
- const answer = await io.ask('Switch Codex to this computer’s sign-in? [Y/n] ');
201
- return !/^\s*no?\s*$/i.test(answer);
202
- }
211
+ const switchesFor = (io, ctx) => switches(io, ctx, {
212
+ name: 'Codex',
213
+ what: 'Codex signs in to Pending You by itself here (codex mcp login).',
214
+ uses: 'its hooks use',
215
+ headless: true,
216
+ });
203
217
  async function writeConfig(path, text) {
204
218
  return writeWhole(path, text).then(() => true, () => false);
205
219
  }
@@ -210,24 +224,26 @@ function keepTheirTrust(config, file, before) {
210
224
  'state',
211
225
  trustKey(file, event, group, handler),
212
226
  ];
213
- for (const spec of CODEX_HOOKS) {
214
- const groups = Array.isArray(before[spec.event]) ? before[spec.event] : [];
227
+ for (const event of [...new Set(ALL_CODEX_HOOKS.map((spec) => spec.event))]) {
228
+ const subs = ALL_CODEX_HOOKS.filter((spec) => spec.event === event).map((spec) => spec.sub);
229
+ const ours = (hook) => subs.some((sub) => isOurHook(hook?.command, sub));
230
+ const groups = Array.isArray(before[event]) ? before[event] : [];
215
231
  for (const [g, group] of groups.entries())
216
232
  for (const [h, hook] of (Array.isArray(group?.hooks) ? group.hooks : []).entries())
217
- if (isOurHook(hook?.command, spec.sub))
218
- next = withTable(next, state(spec.event, g, h), null);
233
+ if (ours(hook))
234
+ next = withTable(next, state(event, g, h), null);
219
235
  let at = 0;
220
236
  for (const [g, group] of groups.entries()) {
221
237
  if (!Array.isArray(group?.hooks)) {
222
238
  at++;
223
239
  continue;
224
240
  }
225
- const kept = group.hooks.flatMap((hook, h) => (isOurHook(hook?.command, spec.sub) ? [] : [h]));
241
+ const kept = group.hooks.flatMap((hook, h) => (ours(hook) ? [] : [h]));
226
242
  if (kept.length === 0)
227
243
  continue;
228
244
  for (const [h, was] of kept.entries())
229
245
  if (g !== at || was !== h)
230
- next = renameTable(next, state(spec.event, g, was), state(spec.event, at, h));
246
+ next = renameTable(next, state(event, g, was), state(event, at, h));
231
247
  at++;
232
248
  }
233
249
  }
@@ -315,7 +331,7 @@ export const codex = {
315
331
  else if ((await signedInByItself(io, program)) !== true) {
316
332
  action = 'write';
317
333
  }
318
- else if (await switches(io, ctx))
334
+ else if (await switchesFor(io, ctx))
319
335
  action = 'write';
320
336
  else {
321
337
  mode = 'oauth';
@@ -327,6 +343,7 @@ export const codex = {
327
343
  }
328
344
  }
329
345
  const version = detected.version;
346
+ let newHooks = null;
330
347
  return {
331
348
  app: codex,
332
349
  detected,
@@ -384,7 +401,15 @@ export const codex = {
384
401
  }
385
402
  const skill = await saveSkill(io, ictx.origin, codexSkillPath(io), before?.skill ?? null);
386
403
  report(skill.step);
387
- const lines = Object.fromEntries(CODEX_HOOKS.map((spec) => [spec.event, ictx.hookLine(spec.sub)]));
404
+ const cards = ctx.permissionCards ?? before?.permissionCards !== false;
405
+ const extra = mode === 'helper' && ictx.helper !== null;
406
+ const wanted = [
407
+ ...CODEX_HOOKS,
408
+ ...(extra && cards ? CODEX_PERMISSION_HOOKS : []),
409
+ ...(extra ? CODEX_PRESENCE_HOOKS : []),
410
+ ];
411
+ const unwanted = ALL_CODEX_HOOKS.filter((spec) => !wanted.includes(spec));
412
+ const lines = Object.fromEntries(wanted.map((spec) => [lineKey(spec), ictx.hookLine(spec.sub)]));
388
413
  const file = await readHooksFile(io);
389
414
  if ('invalid' in file)
390
415
  report({ ok: false, text: file.invalid });
@@ -393,14 +418,25 @@ export const codex = {
393
418
  const hooks = file.json.hooks && typeof file.json.hooks === 'object'
394
419
  ? file.json.hooks
395
420
  : {};
396
- const merged = mergeHooks(hooks, CODEX_HOOKS, lines, file.path);
397
- if (merged.changed)
398
- await writeWhole(file.path, `${JSON.stringify({ ...file.json, hooks: merged.hooks }, null, 2)}\n`);
421
+ const merged = mergeHooks(hooks, wanted, lines, file.path);
422
+ const removed = removeHooks(merged.hooks, unwanted);
423
+ if (merged.changed || removed.changed)
424
+ await writeWhole(file.path, `${JSON.stringify({ ...file.json, hooks: removed.hooks }, null, 2)}\n`);
425
+ const fresh = wanted.filter((spec) => !CODEX_HOOKS.includes(spec) && !(lineKey(spec) in (before?.hooks ?? {})));
426
+ if (before && merged.changed && fresh.length)
427
+ newHooks = [
428
+ ...(fresh.some((spec) => spec.sub === 'presence') ? ['session end'] : []),
429
+ ...(fresh.some((spec) => spec.sub.startsWith('permission'))
430
+ ? ['permission prompts']
431
+ : []),
432
+ ].join(' and ');
399
433
  report({
400
434
  ok: true,
401
435
  text: merged.changed
402
- ? `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.`
403
- : 'Codex’s hooks were already installed.',
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.`
437
+ : removed.changed
438
+ ? `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
+ : 'Codex’s hooks were already installed.',
404
440
  });
405
441
  }
406
442
  catch (error) {
@@ -426,6 +462,7 @@ export const codex = {
426
462
  hooks: lines,
427
463
  helper: mode === 'helper' ? ictx.helper : null,
428
464
  setup: { since: new Date(io.now()).toISOString() },
465
+ permissionCards: cards,
429
466
  };
430
467
  await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, {
431
468
  secret: true,
@@ -438,9 +475,19 @@ export const codex = {
438
475
  const start = detected.command
439
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)`
440
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}`}.`
480
+ : null;
441
481
  return {
442
- lines: [`Next: ${first}${start}.`, DONT_SAVE, 'Then say this to it:', '', FINISH_SAY],
443
- together: `- Codex: ${first}${start}, then say the line below to it. ${DONT_SAVE}`,
482
+ lines: [
483
+ `Next: ${first}${start}.`,
484
+ ...(again ? [again] : []),
485
+ DONT_SAVE,
486
+ 'Then say this to it:',
487
+ '',
488
+ FINISH_SAY,
489
+ ],
490
+ together: `- Codex: ${first}${start}, then say the line below to it.${again ? ` ${again}` : ''} ${DONT_SAVE}`,
444
491
  say: true,
445
492
  };
446
493
  },
@@ -454,7 +501,7 @@ export const codex = {
454
501
  let doc = (await readText(config).catch(() => null)) ?? null;
455
502
  if ('json' in file) {
456
503
  const hooks = file.json.hooks && typeof file.json.hooks === 'object' ? file.json.hooks : {};
457
- const removed = removeHooks(hooks, CODEX_HOOKS);
504
+ const removed = removeHooks(hooks, ALL_CODEX_HOOKS);
458
505
  if (removed.changed) {
459
506
  const rest = { ...file.json, hooks: removed.hooks };
460
507
  if (Object.keys(removed.hooks).length === 0)
@@ -484,6 +531,7 @@ export const codex = {
484
531
  steps.push(skill);
485
532
  await rm(join(configDir(io), 'codex-threads.json'), { force: true });
486
533
  await rm(join(configDir(io), 'codex-listen'), { recursive: true, force: true });
534
+ await removePresenceFiles(io, 'codex');
487
535
  await rm(manifestPath(io), { force: true });
488
536
  return steps;
489
537
  },
@@ -541,10 +589,30 @@ export const codex = {
541
589
  off: 'turned off',
542
590
  missing: 'missing',
543
591
  };
544
- const hooksOk = trust.every((hook) => hook.trust === 'trusted');
545
- const hooksText = trust.some((hook) => hook.trust === 'missing')
592
+ const core = trust.filter((hook) => CODEX_HOOKS.some((spec) => spec.sub === hook.sub));
593
+ const hooksOk = core.every((hook) => hook.trust === 'trusted');
594
+ const hooksText = core.some((hook) => hook.trust === 'missing')
546
595
  ? `not installed; run npx pendingyou init --app codex${flag}`
547
- : `${trust.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
596
+ : `${core.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
597
+ const extraLine = (label, what, subs, off) => {
598
+ const hooks = trust.filter((hook) => subs.includes(hook.sub));
599
+ if (off)
600
+ return ` ${label} ${off}`;
601
+ if (hooks.some((hook) => hook.trust === 'missing'))
602
+ return ` missing ${label} ${what}: not installed; run npx pendingyou@latest init --app codex${flag}`;
603
+ const trusted = hooks.every((hook) => hook.trust === 'trusted');
604
+ return trusted
605
+ ? ` 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}`;
607
+ };
608
+ const ownConnection = credential?.kind === 'connection' && helper;
609
+ const notOwn = 'off: it needs Codex signed in through this computer’s own connection';
610
+ const permissionText = extraLine('Permission prompts:', 'a card when Codex waits for your OK', CODEX_PERMISSION_HOOKS.map((spec) => spec.sub), !ownConnection
611
+ ? notOwn
612
+ : manifest?.permissionCards === false
613
+ ? `off, as you chose; for a card when Codex waits for your OK, run npx pendingyou init --app codex --permission-cards${flag}`
614
+ : null);
615
+ const presenceText = extraLine('Presence:', 'tells Pending You when this session is open', CODEX_PRESENCE_HOOKS.map((spec) => spec.sub), ownConnection ? null : notOwn);
548
616
  const wakes = detected
549
617
  ? atLeast(detected.version, detected.command ? CODEX_APP_WAKE : CODEX_WAKE)
550
618
  : false;
@@ -562,6 +630,8 @@ export const codex = {
562
630
  : []),
563
631
  ` ${mark(skill !== null)} Skill: ${skill !== null ? `saved (${codexSkillPath(io)})` : `not saved; run npx pendingyou init --app codex${flag}`}`,
564
632
  ` ${mark(hooksOk)} Hooks: ${hooksText}`,
633
+ permissionText,
634
+ presenceText,
565
635
  ` ${mark(wakes)} Wake: ${wakes
566
636
  ? detected?.command
567
637
  ? '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)'