pendingyou 0.13.0 → 0.14.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +73 -11
  2. package/README.md +65 -20
  3. package/dist/api.js +0 -50
  4. package/dist/apps/claude-code.js +0 -38
  5. package/dist/apps/codex-app.js +0 -18
  6. package/dist/apps/codex-wake.js +290 -122
  7. package/dist/apps/codex.js +0 -68
  8. package/dist/apps/ids.js +0 -11
  9. package/dist/apps/opencode-plugin.js +39 -65
  10. package/dist/apps/opencode.js +0 -70
  11. package/dist/apps/pi-extension.js +60 -75
  12. package/dist/apps/pi.js +0 -62
  13. package/dist/apps/registry.js +0 -2
  14. package/dist/args.js +7 -40
  15. package/dist/bridge.js +0 -94
  16. package/dist/claude.js +0 -94
  17. package/dist/cli.js +0 -2
  18. package/dist/confirm.js +0 -69
  19. package/dist/credentials.js +0 -27
  20. package/dist/environment.js +0 -28
  21. package/dist/errors.js +0 -1
  22. package/dist/files.js +0 -16
  23. package/dist/format.js +30 -13
  24. package/dist/handed.js +71 -0
  25. package/dist/hold.js +0 -24
  26. package/dist/hooks.js +0 -26
  27. package/dist/index.js +0 -2
  28. package/dist/init.js +0 -32
  29. package/dist/install.js +0 -32
  30. package/dist/io.js +0 -10
  31. package/dist/json.js +0 -29
  32. package/dist/jsonc.js +0 -33
  33. package/dist/main.js +3 -26
  34. package/dist/mod.js +0 -23
  35. package/dist/oauth.js +0 -45
  36. package/dist/permission.js +0 -107
  37. package/dist/pickup.js +101 -62
  38. package/dist/qr.js +0 -28
  39. package/dist/redact.js +0 -77
  40. package/dist/remote.js +0 -90
  41. package/dist/setup.js +0 -30
  42. package/dist/shim.js +0 -23
  43. package/dist/signin.js +0 -42
  44. package/dist/state.js +19 -10
  45. package/dist/stopcheck.js +40 -84
  46. package/dist/toml.js +0 -34
  47. package/dist/version.js +1 -2
  48. package/dist/watch.js +3 -20
  49. package/mod/.claude-plugin/plugin.json +1 -1
  50. package/mod/README.md +2 -2
  51. package/mod/hooks/register.js +104 -61
  52. package/mod/hooks/wake.js +110 -108
  53. package/package.json +2 -1
  54. package/mod/tests/wake.test.ts +0 -247
package/CHANGELOG.md CHANGED
@@ -1,11 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-10-05)
4
+
5
+ - Questions your person hands one of your agents from another assistant (Delegate: Pending You's card link, or D on the
6
+ desk). Once the delegation's 5-second hold is over, Pending You's `/mcp/cli/answers` brings such a question to the
7
+ app's sign-in, marked `delegated` (who asked, `freely` or `loop`, the person's note), under the name the helper
8
+ answers with (`sessionLabel`, the task the person picked) and in that task's folder (`cwd`). The hooks used to say
9
+ “Pending You: they wrote back on req_…”; now a session start or message says “Pending You: your person handed you
10
+ req_…, a question from billing-webhooks (answer freely: your answer goes straight to billing-webhooks).”, the
11
+ question, “Their note: “…””, and “Call get_request (requestId req_…, name “infra”) to read it. If you know,
12
+ answer_delegated with your answer and how you know; if not, hand_back with what you checked.” (“they’ll see your
13
+ answer first” when the person kept themselves in the loop). The header says “your person handed you a question from
14
+ another assistant” (or “answered, and handed you …”), never “your person answered”, and the line to tell the person
15
+ first is “You handed me a question from …”.
16
+ - Which session hears one (`src/handed.ts`), the same for every app: a session that goes by its name in its folder
17
+ (the task the person picked; the most recent of several), else the session in that folder that used Pending You
18
+ most recently, never the one that asked it (a session that goes by the asking assistant's name), and never two: the
19
+ session that takes it claims it first in `state.json` (`claimHanded`), and gives it back if it couldn't be told. A
20
+ question with no folder (a task that never said one) goes to a session by its name anywhere, else the app's most
21
+ recent. A sign-in that only hears (`--oauth`) hears every computer's questions, so it leaves one whose `machine`
22
+ (the computer Pending You says it went to, as the Assistants page names it) is another computer than this one, by
23
+ its name or its hostname (the hostname, the names its code sign-ins were given, a Mac's own name, and, outside a
24
+ hook, Tailscale's); one with no `machine` goes by the folder. The hooks hand a session one at once when it goes by that name (or it's the session the listeners would
25
+ wake); another session in the folder only after 5 minutes, so the one it went to hears it first.
26
+ - Claude Code's wake mod wakes the session it's for: while a session used Pending You in the last 12 hours (as long as
27
+ Pending You offers its task as running), it asks `list_pending` with the session's name every minute for the first
28
+ half hour, then every 3 minutes; reads each new one with `get_request` (who asked, how its answer travels, whether
29
+ there's a note); and starts one turn, “Pending You: your person handed you “…” (req_…), a question from … Call
30
+ get_request (requestId req_…, name “infra”) to read it and their note. If you know, answer_delegated …; if not,
31
+ hand_back …”, never with the note. It claims each in its store (`handed:<requestId>`), so another session by the same
32
+ name doesn't hear it too, and never wakes a session for one it read or answered itself. Among sessions by one name,
33
+ the one working in the folder of the task it went to (`list_pending`'s `cwd`: the folder the session started in, or
34
+ one it gave its own cards, `~/…` or full) takes it, one elsewhere never does, and one that can't tell its folder
35
+ waits 5 minutes. The hand-off hook that
36
+ runs for the mod's prompt claims it in `state.json` without saying it twice. `answer_delegated` and `hand_back` are
37
+ among the calls it reads. Regenerated into the plugin's builds too, which reach installed plugins with the next guide
38
+ release.
39
+ - Codex: one listener for the app, `pendingyou listen --app codex --handed` (detached, a lease of its own), started by
40
+ the hooks while any thread may be handed a question, `codex queue`s each into the thread it's for, in the wake mod's
41
+ words, and gives it back (forgetting the thread) when Codex doesn't know the thread. Codex's hooks now remember each
42
+ thread's names and folder (`codex-threads.json`), and keep a thread for 12 hours after it last used the card tools,
43
+ with no card of its own waiting; a session start or message marks it active. A thread's own listener is never woken
44
+ for its own card while it's with another assistant.
45
+ - OpenCode and Pi: a session's listener also wakes it for a question handed to it, and keeps listening for one for 12
46
+ hours after the session last used the card tools (`opencode-threads.json`, `pi-threads.json`), while the app is
47
+ open. OpenCode's plugin starts it with each message while the record says so, and passes the session's folder to
48
+ `posted`; Pi's extension starts it as before, now for that too. Both start a listener again when a card call asked for
49
+ one while the last was on its way out: a card posted just as a session's first listener stopped (finding nothing to
50
+ listen for) went unheard until the next message.
51
+ - `watch` names a handed question's event `delegated_ready` (`PENDINGYOU_EVENT`), with `delegated` in the JSON on
52
+ stdin.
53
+ - The Stop check never asks a helper to post the question as a card of its own. `answer_delegated` and `hand_back`
54
+ count as the turn's card calls (Claude Code's transcript, OpenCode's turn, Pi's extension); a turn a handed
55
+ question's wake started (its first message the wake's words: Claude Code's transcript, OpenCode's turn, Pi's extension
56
+ says `handed`, Codex's next-message hook records it when Codex gives the message) that left the person something in
57
+ chat without either is asked once: “Your person handed you that question to answer, not to ask them back:
58
+ answer_delegated if you know, or hand_back with what you checked, unless you already did. Never post it as a card of
59
+ your own.” Codex's PostToolUse hook is trusted by its exact matcher, which names only the four card tools, so it never
60
+ sees those two calls (a new matcher would have to be trusted again).
61
+ - The package names no one, and carries only what runs. Its JavaScript is built without the source's comments, and so is
62
+ the wake mod's (`mod/hooks`); its examples and this changelog use made-up names (`--name build-01`); and the mod's
63
+ test, which only `claude plugin test` reads, stays out of the package. Nothing the command line does changes.
64
+
3
65
  ## 0.13.0 (2026-10-04)
4
66
 
5
- - Claude Code's permission prompts on a card (Darin, 2026-10-04: "Tell me: a card and a push, cleared once I answer in
6
- the terminal"). A Claude Code agent kept asking to remove files and sat waiting until Darin happened to look. `init`
7
- now adds Claude Code's PermissionRequest hook (https://code.claude.com/docs/en/hooks: it runs as Claude Code is about
8
- to ask for permission to use a tool, with `tool_name` and `tool_input`), through the shim with `--app claude-code`.
67
+ - Claude Code's permission prompts on a card: a card and a push, cleared once you answer in the terminal. A Claude Code
68
+ agent kept asking to remove files and sat waiting until its person happened to look. `init` now adds Claude Code's
69
+ PermissionRequest hook (https://code.claude.com/docs/en/hooks: it runs as Claude Code is about to ask for permission
70
+ to use a tool, with `tool_name` and `tool_input`), through the shim with `--app claude-code`.
9
71
  It never decides: it prints nothing, so the prompt shows exactly as before, and it's done in about a tenth of a
10
72
  second. It writes the prompt down (its card's redacted words, never the tool's input) in the session's own file,
11
73
  `~/.config/pendingyou/permission-cards/<session>.json`, and starts that session's one worker, detached (`pendingyou
@@ -67,8 +129,8 @@
67
129
  approve: 1. whoami with your name. If it says app.setUp is true, another session did this: stop. 2. report_setup with
68
130
  source codex, your name, skillSaved true, hears instant, and skillVersion set to whoami’s skill.latest. Its answer
69
131
  has a testAreaId. 3. post_request a short test question in that testAreaId, tell your person it’s on its way, and end
70
- your turn: you’re woken when they answer, so don’t ask them to type anything.” Pi's first setup on Darin's devbox
71
- spent about 20,000 tokens on the words before: “whoami with your name and guide true” answered with the whole guide
132
+ your turn: you’re woken when they answer, so don’t ask them to type anything.” Pi's first live setup spent about
133
+ 20,000 tokens on the words before: “whoami with your name and guide true” answered with the whole guide
72
134
  (87,000 characters), which Pi cut into a file the model then parsed, and “your test question in its testAreaId” sent
73
135
  it looking for testAreaId in whoami's answer, where it isn't (report_setup's has it). A setup that has reported gets
74
136
  the same calls without whoami's way out (its whoami says app.setUp is true); a test card answered says who picks it
@@ -144,7 +206,7 @@
144
206
 
145
207
  ## 0.11.0
146
208
 
147
- - One step for the coding agents on a computer (Darin, 2026-10-04): `npx -y pendingyou@latest init` finds Claude Code
209
+ - One step for the coding agents on a computer: `npx -y pendingyou@latest init` finds Claude Code
148
210
  and Codex (`--app claude-code,codex` names some), says what it found and which it can't set up (an agent too old for
149
211
  it is named, with the version it needs), and sets each up with one approval. Then: restart the agent, and its first
150
212
  session finishes setting up by itself (whoami, report_setup, a test card). Asked for by name and not installed, or
@@ -222,10 +284,10 @@
222
284
  - The next-message hook looks for this folder's answers with every message, not only while a hold had left an entry
223
285
  behind: one request, as before, given up after 2 seconds, inside the hook's 3-second deadline. `state.json` no
224
286
  longer keeps holds' waits.
225
- - A computer with no browser (a devbox or server you SSH into): one code approved on your phone sets Claude Code up.
287
+ - A computer with no browser (a server you SSH into): one code approved on your phone sets Claude Code up.
226
288
  `/mcp`'s Authenticate opened a browser on that machine, which nobody could reach, and `login --device` only signed
227
289
  this command in. Now `init` there (an SSH session, or Linux with no display; `--device` forces it, `--browser` the
228
- browser) signs the machine in by code as a Claude Code connection of its own ("Claude Code on devbox" on your
290
+ browser) signs the machine in by code as a Claude Code connection of its own ("Claude Code on build-01" on your
229
291
  Assistants page, named by `--name`, else Tailscale's name for it, else its hostname; `login --name` with a new name
230
292
  renames the connection with one more approval, which `login` suggests for a cloud's own name like `ip-10-42-1-252`;
231
293
  QR code of the link at a terminal), then adds the MCP server with a `headersHelper` instead of OAuth
@@ -239,8 +301,8 @@
239
301
  PATH), each with the command that fixes it, `--origin` included off production. `uninstall` takes the helper out and
240
302
  says the connection stays on your Assistants page. Needs Pending You's Worker from the same release.
241
303
  - `login` over SSH signs in by code without `--device`. A sign-in by code names the computer, and the phone asks "Let
242
- devbox hear your answers for the assistants running on it?"; in the browser, "Let this computer hear…". Neither offers
243
- a choice of assistant any more (Darin, 2026-10-04): a computer hears for the assistants running on it. A sign-in
304
+ build-01 hear your answers for the assistants running on it?"; in the browser, "Let this computer hear…". Neither
305
+ offers a choice of assistant any more: a computer hears for the assistants running on it. A sign-in
244
306
  pointed at one assistant before then keeps hearing it, so `watch` for a Muse or another app can't be set up anew.
245
307
  - Routing by sender: the hooks and `hold` ask Pending You for Claude Code's cards only (`source=claude-code`), so a card
246
308
  Codex or another app posted from the same folder never reaches a Claude Code session, even with a sign-in pointed at
package/README.md CHANGED
@@ -27,7 +27,8 @@ Run it once on each computer, in a terminal. It finds the agents on the computer
27
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
28
  ([below](#when-claude-code-waits-for-your-ok)),
29
29
  - and wakes it when you answer: Claude Code 2.1.287 or later through the wake mod, Codex through `codex queue`, OpenCode
30
- through its plugin and Pi through its extension (which also do the hooks' work there).
30
+ through its plugin and Pi through its extension (which also do the hooks' work there); and when you hand it a
31
+ question from another of your assistants ([below](#when-you-hand-an-agent-a-question)).
31
32
 
32
33
  Then it says what to do next, and the one sentence to say if it's needed:
33
34
 
@@ -111,7 +112,8 @@ it with `codex queue`: “Pending You: your person answered “…” (req_…).
111
112
  answer itself never goes in a command's arguments, where other programs could see it). Codex starts a turn at
112
113
  once when the thread is open and idle, after the current one when it's working, or when you next open it. In the Codex
113
114
  app, a chat it has open is woken while the app runs (one it let go, idle 3 hours or behind 10 newer idle chats, when
114
- you open it again).
115
+ you open it again). One more listener, for the whole app, queues a question you hand a thread from another assistant
116
+ ([below](#when-you-hand-an-agent-a-question)).
115
117
 
116
118
  ## OpenCode
117
119
 
@@ -139,7 +141,8 @@ does in OpenCode what the hooks do elsewhere, through the same hooks' script: ea
139
141
  reminder and any answer waiting (hidden in OpenCode's view, read by the model), a session's first message also says
140
142
  what setup has left, a session that left you a question in chat is asked once to put it on a card (with a toast saying
141
143
  why), and when you answer a card a session posted, the plugin starts a turn in that session: “Pending You: your person
142
- answered “…” (req_…). Call get_request …” (never the answer itself). That works while OpenCode is open, idle or busy.
144
+ answered “…” (req_…). Call get_request …” (never the answer itself). That works while OpenCode is open, idle or busy,
145
+ and for a question you hand the session from another assistant too ([below](#when-you-hand-an-agent-a-question)).
143
146
 
144
147
  Then restart OpenCode (`/exit`, then `opencode --continue`): it finishes setting up the first time you write to it.
145
148
 
@@ -167,7 +170,9 @@ Pi has no hooks, so `init` gives it an extension: `~/.config/pendingyou/pi/pendi
167
170
  the same hooks' script as a session starts, with each message, after Pending You's card tools and as a run ends (a run
168
171
  that leaves you something only in chat goes on once), and when you answer a card a session posted it starts a turn in
169
172
  that session, idle or busy: “Pending You: your person answered “…” (req_…). Call get_request …” (never the answer
170
- itself). In Pi's terminal the first session finishes setting up by itself, once Pending You's tools are there.
173
+ itself), and the same for a question you hand it from another assistant
174
+ ([below](#when-you-hand-an-agent-a-question)). In Pi's terminal the first session finishes setting up by itself, once
175
+ Pending You's tools are there.
171
176
 
172
177
  Then run `/reload` in each Pi session that's open, or start `pi`. Pi's shell waits for a command to end, so in Pi
173
178
  `npx pendingyou hold` answers at once instead of waiting.
@@ -208,6 +213,13 @@ replied on a card comes from Codex's own hook for Pending You's tools in that tu
208
213
  action card titled “Confirm in Codex on <computer>: <step>” when it asked you to type something there. OpenCode's plugin
209
214
  and Pi's extension (0.12.0) do the same in theirs.
210
215
 
216
+ A question you hand an agent from another assistant (0.14.0) is never asked to go on a card of its own: answering it
217
+ (`answer_delegated`) or giving it back (`hand_back`) puts it in front of you, so it counts as the turn's card call. A
218
+ turn that such a question started, and that left you something in chat without doing either, is asked once to answer
219
+ it or hand it back instead (about 45 tokens). Codex's hook for Pending You's tools is trusted by its exact list of
220
+ tools, which doesn't name those two, so there the check knows only that the turn started with one (when Codex gives the
221
+ message to its next-message hook), and says “unless you already did”.
222
+
211
223
  **On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
212
224
  `npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
213
225
 
@@ -269,10 +281,38 @@ allowed and the server has connected). When it isn't, it starts one turn once th
269
281
  Pending You: it’s connected here already.” and exactly what to do. Once per session, in at most three sessions, never
270
282
  two within ten minutes, and never again once Pending You says it's set up.
271
283
 
284
+ ## When you hand an agent a question
285
+
286
+ In Pending You you can hand a card to another of your assistants, a coding task included (Delegate: the card's link,
287
+ or D on the desk): it answers for you (“Answer freely”), or its answer comes back to you first (“Keep me in the loop”),
288
+ or it hands the card back with what it checked. Since 0.14.0 the task you picked hears it the way it hears your
289
+ answers, once the 5-second window to undo is over:
290
+
291
+ - **Which session**: the one that goes by the task's name in its folder (the most recent, when several do); else the
292
+ session in that folder that used Pending You most recently; never the one that asked, and never two. A sign-in that
293
+ only hears (`--oauth`) hears every computer's, so it leaves one Pending You says went to another computer (by its
294
+ name or its hostname); one that names no computer goes by the folder.
295
+ - **Claude Code**: the wake mod looks for questions handed to the session's name (`list_pending`, every minute while
296
+ the session used Pending You in the last half hour, then every 3 minutes, for 12 hours), reads each with
297
+ `get_request`, and starts a turn: “Pending You: your person handed you “Which AWS account for staging?” (req_…), a
298
+ question from billing-webhooks (answer freely: your answer goes straight to billing-webhooks). Call get_request
299
+ (requestId req_…, name “infra”) to read it and their note. If you know, answer_delegated with your answer and how you
300
+ know; if not, hand_back with what you checked.” Among sessions by one name, the one working in the task's folder
301
+ takes it (as `list_pending` gives it, `~/…` or a full path), one in another folder never does, and one that can't
302
+ tell its folder waits 5 minutes.
303
+ - **Codex**: one listener for the app queues it into the thread it's for (`codex queue`, the same words).
304
+ - **OpenCode and Pi**: the session's listener starts the turn through the plugin or extension; a session that used
305
+ Pending You's card tools keeps listening for 12 hours, while the app is open.
306
+ - **Otherwise**: a session start or your next message in that folder brings it, with your note: at once in the
307
+ session that goes by the task's name, after 5 minutes in any other.
308
+
309
+ Your note never goes in a program's arguments, where any other program on the computer could read it: the agent reads
310
+ it with `get_request`.
311
+
272
312
  ## A computer with no browser
273
313
 
274
- On a devbox or server you reach over SSH, an agent's own sign-in opens a browser on that machine, which you can't
275
- reach. `init` signs in with a code there instead: SSH in, and at the prompt there run
314
+ On a computer you reach over SSH (a build server, a machine in the cloud), an agent's own sign-in opens a browser on
315
+ that machine, which you can't reach. `init` signs in with a code there instead: SSH in, and at the prompt there run
276
316
 
277
317
  ```sh
278
318
  npx -y pendingyou@latest init
@@ -289,15 +329,15 @@ hooks find Node anyway when the agents run without your shell's PATH (a systemd
289
329
  Remote Control): on PATH, nvm's default, the one `init` ran with, or where Node usually is.
290
330
 
291
331
  The machine is called by its Tailscale name when Tailscale is installed and running (as `tailscale status` shows it),
292
- else by its hostname, or by `--name devbox` on `init` or `login`; a Mac by its own name (Sharing in System Settings). A
293
- cloud's own hostname (AWS's `ip-10-42-1-252`) is kept when there's nothing better, and `login` says how to change it:
332
+ else by its hostname, or by `--name build-01` on `init` or `login`; a Mac by its own name (Sharing in System Settings).
333
+ A cloud's own hostname (AWS's `ip-10-42-1-252`) is kept when there's nothing better, and `login` says how to change it:
294
334
  `npx pendingyou login --name <name>` asks for one more approval and renames the connection, which stays the same one.
295
335
  If an agent's sign-in ends (30 days, or you remove it on your Assistants page), `npx pendingyou login` (`--app codex`
296
336
  for Codex's) approves a new code and keeps the same connection.
297
337
 
298
338
  Only want this command's own sign-in (for `watch`, below)? `npx pendingyou login --device` signs it in with a code,
299
- hearing answers only, and adds no connection. Your phone asks "Let devbox hear your answers for the assistants running
300
- on it?", with no choice of assistant.
339
+ hearing answers only, and adds no connection. Your phone asks "Let build-01 hear your answers for the assistants
340
+ running on it?", with no choice of assistant.
301
341
 
302
342
  ## Always-on scripts
303
343
 
@@ -309,10 +349,11 @@ npx pendingyou watch -- ./on-answer.sh
309
349
 
310
350
  It hears your Claude Code assistants' answers. Signing in asks no choice of assistant (since 2026-10-04: "Let pi hear
311
351
  your answers for the assistants running on it?"); a sign-in pointed at one assistant before then keeps hearing that
312
- one. The command runs without a shell, once per answer, message or fallback that comes due, oldest first. It
313
- gets the request as JSON on stdin, and `PENDINGYOU_REQUEST_ID`, `PENDINGYOU_EVENT` (`answer_ready`, `message_ready` or
314
- `fallback_due`), `PENDINGYOU_VERSION` and `PENDINGYOU_ORIGIN` in its environment. A failing command is tried twice
315
- more, then skipped. `--once` stops after the first.
352
+ one. The command runs without a shell, once per answer, message or fallback that comes due, or (0.14.0) question you
353
+ hand the assistant from another one, oldest first. It gets the request as JSON on stdin (a handed question with
354
+ `delegated`: who asked, how its answer travels, your note), and `PENDINGYOU_REQUEST_ID`, `PENDINGYOU_EVENT`
355
+ (`answer_ready`, `message_ready`, `fallback_due` or `delegated_ready`), `PENDINGYOU_VERSION` and `PENDINGYOU_ORIGIN` in
356
+ its environment. A failing command is tried twice more, then skipped. `--once` stops after the first.
316
357
 
317
358
  ## Commands
318
359
 
@@ -349,20 +390,24 @@ reminded an unfinished setup, and when; `permission-cards/` each Claude Code ses
349
390
  and its card (ids, hashes and the card's redacted words: never what the tool was given), gone once nothing waits;
350
391
  `claude-code.json`, `codex.json`, `opencode.json` and `pi.json` what `init`
351
392
  added; `codex-threads.json`, `opencode-threads.json` and `pi-threads.json` which cards each Codex thread or OpenCode or
352
- Pi session waits on (ids, names and titles: never an answer); `pi/` Pi's extension; and `cli/` the hooks' copy of the
393
+ 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
394
+ (ids, names, folders and titles: never an answer or a note); `pi/` Pi's extension; and `cli/` the hooks' copy of the
353
395
  command line. `init`, `status` and the hooks read `~/.claude.json` (and a folder's `.mcp.json`, and
354
396
  `~/.claude/settings.json`'s enabled plugins), `~/.codex/config.toml`, OpenCode's config and Pi's `mcp.json` only to see
355
- whether the agent has a `pendingyou` MCP server and where it points. For a permission prompt's card, the worker reads
397
+ whether the agent has a `pendingyou` MCP server and where it points. With a sign-in that only hears, the hooks and
398
+ listeners tell this computer's handed questions from another's by its names: its hostname, the names its sign-ins by
399
+ 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
356
400
  the end of the Claude Code session's transcript only for the name the session gave Pending You, and `git remote
357
401
  get-url origin` in its folder (sent without any user name or password in it). Nothing is sent anywhere but Pending
358
402
  You. OpenCode's plugin reads its session's messages only when it goes idle, for the check that
359
403
  nothing was left in chat, and hands the hooks' script just that turn's words and tool names.
360
404
 
361
405
  The wake mod runs inside Claude Code and reaches Pending You only through the session's own connection, with
362
- `get_request` on the session's own cards (and, in `init`'s copy, `whoami` to see whether setup is finished). It keeps
363
- each session's cards (id, title, the agent's name, where each stands) in Claude Code's store for mods,
364
- `~/.claude/plugins/store/`, and forgets them once they close or after 7 days. It marks the copy acting for a session in
365
- that session's environment (`PENDINGYOU_WAKE_OWNER`).
406
+ `get_request` on the session's own cards (and, in `init`'s copy, `whoami` to see whether setup is finished), and (0.14.0)
407
+ `list_pending` with the session's name and `get_request` on a question you handed it. It keeps each session's cards (id,
408
+ title, the agent's name, where each stands), and which handed questions a session was told of (ids and when), in Claude
409
+ 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`).
366
411
 
367
412
  ## Development
368
413
 
package/dist/api.js CHANGED
@@ -1,32 +1,11 @@
1
- // Calls to Pending You with the command line's sign-in: a fresh access token (refreshed under the credentials lock,
2
- // so a hold and a hook never spend the same refresh token), one retry after a 401, and plain errors.
3
- //
4
- // Hooks never refresh in their own process. A hook has a hard deadline (main.ts), and a refresh cut off halfway can
5
- // end the sign-in: the server rotates the refresh token as it answers, and keeps exactly one earlier token valid
6
- // (@cloudflare/workers-oauth-provider's previousRefreshTokenId). A refresh abandoned before its answer arrives is
7
- // harmless on its own (the stored token is that earlier one, and still works), but if the server finishes it after a
8
- // later refresh has stored a newer token, that newer token stops working and the next refresh gets invalid_grant. So
9
- // a hook that needs a new token starts `pendingyou refresh` detached (background), which runs to the end under the
10
- // lock whatever happens to the hook, and waits for it only briefly; until it lands the hook uses the token it has, or
11
- // gives up and prints its plain fallback.
12
- //
13
- // Since 0.11.0 each app has its own sign-in (credentials.ts), and every call names the app it's for: its sign-in is
14
- // the one used, and a session's request names it as `source` (routing by sender, 2026-10-04), so Pending You returns
15
- // only that app's cards, never one Codex posted from the same folder to a Claude Code session there. `watch` names no
16
- // app: it hands answers to a script, not a session, with Claude Code's sign-in as before.
17
1
  import { DEFAULT_APP } from "./apps/ids.js";
18
2
  import { credentialsPath, readCredential, updateCredential, } from "./credentials.js";
19
3
  import { isLocked } from "./files.js";
20
4
  import { OAuthError, refresh } from "./oauth.js";
21
- /** Refresh this long before the access token runs out. */
22
5
  const EARLY_MS = 60_000;
23
- /** A hook starts a background refresh this long before the access token runs out, and goes on with the old one. */
24
6
  export const SOON_MS = 5 * 60_000;
25
- /** How long a hook waits for a background refresh when it has no working token. */
26
7
  export const REFRESH_WAIT_MS = 1200;
27
- /** An access token with less than this left isn't worth sending. */
28
8
  const USABLE_MS = 10_000;
29
- /** Not signed in on this computer (for this origin), or the sign-in ended: the person runs `pendingyou login`. */
30
9
  export class SignInNeeded extends Error {
31
10
  name = 'SignInNeeded';
32
11
  ended;
@@ -35,26 +14,19 @@ export class SignInNeeded extends Error {
35
14
  this.ended = ended;
36
15
  }
37
16
  }
38
- /** Pending You couldn't be reached, or had a problem: worth trying again later. */
39
17
  export class Unavailable extends Error {
40
18
  name = 'Unavailable';
41
- /** Seconds the server asked to wait, when it said. */
42
19
  retryAfter;
43
20
  constructor(message, retryAfter) {
44
21
  super(message);
45
22
  this.retryAfter = retryAfter;
46
23
  }
47
24
  }
48
- /**
49
- * Refreshes the sign-in under the lock unless it no longer needs it: a token with more than `earlyMs` left (or, when
50
- * `force`d after a 401, a refresh token another process already replaced) is used as it is.
51
- */
52
25
  async function refreshStored(io, origin, stored, options, app) {
53
26
  let ended = false;
54
27
  const updated = await updateCredential(io, origin, async (current) => {
55
28
  if (!current)
56
29
  return null;
57
- // Another process may have refreshed while this one waited for the lock.
58
30
  if (current.refreshToken !== stored.refreshToken && current.expiresAt - EARLY_MS > io.now())
59
31
  return current;
60
32
  if (!options.force && current.expiresAt - options.earlyMs > io.now())
@@ -84,7 +56,6 @@ async function freshToken(io, origin, force, app) {
84
56
  return stored.accessToken;
85
57
  return (await refreshStored(io, origin, stored, { force, earlyMs: EARLY_MS }, app)).accessToken;
86
58
  }
87
- /** What a process starts to refresh an app's sign-in in the background: `--app` only for an app but Claude Code. */
88
59
  export const refreshArgs = (origin, app, force = false) => [
89
60
  'refresh',
90
61
  '--origin',
@@ -92,10 +63,6 @@ export const refreshArgs = (origin, app, force = false) => [
92
63
  ...(app === DEFAULT_APP ? [] : ['--app', app]),
93
64
  ...(force ? ['--force'] : []),
94
65
  ];
95
- /**
96
- * `pendingyou refresh`, what a hook starts in the background: refreshes the sign-in when it runs out within SOON_MS
97
- * (or `force`, after a 401), under the lock, to the end. Quiet; true when there's a sign-in afterwards.
98
- */
99
66
  export async function refreshInBackground(io, origin, force, app = DEFAULT_APP) {
100
67
  const stored = await readCredential(io, origin, app);
101
68
  if (!stored)
@@ -105,7 +72,6 @@ export async function refreshInBackground(io, origin, force, app = DEFAULT_APP)
105
72
  await refreshStored(io, origin, stored, { force, earlyMs: SOON_MS }, app);
106
73
  return true;
107
74
  }
108
- /** A hook's token: never refreshed here (see the top of this file). */
109
75
  async function hookToken(io, origin, force, signal, app) {
110
76
  const stored = await readCredential(io, origin, app);
111
77
  if (!stored)
@@ -113,10 +79,8 @@ async function hookToken(io, origin, force, signal, app) {
113
79
  const left = stored.expiresAt - io.now();
114
80
  if (!force && left > SOON_MS)
115
81
  return stored.accessToken;
116
- // One refresher at a time: a lock held now is a refresh (or a write) already under way.
117
82
  if (!(await isLocked(credentialsPath(io))))
118
83
  io.background(refreshArgs(origin, app, force));
119
- // Still good for a few seconds: this request goes with it, the next one with the new token.
120
84
  if (!force && left > USABLE_MS)
121
85
  return stored.accessToken;
122
86
  const until = io.now() + REFRESH_WAIT_MS;
@@ -130,14 +94,6 @@ async function hookToken(io, origin, force, signal, app) {
130
94
  }
131
95
  throw new Unavailable('The sign-in is being refreshed in the background.');
132
96
  }
133
- /**
134
- * The token `pendingyou mcp-headers` hands an app's MCP server (remote.ts), and the stdio bridge sends with each
135
- * message (bridge.ts). The app runs the helper each time it connects and gives it 10 seconds, and the bridge may be
136
- * stopped at any moment, so, as for a hook, a refresh is never made in this process: within 5 minutes of expiry it
137
- * starts `pendingyou refresh` detached and waits for its token, up to `waitMs`, then uses the old one while it still
138
- * works. A token handed over has at least 10 seconds left; the app runs the helper again on a 401. `force` (the bridge,
139
- * after a 401) refreshes whatever the expiry says, and hands over only the new token.
140
- */
141
97
  export async function helperToken(io, origin, waitMs, app = DEFAULT_APP, force = false) {
142
98
  const stored = await readCredential(io, origin, app);
143
99
  if (!stored)
@@ -159,7 +115,6 @@ export async function helperToken(io, origin, waitMs, app = DEFAULT_APP, force =
159
115
  return stored.accessToken;
160
116
  throw new Unavailable('Pending You couldn’t be reached to refresh the sign-in.');
161
117
  }
162
- /** Aborts on `signal` or after `ms`, whichever comes first (AbortSignal.any needs Node 20.3). */
163
118
  function either(signal, ms) {
164
119
  const timeout = AbortSignal.timeout(ms);
165
120
  if (!signal)
@@ -172,11 +127,6 @@ function either(signal, ms) {
172
127
  timeout.addEventListener('abort', abort, { once: true });
173
128
  return controller.signal;
174
129
  }
175
- /**
176
- * GETs `path` on `origin` as the command line, with an app's sign-in (Claude Code's unless `app` says), and returns the
177
- * JSON body with its status. 401 after a refresh means the sign-in is over (revoked, or 30 days passed). Network
178
- * failures and 5xx are `Unavailable`.
179
- */
180
130
  export async function getJson(io, origin, path, timeoutMs, signal, options = {}) {
181
131
  const app = options.app ?? DEFAULT_APP;
182
132
  for (let attempt = 0;; attempt++) {
@@ -1,16 +1,3 @@
1
- // Claude Code (0.11.0's module of what claude.ts carries out): what `pendingyou init` decides before anyone signs in,
2
- // what it does after, `status`'s section and the lines init ends with.
3
- //
4
- // Its MCP server signs in through this computer's own connection ("Claude Code on devbox"), by the headers helper, on
5
- // every computer but Windows (where Claude Code runs a headersHelper through cmd) or with `--oauth`; the hooks use the
6
- // same connection, so one approval covers both. A pendingyou server that's there decides the rest, before signing in:
7
- // - ours, through the helper, at this Pending You: kept.
8
- // - one at this Pending You that signs in by itself (Claude Code's own /mcp → Authenticate), or no server beside an
9
- // enabled Pending You plugin for it (whose server is OAuth): with no browser here it's switched without a word (as
10
- // 0.10.0 did: that sign-in can't finish here); at a terminal init asks, Yes by default; `--yes` switches; with nobody
11
- // to ask it's kept, and init says how to switch. Kept, Claude Code signs in by itself and the hooks get a sign-in
12
- // that only hears; kept beside the plugin, no skill is saved either: the plugin carries it.
13
- // - one at another Pending You: replaced once the person says so (or `--yes`), or else Claude Code is left alone.
14
1
  import { addCommand, claudeState, helperServer, installClaude, mcpFromClaude, mcpFromFile, PLUGIN_ORIGINS, permissionBlocker, pluginFor, readManifest, removeCommand, sameUrl, uninstallClaude, } from "../claude.js";
15
2
  import { connectionMachine, readCredential } from "../credentials.js";
16
3
  import { differs, serverForFolder, siteOf } from "../environment.js";
@@ -21,12 +8,7 @@ import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
21
8
  import { shimPath } from "../shim.js";
22
9
  import { APP_NAMES } from "./ids.js";
23
10
  const NAME = APP_NAMES['claude-code'];
24
- /** The sentence's lead-in, and what restarting means, in init's last lines. */
25
11
  const RESTART = 'restart Claude Code here (/exit, then claude --continue), or start it in the folder you work in';
26
- /**
27
- * Whether to move a server that signs in by itself to this computer's sign-in: without a word where there's no
28
- * browser, `--yes`, or Yes (the default) at a terminal; kept, with nobody to ask.
29
- */
30
12
  async function switches(io, ctx, what) {
31
13
  if (ctx.headless || ctx.yes)
32
14
  return true;
@@ -36,7 +18,6 @@ async function switches(io, ctx, what) {
36
18
  const answer = await io.ask('Switch Claude Code to this computer’s sign-in? [Y/n] ');
37
19
  return !/^\s*no?\s*$/i.test(answer);
38
20
  }
39
- /** The plan for a server at another Pending You: replaced once the person says so, or Claude Code left alone. */
40
21
  async function replaces(io, ctx, server, url) {
41
22
  ctx.progress(`Claude Code’s pendingyou MCP server points at ${server.url}, not ${url}.\n`);
42
23
  if (ctx.yes)
@@ -92,7 +73,6 @@ export const claudeCode = {
92
73
  };
93
74
  }
94
75
  else if (server?.url && !sameUrl(server.url, url)) {
95
- // Another Pending You (staging, when production was asked for): asked before anyone signs in.
96
76
  if (!(await replaces(io, ctx, server, url))) {
97
77
  const commands = ` ${removeCommand(server.scope)}\n ${add}`;
98
78
  return {
@@ -123,7 +103,6 @@ export const claudeCode = {
123
103
  else if (ours)
124
104
  plan = { action: 'switch', server };
125
105
  else if (server.helper) {
126
- // Someone else's headersHelper: theirs to change. The hooks hear through a sign-in of their own.
127
106
  mode = 'oauth';
128
107
  plan = helperMode
129
108
  ? {
@@ -152,7 +131,6 @@ export const claudeCode = {
152
131
  }
153
132
  }
154
133
  else if (plugin) {
155
- // No server added by hand, so the Pending You plugin's is Claude Code's: one added by hand would win over it.
156
134
  if (helperMode &&
157
135
  (await switches(io, ctx, 'The Pending You plugin signs Claude Code in to Pending You by itself here.')))
158
136
  plan = { action: 'add', plugin: true };
@@ -224,10 +202,6 @@ export const claudeCode = {
224
202
  return claudeStatus(io, ctx);
225
203
  },
226
204
  };
227
- /**
228
- * status's line for the permission-prompt hooks (0.13.0): on; off, as the person chose; or why Claude Code here can't
229
- * have them. Never a reason status isn't Ready.
230
- */
231
205
  function permissionLine(io, claude, manifest, helper, flag) {
232
206
  const label = 'Permission prompts:';
233
207
  if (claude.permission)
@@ -241,7 +215,6 @@ function permissionLine(io, claude, manifest, helper, flag) {
241
215
  ? ` ${label} no card when Claude Code waits for your OK: ${blocker}`
242
216
  : ` missing ${label} no card when Claude Code waits for your OK; run npx pendingyou@latest init${flag}`;
243
217
  }
244
- /** Claude Code's section of `pendingyou status`: what's set up and what's missing, each with the command that fixes it. */
245
218
  async function claudeStatus(io, ctx) {
246
219
  const { origin, signIn } = ctx;
247
220
  const [claude, here, manifest] = await Promise.all([
@@ -252,8 +225,6 @@ async function claudeStatus(io, ctx) {
252
225
  const credential = await readCredential(io, origin);
253
226
  const flag = originArgs(origin);
254
227
  const mcp = claude.mcp;
255
- // Through the helper (this computer's own sign-in): Claude Code's server runs ours, init set it up so, or nothing is
256
- // set up yet and init would (everywhere but Windows).
257
228
  const helped = usesHelper(mcp, origin);
258
229
  const helper = io.platform !== 'win32' &&
259
230
  (helped ||
@@ -267,7 +238,6 @@ async function claudeStatus(io, ctx) {
267
238
  const url = `${origin}/mcp`;
268
239
  const plugin = claude.plugins.find((id) => siteOf(PLUGIN_ORIGINS[id] ?? '') === siteOf(origin));
269
240
  const sameServer = mcp !== false && mcp !== 'unknown' && (!mcp.url || mcp.url.replace(/\/+$/, '') === url);
270
- // The plugin's server is Claude Code's when nobody added one by hand.
271
241
  const pluginServer = mcp === false && Boolean(plugin);
272
242
  const mcpOk = helper ? sameServer && helped : sameServer || pluginServer;
273
243
  const mcpText = mcp === 'unknown'
@@ -287,7 +257,6 @@ async function claudeStatus(io, ctx) {
287
257
  : `pendingyou (${mcp.url}) signs in through a browser${why ? ', which can’t finish on this computer' : ''}; run npx pendingyou init${flag}`;
288
258
  const form = claude.form;
289
259
  const hooksRun = form?.runs ?? true;
290
- // The wake mod (0.10.0): Claude Code 2.1.287 or later loads it from the plugin or from CLAUDE_CODE_PLUGIN_DIRS.
291
260
  const mod = claude.mod;
292
261
  const modOk = mod.loads && (mod.plugin || mod.present);
293
262
  const modText = !claude.claude
@@ -311,9 +280,7 @@ async function claudeStatus(io, ctx) {
311
280
  : form.form === 'npx'
312
281
  ? `they run through npx${via}, which is slower; run npx pendingyou@latest init${flag} to run them directly`
313
282
  : 'they run pendingyou from PATH or a file of your own';
314
- // login makes a connection only once Claude Code's server signs in through the helper; init does both.
315
283
  const fix = `npx pendingyou ${helped ? 'login' : 'init'}${flag}`;
316
- // The connection's name as Pending You has it, so status never drifts from it; else this computer's own record.
317
284
  const title = signIn.state === 'ok' && signIn.for
318
285
  ? connectionTitle(signIn.for)
319
286
  : `${NAME} on ${(await connectionMachine(io, origin)) ?? (machineOf(io.host) || 'this computer')}`;
@@ -338,7 +305,6 @@ async function claudeStatus(io, ctx) {
338
305
  : signIn.state === 'none'
339
306
  ? `not signed in; run npx pendingyou login${flag}`
340
307
  : 'Pending You couldn’t be reached';
341
- // The helper as Claude Code runs it at its barest: found Node, and runs pendingyou's copy.
342
308
  const check = helper ? await checkHelper(io, origin) : null;
343
309
  const skillOk = claude.skill || Boolean(plugin);
344
310
  const skillText = claude.skill
@@ -357,17 +323,13 @@ async function claudeStatus(io, ctx) {
357
323
  ` ok Plugin: ${plugin} is enabled${pluginServer ? '' : mcp !== false && mcp !== 'unknown' ? '; Claude Code uses the server added by hand instead of its own' : ''}`,
358
324
  ]
359
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));
360
- // How setup stands on Pending You (from servers that say): never a reason it isn't Ready.
361
326
  const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
362
327
  if (setup)
363
328
  lines.push(` ${setup === 'finished' ? 'ok ' : ' '} Setup: ${setup}`);
364
- // A folder's own server (local or project scope) wins over the user one in Claude Code: say when it's elsewhere. (The
365
- // plugin's server counts only when no server was added by hand, and is production's.)
366
329
  if (here && (here.scope === 'local' || here.scope === 'project') && differs(here, origin))
367
330
  lines.push(` ${mark(false)} This folder: its ${here.scope}-scope pendingyou MCP server (${here.url}) is another Pending You than ${origin}; Claude Code uses it here instead`);
368
331
  const user = mcp !== false && mcp !== 'unknown' && mcp.url ? { url: mcp.url, scope: 'user' } : null;
369
332
  const other = differs(here, origin) ? here : differs(user, origin) ? user : null;
370
- // Through the helper, Claude Code reaches Pending You only with the helper, the connection and the server together.
371
333
  const reaches = !helper || (signOk && mcpOk && check?.ok === true);
372
334
  const ready = reaches && signIn.state === 'ok' && claude.hooks && hooksRun && !other;
373
335
  if (other)
@@ -1,32 +1,14 @@
1
- // The Codex app for macOS (0.13.0; research 2026-10-04): "ChatGPT.app" (bundle id com.openai.codex; 26.928 carries codex
2
- // 0.159.2) has its own codex command line inside it, and shares ~/.codex with Codex CLI: its MCP servers (the headers
3
- // helper too), ~/.agents/skills, ~/.codex/hooks.json and the hooks' trust. Its hooks run with a PATH that ends in its own
4
- // codex, and its private Codex server reads ~/.codex/queue_1.sqlite every 10 seconds, so `codex queue --thread` wakes a
5
- // chat the app has loaded while it's open (an idle chat is let go after 3 hours, or behind 10 newer idle ones; then the
6
- // message waits until the chat is opened again). Where `codex` isn't on PATH, init, status and the listener
7
- // (codex-wake.ts) run the app's own instead: these paths, in /Applications or ~/Applications, under either name.
8
1
  import { stat } from 'node:fs/promises';
9
2
  import { join } from 'node:path';
10
- /** The app's names: the ChatGPT desktop app that carries Codex, and Codex's own, should it ship as that. */
11
3
  const APP_NAMES = ['ChatGPT.app', 'Codex.app'];
12
- /**
13
- * Where a Codex app keeps its own codex: `Contents/Resources/codex-cli/bin/codex`, and the CodexCLI.app inside it that
14
- * the app's own code looks for (`codex-cli/CodexCLI.app/Contents/MacOS/codex`), in /Applications, then ~/Applications.
15
- * `system` is /Applications unless PENDINGYOU_APPLICATIONS_DIR says otherwise (tests: they never look in the real one).
16
- */
17
4
  export function appCodexPaths(home, system = '/Applications') {
18
5
  return [system, join(home, 'Applications')].flatMap((root) => APP_NAMES.flatMap((name) => {
19
6
  const cli = join(root, name, 'Contents', 'Resources', 'codex-cli');
20
7
  return [join(cli, 'bin', 'codex'), join(cli, 'CodexCLI.app', 'Contents', 'MacOS', 'codex')];
21
8
  }));
22
9
  }
23
- /** `codex --version`'s version ("codex-cli 0.159.2"), or "installed" when it can't be read. */
24
10
  const versionOf = (stdout) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? 'installed';
25
11
  const executable = (path) => stat(path).then((info) => info.isFile() && (info.mode & 0o111) !== 0, () => false);
26
- /**
27
- * The codex to run: `codex` on PATH; else, on a Mac, the Codex app's own (the first of `paths` there that answers
28
- * `--version`); null when there's neither.
29
- */
30
12
  export async function findCodex(io, paths = appCodexPaths(io.home, io.env.PENDINGYOU_APPLICATIONS_DIR || undefined)) {
31
13
  const onPath = await io.run('codex', ['--version'], 20_000);
32
14
  if (onPath.code === 0)