pendingyou 0.15.0 → 0.16.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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 (2026-10-05)
4
+
5
+ - A word on one of your own cards that's neither an answer nor your person's (guide 2.32): `/mcp/cli/answers` marks it
6
+ `notice` (`{ kind, from }`), and the hold, the pickup, the hooks and `watch` say what it is instead of “they wrote
7
+ back”:
8
+ - `follow_up`: the helper that answered it for your person followed up afterwards (“shipped in #231”): “Pending You:
9
+ pending-you-chief followed up on req_…, which it answered for your person.”, the question, its words, then “Act on
10
+ it if it needs anything, then call ack_answer … to mark it heard.” On an answer you haven't picked up yet, its
11
+ words come with the answer (“Also: …”).
12
+ - `handed_over`: your person handed your open cards to another assistant: “Pending You: your person handed your open
13
+ cards to pending-you-chief, req_… among them.” and “Stop working on them: pending-you-chief answers them and
14
+ follows up. Call ack_answer … to say you’ve stopped.”
15
+ `watch` gives both as `message_ready`. A command line before 0.16.0 hears the same words as a message from them.
16
+ - Claude Code's permission prompts get a card in bypassPermissions mode too: a dangerous request from a background
17
+ agent there waited minutes for an OK with no card. Claude Code still asks for a few things in that mode (a dangerous
18
+ `rm`, an ask rule, a safety check it can't verify), but the PermissionRequest hook skipped bypassPermissions and
19
+ dontAsk mode outright. It also runs for a call Claude Code then denies by itself, showing
20
+ nothing, so in those two modes it now writes the prompt down quietly, and its card goes up only once a dialog is on
21
+ screen. A new Notification hook says so: `pendingyou-hook notify --app claude-code`, with the matcher
22
+ `permission_prompt`, so Claude Code runs it only for a dialog that has waited about six seconds with nobody typing,
23
+ and never for its other notifications. A call denied without a dialog never gets a card: its PostToolUseFailure, the
24
+ same agent's next call, your next message or the end of the turn settles it. In the other modes a card still goes up
25
+ after 10 seconds, or as soon as the Notification comes.
26
+ - A dialog no PermissionRequest hook ran for (a sandboxed command's network request; one a Claude Code ran none for)
27
+ gets a card from its Notification alone: “Claude Code is waiting for your OK”, with Claude Code's own words, redacted,
28
+ in its summary (“It says: “Claude needs your permission”.”). Whose dialog it was isn't known, so only a turn that ends
29
+ with no subagent running, or the session's end, takes it down.
30
+ - A background agent's prompt says so: “Claude Code is waiting for your OK: pnpm lint && git push (a background
31
+ agent)”, and its summary names the agent's type (“A background agent (general-purpose) of Claude Code
32
+ (billing-webhooks), in ~/… on work-mbp, is waiting for your OK to run a command.”). It stays on the session's one card,
33
+ and only that agent's own calls settle it, not the session's or another agent's.
34
+ - `init` adds the Notification hook at the end of Claude Code's Notification hooks, after any of your own, and says so
35
+ on a computer set up before (“Added a Notification hook to the permission-prompt hooks: …”); `status` marks the
36
+ Permission prompts line missing without it; `uninstall` and `--no-permission-cards` take it out with the others.
37
+ Codex is unchanged: it has no hook that says a dialog is on screen (its `notify` runs only when a turn completes).
38
+
3
39
  ## 0.15.0 (2026-10-05)
4
40
 
5
41
  - Presence: Pending You hears which of your agents' sessions are open right now. Each app tells it with its own
package/README.md CHANGED
@@ -246,6 +246,14 @@ a key are blanked (`curl -H "Authorization: …" https://api.example.com/items?t
246
246
  shown safely (several lines, inline code like `node -e`, one that takes a secret) shows only as “Bash: node …”. No card
247
247
  for `claude -p` or the Agent SDK, where nobody sees a prompt.
248
248
 
249
+ In bypassPermissions mode (`--dangerously-skip-permissions`) Claude Code still asks for a few things: a dangerous `rm`,
250
+ an ask rule of yours, a safety check it can't verify. Since 0.16.0 those get a card too, once Claude Code says the
251
+ dialog has waited a few seconds with nobody typing (its Notification hook, which `init` adds for permission dialogs
252
+ only): a call it denies by itself, showing you nothing, never gets one. The same hook puts up a card for a dialog no
253
+ other hook sees (a sandboxed command asking for the network), in Claude Code's own words, and in the other modes puts
254
+ a card up as soon as Claude Code says so, without waiting the full 10 seconds. A background agent's prompt says so:
255
+ “Claude Code is waiting for your OK: pnpm lint && git push (a background agent)”.
256
+
249
257
  It needs Claude Code 2.1.119 or later, signing in through this computer's sign-in (not `--oauth`), and isn't on
250
258
  Windows yet. Don't want it? `npx pendingyou init --no-permission-cards` (later runs keep it off; `--permission-cards`
251
259
  turns it back on). `npx pendingyou status` says which.
@@ -199,8 +199,10 @@ export const claudeCode = {
199
199
  };
200
200
  function permissionLine(io, claude, manifest, helper, flag) {
201
201
  const label = 'Permission prompts:';
202
- if (claude.permission)
202
+ if (claude.permission && claude.notify)
203
203
  return ` ok ${label} a card when Claude Code waits for your OK`;
204
+ if (claude.permission)
205
+ return ` missing ${label} no card for a dialog in bypassPermissions mode, or one the other hooks don’t see; run npx pendingyou@latest init${flag}`;
204
206
  if (manifest?.permissionCards === false)
205
207
  return ` ${label} off, as you chose; for a card when Claude Code waits for your OK, run npx pendingyou init --permission-cards${flag}`;
206
208
  const blocker = claude.claude
@@ -5,7 +5,7 @@ import { getJson, SignInNeeded, Unavailable } from "../api.js";
5
5
  import { DEFAULT_ORIGIN } from "../args.js";
6
6
  import { connectionMachine, readCredential } from "../credentials.js";
7
7
  import { configDir, readJson, withLock, writeWhole } from "../files.js";
8
- import { clip, delegatedOf, isClosed } from "../format.js";
8
+ import { clip, delegatedOf, isClosed, noticeOf } from "../format.js";
9
9
  import { chooseThread, goesBy, HANDED_FOR_MS, handedCard, handedMessage, namesAnotherComputer, } from "../handed.js";
10
10
  import { macName, tailscaleName } from "../remote.js";
11
11
  import { claimHanded, markHanded, momentOf, releaseHanded, wasHanded } from "../state.js";
@@ -310,6 +310,18 @@ function line(heard, card) {
310
310
  .replaceAll('”', '’')}” (${heard.requestId})`;
311
311
  const named = card.name ?? heard.sessionLabel;
312
312
  const get = `get_request (requestId ${heard.requestId}, ${named ? `name “${named}”` : 'your name'})`;
313
+ const notice = noticeOf(heard);
314
+ const from = notice ? clip(notice.from, 60).text : '';
315
+ if (notice?.kind === 'handed_over')
316
+ return [
317
+ `your person handed your open cards to ${from}, ${titled} among them`,
318
+ `stop working on them: ${from} answers them and follows up; ack_answer ${heard.requestId} with its version to say you’ve stopped`,
319
+ ];
320
+ if (notice?.kind === 'follow_up' && heard.status !== 'answered')
321
+ return [
322
+ `${from} followed up on ${titled}, which it answered for your person`,
323
+ `call ${get} to read it, act on it if it needs anything, then ack_answer with its version to mark it heard`,
324
+ ];
313
325
  switch (heard.status) {
314
326
  case 'expired':
315
327
  return [
@@ -339,7 +351,7 @@ export function wakeMessage(ready) {
339
351
  });
340
352
  if (lines.length === 0)
341
353
  return null;
342
- const asked = lines.some(({ heard }) => heard.turn === 'agent' && heard.status !== 'expired');
354
+ const asked = lines.some(({ heard }) => heard.turn === 'agent' && heard.status !== 'expired' && !noticeOf(heard));
343
355
  const reopen = asked
344
356
  ? ' If they asked you something or you need more from them, reply_in_thread with reopen instead.'
345
357
  : '';
package/dist/args.js CHANGED
@@ -63,6 +63,7 @@ const COMMANDS = new Set([
63
63
  'mcp',
64
64
  'permission',
65
65
  'permission-done',
66
+ 'notify',
66
67
  'permission-card',
67
68
  'presence',
68
69
  ]);
@@ -88,6 +89,7 @@ const FLAGS = {
88
89
  listen: { origin: true, app: true, thread: true, handed: false },
89
90
  permission: { origin: true, app: true },
90
91
  'permission-done': { origin: true, app: true },
92
+ notify: { origin: true, app: true },
91
93
  'permission-card': { origin: true, session: true, worker: true },
92
94
  presence: {
93
95
  origin: true,
@@ -154,6 +156,7 @@ export const HOOK_COMMANDS = new Set([
154
156
  'posted',
155
157
  'permission',
156
158
  'permission-done',
159
+ 'notify',
157
160
  'presence',
158
161
  ]);
159
162
  const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
@@ -315,6 +318,7 @@ export function parseArgs(argv, env = {}) {
315
318
  }
316
319
  case 'permission':
317
320
  case 'permission-done':
321
+ case 'notify':
318
322
  none();
319
323
  return { name: first, origin, ...app() };
320
324
  case 'permission-card': {
package/dist/claude.js CHANGED
@@ -26,6 +26,7 @@ export const PERMISSION_HOOKS = [
26
26
  { event: 'PostToolUse', sub: 'permission-done' },
27
27
  { event: 'PostToolUseFailure', sub: 'permission-done' },
28
28
  { event: 'SessionEnd', sub: 'permission-done', timeout: null },
29
+ { event: 'Notification', sub: 'notify', matcher: 'permission_prompt' },
29
30
  ];
30
31
  export const PERMISSION_CLAUDE = '2.1.119';
31
32
  export const PERMISSIONS = ['Bash(npx pendingyou hold:*)', `mcp__${MCP_NAME}`];
@@ -337,7 +338,11 @@ export async function installClaude(io, origin, options) {
337
338
  const permissionLines = wanted && !blocker
338
339
  ? Object.fromEntries(PERMISSION_HOOKS.map((spec) => [spec.event, options.hookLine(spec.sub)]))
339
340
  : null;
340
- const merged = mergeSettings(await readSettings(io), commands, permissionLines);
341
+ const settings = await readSettings(io);
342
+ const had = hooksOf(settings, 'Claude Code’s settings');
343
+ const upgrade = Boolean(findHook(had, 'PermissionRequest', 'permission')) &&
344
+ !findHook(had, 'Notification', 'notify');
345
+ const merged = mergeSettings(settings, commands, permissionLines);
341
346
  report({
342
347
  ok: true,
343
348
  text: merged.hooksChanged
@@ -349,7 +354,9 @@ export async function installClaude(io, origin, options) {
349
354
  ok: true,
350
355
  text: permissionLines
351
356
  ? merged.permissionChanged
352
- ? 'Added the permission-prompt hooks: when Claude Code waits for your OK and you don’t answer within a few seconds, a card says so (a push only after your hand-off wait), and it goes once you answer in Claude Code.'
357
+ ? upgrade
358
+ ? 'Added a Notification hook to the permission-prompt hooks: a dialog in bypassPermissions mode gets a card too, and so does one the other hooks don’t see, once Claude Code says it has waited a few seconds.'
359
+ : 'Added the permission-prompt hooks: when Claude Code waits for your OK and you don’t answer within a few seconds, a card says so (a push only after your hand-off wait), and it goes once you answer in Claude Code.'
353
360
  : 'The permission-prompt hooks were already installed.'
354
361
  : !wanted
355
362
  ? `${merged.permissionChanged ? 'Took out the permission-prompt hooks' : 'Left out the permission-prompt hooks'}, as you asked: no card when Claude Code waits for your OK. To turn them on: npx pendingyou init --permission-cards${flag}`
@@ -470,6 +477,7 @@ export async function claudeState(io) {
470
477
  const skill = (await readText(skillPath(io))) !== null;
471
478
  let ours = (_event) => undefined;
472
479
  let permission = false;
480
+ let notify = false;
473
481
  const mod = { loads: false, dirs: [], present: false, plugin: false };
474
482
  const loads = claude ? claudeVersion(claude) : null;
475
483
  mod.loads = loads !== null && loadsMods(loads);
@@ -482,6 +490,7 @@ export async function claudeState(io) {
482
490
  const all = objectAt(settings, 'hooks') ?? {};
483
491
  ours = (event) => findHook(all, event, HOOKS[event]);
484
492
  permission = Boolean(findHook(all, 'PermissionRequest', 'permission'));
493
+ notify = Boolean(findHook(all, 'Notification', 'notify'));
485
494
  }
486
495
  catch { }
487
496
  const start = ours('SessionStart');
@@ -496,6 +505,7 @@ export async function claudeState(io) {
496
505
  hooks: Boolean(start && ours('UserPromptSubmit')),
497
506
  stopcheck: Boolean(ours('Stop')),
498
507
  permission,
508
+ notify,
499
509
  presence: Boolean(ours('SessionEnd')),
500
510
  form: form ? { ...form, runs: await hookRuns(form), shim } : null,
501
511
  mod,
package/dist/format.js CHANGED
@@ -1,3 +1,12 @@
1
+ export function noticeOf(heard) {
2
+ const given = heard.notice;
3
+ if (typeof given !== 'object' || given === null)
4
+ return null;
5
+ const { kind, from } = given;
6
+ if ((kind !== 'follow_up' && kind !== 'handed_over') || typeof from !== 'string')
7
+ return null;
8
+ return { kind, from };
9
+ }
1
10
  export const HANDED_PREFIX = 'Pending You: your person handed you';
2
11
  export const HANDED_REPLY = 'on the question handed to you';
3
12
  export const HANDED_TALK_TODO = 'If you know now, answer_delegated with your answer and how you know; if you need more, reply_in_thread to “asker”; if it’s your person’s to decide, reply_in_thread with escalate true; if not, hand_back with what you checked.';
@@ -81,11 +90,37 @@ function describeHanded(heard, delegated) {
81
90
  ...(note?.cut ? [`(Cut short: get_request ${id} has all of it.)`] : []),
82
91
  ];
83
92
  }
93
+ function describeNotice(heard, notice) {
94
+ const id = heard.requestId;
95
+ const from = clip(notice.from, MAX_NAME).text;
96
+ const ack = `ack_answer with requestId ${id}, name ${named(heard)} and expectedVersion ${heard.version}`;
97
+ if (notice.kind === 'handed_over')
98
+ return [
99
+ `Pending You: your person handed your open cards to ${from}, ${id} among them.`,
100
+ `Stop working on them: ${from} answers them and follows up. Call ${ack} to say you’ve stopped.`,
101
+ ];
102
+ let cut = false;
103
+ const wrote = heard.messages.map((message) => {
104
+ const said = quoted(message.body, MAX_MESSAGE);
105
+ cut ||= said.cut;
106
+ return said.line;
107
+ });
108
+ return [
109
+ `Pending You: ${from} followed up on ${id}, which it answered for your person.`,
110
+ `Question: ${clip(heard.title, MAX_TITLE).text}`,
111
+ ...wrote,
112
+ `Act on it if it needs anything, then call ${ack} to mark it heard.`,
113
+ ...(cut ? [`(Cut short: get_request ${id} has all of it.)`] : []),
114
+ ];
115
+ }
84
116
  export function describe(heard, again) {
85
117
  const id = heard.requestId;
86
118
  const delegated = delegatedOf(heard);
87
119
  if (delegated)
88
120
  return describeHanded(heard, delegated);
121
+ const notice = noticeOf(heard);
122
+ if (notice && heard.status !== 'answered')
123
+ return describeNotice(heard, notice);
89
124
  const question = `Question: ${clip(heard.title, MAX_TITLE).text}`;
90
125
  let cut = false;
91
126
  const wrote = heard.messages.map((message) => {
@@ -111,7 +146,7 @@ export function describe(heard, again) {
111
146
  lines.push(`Pending You: ${id} is answered.`, question, `Answer: ${answer.text}`);
112
147
  }
113
148
  if (wrote.length)
114
- lines.push(`They also wrote: ${wrote.join(' ')}`);
149
+ lines.push(`${noticeOf(heard) ? 'Also' : 'They also wrote'}: ${wrote.join(' ')}`);
115
150
  lines.push(`Act on it, then call ack_answer with requestId ${id}, name ${named(heard)}, expectedVersion ${heard.version} and a one-line outcome.`);
116
151
  break;
117
152
  }
package/dist/main.js CHANGED
@@ -46,7 +46,8 @@ async function run(io, command, hook = { fallback: '' }) {
46
46
  case 'posted':
47
47
  return posted(io, { origin: command.origin, app: command.app ?? DEFAULT_APP });
48
48
  case 'permission':
49
- case 'permission-done': {
49
+ case 'permission-done':
50
+ case 'notify': {
50
51
  const app = command.app ?? DEFAULT_APP;
51
52
  if (!isPromptApp(app))
52
53
  return 0;
@@ -13,6 +13,10 @@ import { machineOf } from "./remote.js";
13
13
  export const GRACE_MS = 10_000;
14
14
  export const TITLE_MAX = 90;
15
15
  export const WAITING = 'Claude Code is waiting for your OK';
16
+ export const BACKGROUND_AGENT = ' (a background agent)';
17
+ const QUIET_MODES = new Set(['bypassPermissions', 'dontAsk']);
18
+ export const NOTICE_MS = 4000;
19
+ const BATCH_MS = 1000;
16
20
  export const PROMPT_APPS = ['claude-code', 'codex'];
17
21
  const CLAUDE = 'claude-code';
18
22
  export const isPromptApp = (app) => PROMPT_APPS.includes(app);
@@ -47,9 +51,10 @@ function canonical(value) {
47
51
  return JSON.stringify(value) ?? 'null';
48
52
  }
49
53
  const ANSWERED_IN_INPUT = new Set(['AskUserQuestion', 'ExitPlanMode']);
50
- export function promptKey(tool, input) {
54
+ export function promptKey(tool, input, agent) {
51
55
  const what = ANSWERED_IN_INPUT.has(tool) ? '' : canonical(input ?? {});
52
- return createHash('sha256').update(`${tool}\n${what}`).digest('hex').slice(0, 32);
56
+ const whose = agent ? `${agent}\n` : '';
57
+ return createHash('sha256').update(`${whose}${tool}\n${what}`).digest('hex').slice(0, 32);
53
58
  }
54
59
  const text = (value) => (typeof value === 'string' ? value : '');
55
60
  function commandText(value) {
@@ -134,6 +139,20 @@ export function promptWords(tool, input, home, app = CLAUDE) {
134
139
  }
135
140
  return said(toolName(tool), `to use ${toolName(tool)}`);
136
141
  }
142
+ export const fromAgent = (title) => `${cut(title, TITLE_MAX - [...BACKGROUND_AGENT].length)}${BACKGROUND_AGENT}`;
143
+ const noticeWords = (message) => redactText(text(message), 120);
144
+ function noticeOf(message, now) {
145
+ return {
146
+ key: promptKey('Notification', { message }),
147
+ title: cut(WAITING, TITLE_MAX),
148
+ what: message || 'a dialog in Claude Code',
149
+ doing: '',
150
+ ...(message ? { reason: message } : {}),
151
+ notice: true,
152
+ shown: now,
153
+ at: now,
154
+ };
155
+ }
137
156
  async function load(io, session) {
138
157
  const file = await readJson(pathOf(io, session)).catch(() => null);
139
158
  if (!isObject(file) || !Array.isArray(file.prompts) || typeof file.origin !== 'string')
@@ -152,6 +171,7 @@ async function change(io, session, edit) {
152
171
  });
153
172
  }
154
173
  const alive = (state, now) => Boolean(state.worker && now - state.worker.at < LEASE_STALE_MS);
174
+ const seen = (prompt) => !prompt.quiet || prompt.shown !== undefined;
155
175
  function claim(state, now) {
156
176
  if (alive(state, now))
157
177
  return null;
@@ -169,10 +189,19 @@ function startWorker(io, origin, session, worker) {
169
189
  ...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
170
190
  ]);
171
191
  }
172
- const sessionOf = (input) => {
173
- const id = text(input.session_id);
174
- return SESSION.test(id) ? id : null;
175
- };
192
+ const SUBAGENT_TRANSCRIPT = /^(.*)([/\\])([A-Za-z0-9][A-Za-z0-9_-]{0,99})[/\\]subagents[/\\][^/\\]+\.jsonl$/;
193
+ function whereOf(input) {
194
+ const transcript = text(input.transcript_path);
195
+ const parent = text(input.agent_id) ? SUBAGENT_TRANSCRIPT.exec(transcript) : null;
196
+ if (parent) {
197
+ const [, folder = '', slash = '/', session = ''] = parent;
198
+ return { session, transcript: `${folder}${slash}${session}.jsonl` };
199
+ }
200
+ const session = text(input.session_id);
201
+ return SESSION.test(session) ? { session, transcript } : null;
202
+ }
203
+ const agentOf = (input) => text(input.agent_id).slice(0, 100);
204
+ const agentTypeOf = (input) => cut(text(input.agent_type).replace(/[^\w.:-]/g, ''), 40);
176
205
  const keyInput = (app, input) => {
177
206
  if (app !== 'codex' || !isObject(input) || !('description' in input))
178
207
  return input;
@@ -185,59 +214,76 @@ function reasonOf(app, input) {
185
214
  const reason = redactText(text(input.description).replace(/\s+/g, ' ').trim(), 120);
186
215
  return reason || null;
187
216
  }
217
+ const wentOn = (agent, now) => (prompt) => Boolean(prompt.quiet) && (prompt.agent ?? '') === agent && prompt.at <= now - BATCH_MS;
188
218
  async function asked(io, origin, input, app) {
189
219
  if (io.platform === 'win32')
190
220
  return;
191
- if (app === 'claude-code') {
192
- if (/^sdk-/.test(io.env.CLAUDE_CODE_ENTRYPOINT ?? ''))
193
- return;
194
- if (input.permission_mode === 'dontAsk' || input.permission_mode === 'bypassPermissions')
195
- return;
196
- }
197
- const session = sessionOf(input);
221
+ if (app === 'claude-code' && /^sdk-/.test(io.env.CLAUDE_CODE_ENTRYPOINT ?? ''))
222
+ return;
223
+ const where = whereOf(input);
198
224
  const tool = text(input.tool_name);
199
- if (!session || !tool)
225
+ if (!where || !tool)
200
226
  return;
201
227
  if ((await readCredential(io, origin, app))?.kind !== 'connection')
202
228
  return;
203
229
  const now = io.now();
204
- const agent = text(input.agent_id);
230
+ const agent = agentOf(input);
231
+ const subagent = app === 'claude-code' ? agent : '';
232
+ const agentType = subagent ? agentTypeOf(input) : '';
233
+ const quiet = app === 'claude-code' && QUIET_MODES.has(text(input.permission_mode));
234
+ const words = promptWords(tool, isObject(input.tool_input) ? input.tool_input : {}, io.home, app);
205
235
  const prompt = {
206
- key: promptKey(tool, keyInput(app, input.tool_input)),
236
+ key: promptKey(tool, keyInput(app, input.tool_input), subagent || undefined),
207
237
  ...(app === 'codex' ? { tool: tool.slice(0, 100) } : {}),
208
238
  ...(reasonOf(app, input.tool_input)
209
239
  ? { reason: reasonOf(app, input.tool_input) }
210
240
  : {}),
211
- ...promptWords(tool, isObject(input.tool_input) ? input.tool_input : {}, io.home, app),
212
- ...(agent ? { agent: agent.slice(0, 100) } : {}),
241
+ ...words,
242
+ ...(subagent ? { title: fromAgent(words.title) } : {}),
243
+ ...(agent ? { agent } : {}),
244
+ ...(agentType ? { agentType } : {}),
245
+ ...(quiet ? { quiet: true } : {}),
213
246
  at: now,
214
247
  };
215
248
  const cwd = text(input.cwd) || io.cwd;
216
- const transcript = text(input.transcript_path);
217
249
  const link = app === 'claude-code' ? sessionLink(io.env) : null;
218
250
  let worker = null;
219
- await change(io, session, (state) => {
220
- const next = state ?? { version: 1, origin, session, cwd, prompts: [], at: now };
251
+ await change(io, where.session, (state) => {
252
+ const next = state ?? {
253
+ version: 1,
254
+ origin,
255
+ session: where.session,
256
+ cwd,
257
+ prompts: [],
258
+ at: now,
259
+ };
221
260
  next.origin = origin;
222
261
  if (app === 'claude-code')
223
262
  delete next.app;
224
263
  else
225
264
  next.app = app;
226
- next.cwd = cwd;
227
- if (transcript)
228
- next.transcript = transcript;
265
+ if (!state || !subagent)
266
+ next.cwd = cwd;
267
+ if (where.transcript && (!subagent || !next.transcript))
268
+ next.transcript = where.transcript;
229
269
  if (link)
230
270
  next.link = link;
271
+ const kept = app === 'claude-code'
272
+ ? next.prompts.filter((each) => !wentOn(agent, now)(each))
273
+ : next.prompts;
274
+ const dropped = kept.length < next.prompts.length;
275
+ next.prompts = kept;
231
276
  if (!next.prompts.some((each) => each.key === prompt.key))
232
277
  next.prompts.push(prompt);
233
278
  next.prompts = next.prompts.slice(-MAX_PROMPTS);
234
279
  delete next.settled;
235
280
  next.at = now;
236
- worker = claim(next, now);
281
+ if (!quiet || (dropped && next.card))
282
+ worker = claim(next, now);
237
283
  return next;
238
284
  });
239
285
  if (worker)
240
- startWorker(io, origin, session, worker);
286
+ startWorker(io, origin, where.session, worker);
241
287
  }
242
288
  async function settle(io, session, which, how) {
243
289
  const before = await load(io, session);
@@ -256,8 +302,8 @@ async function settle(io, session, which, how) {
256
302
  state.prompts = left;
257
303
  state.settled = how;
258
304
  state.at = now;
259
- if (!state.card && left.length === 0)
260
- return alive(state, now) ? state : null;
305
+ if (!state.card && !left.some(seen))
306
+ return left.length > 0 || alive(state, now) ? state : null;
261
307
  worker = claim(state, now);
262
308
  return state;
263
309
  });
@@ -265,16 +311,18 @@ async function settle(io, session, which, how) {
265
311
  startWorker(io, origin, session, worker);
266
312
  }
267
313
  async function ran(io, input, app) {
268
- const session = sessionOf(input);
314
+ const where = whereOf(input);
269
315
  const tool = text(input.tool_name);
270
- if (!session || !tool)
316
+ if (!where || !tool)
271
317
  return;
272
- const key = promptKey(tool, keyInput(app, input.tool_input));
273
- const before = await load(io, session);
318
+ const agent = agentOf(input);
319
+ const key = promptKey(tool, keyInput(app, input.tool_input), app === 'claude-code' ? agent || undefined : undefined);
320
+ const before = await load(io, where.session);
274
321
  if (!before?.prompts.length)
275
322
  return;
276
323
  const exact = before.prompts.some((prompt) => prompt.key === key);
277
- await settle(io, session, exact || app !== 'codex' ? (prompt) => prompt.key === key : (prompt) => prompt.tool === tool, 'answered');
324
+ const movedOn = wentOn(agent, io.now());
325
+ await settle(io, where.session, (prompt) => prompt.key === key || (app === 'codex' ? !exact && prompt.tool === tool : movedOn(prompt)), 'answered');
278
326
  }
279
327
  export async function endPrompts(io, session) {
280
328
  try {
@@ -288,7 +336,7 @@ export async function settlePrompts(io, input) {
288
336
  if (!input.session || !SESSION.test(input.session))
289
337
  return;
290
338
  const subagentsDone = input.event === 'stop' && !input.subagents;
291
- await settle(io, input.session, (prompt) => !prompt.agent || subagentsDone, 'answered');
339
+ await settle(io, input.session, (prompt) => (!prompt.agent && !prompt.notice) || subagentsDone, 'answered');
292
340
  }
293
341
  catch { }
294
342
  }
@@ -299,6 +347,64 @@ export function subagentsRunning(input) {
299
347
  task.type === 'subagent' &&
300
348
  !['completed', 'failed', 'killed', 'cancelled', 'stopped'].includes(text(task.status)));
301
349
  }
350
+ function noticeMsOf(env) {
351
+ const given = Number(env.PENDINGYOU_PERMISSION_NOTICE_MS);
352
+ return Number.isInteger(given) && given >= 0 ? given : NOTICE_MS;
353
+ }
354
+ async function notified(io, origin, input) {
355
+ if (io.platform === 'win32')
356
+ return;
357
+ if (/^sdk-/.test(io.env.CLAUDE_CODE_ENTRYPOINT ?? ''))
358
+ return;
359
+ if (input.notification_type !== 'permission_prompt')
360
+ return;
361
+ const where = whereOf(input);
362
+ if (!where)
363
+ return;
364
+ if ((await readCredential(io, origin, CLAUDE))?.kind !== 'connection')
365
+ return;
366
+ const now = io.now();
367
+ const before = now - noticeMsOf(io.env);
368
+ const message = noticeWords(input.message);
369
+ const cwd = text(input.cwd) || io.cwd;
370
+ const link = sessionLink(io.env);
371
+ let worker = null;
372
+ await change(io, where.session, (state) => {
373
+ const next = state ?? {
374
+ version: 1,
375
+ origin,
376
+ session: where.session,
377
+ cwd,
378
+ prompts: [],
379
+ at: now,
380
+ };
381
+ next.origin = origin;
382
+ delete next.app;
383
+ next.cwd = cwd;
384
+ if (where.transcript)
385
+ next.transcript = where.transcript;
386
+ if (link)
387
+ next.link = link;
388
+ const shows = next.prompts
389
+ .filter((prompt) => prompt.shown === undefined && !prompt.notice && prompt.at <= before)
390
+ .at(-1);
391
+ if (shows) {
392
+ shows.shown = now;
393
+ delete shows.failed;
394
+ }
395
+ else if (next.prompts.some((prompt) => prompt.shown !== undefined)) {
396
+ return state;
397
+ }
398
+ else
399
+ next.prompts = [...next.prompts, noticeOf(message, now)].slice(-MAX_PROMPTS);
400
+ delete next.settled;
401
+ next.at = now;
402
+ worker = claim(next, now);
403
+ return next;
404
+ });
405
+ if (worker)
406
+ startWorker(io, origin, where.session, worker);
407
+ }
302
408
  export async function permissionHook(io, options) {
303
409
  const app = options.app ?? CLAUDE;
304
410
  try {
@@ -313,10 +419,14 @@ export async function permissionHook(io, options) {
313
419
  case 'PostToolUseFailure':
314
420
  await ran(io, input, app);
315
421
  break;
422
+ case 'Notification':
423
+ if (app === CLAUDE)
424
+ await notified(io, options.origin, input);
425
+ break;
316
426
  case 'SessionEnd': {
317
- const session = sessionOf(input);
318
- if (session)
319
- await settle(io, session, () => true, 'ended');
427
+ const where = whereOf(input);
428
+ if (where)
429
+ await settle(io, where.session, () => true, 'ended');
320
430
  break;
321
431
  }
322
432
  }
@@ -324,22 +434,24 @@ export async function permissionHook(io, options) {
324
434
  catch { }
325
435
  return 0;
326
436
  }
437
+ const dueOf = (prompt, grace) => Math.min(prompt.quiet ? Number.POSITIVE_INFINITY : prompt.at + grace, prompt.shown ?? Number.POSITIVE_INFINITY);
438
+ const shownOf = (prompts) => (prompts.filter((prompt) => !prompt.notice).at(-1) ?? prompts.at(-1));
327
439
  export function planFor(state, now, grace) {
328
- if (state.prompts.length === 0)
440
+ const prompts = state.prompts.filter(seen);
441
+ if (prompts.length === 0)
329
442
  return state.card ? { kind: 'cancel' } : { kind: 'done' };
330
- const newest = state.prompts.at(-1);
331
443
  if (state.card) {
444
+ const newest = shownOf(prompts);
332
445
  if (state.card.key === newest.key)
333
446
  return { kind: 'done' };
334
- const due = newest.at + grace - now;
447
+ const due = dueOf(newest, grace) - now;
335
448
  return due > 0 ? { kind: 'wait', ms: due } : { kind: 'update', prompt: newest };
336
449
  }
337
- const live = state.prompts.filter((prompt) => !prompt.failed);
338
- const oldest = live[0];
339
- if (!oldest)
450
+ const live = prompts.filter((prompt) => !prompt.failed);
451
+ if (live.length === 0)
340
452
  return { kind: 'done' };
341
- const due = oldest.at + grace - now;
342
- return due > 0 ? { kind: 'wait', ms: due } : { kind: 'post', prompt: live.at(-1) };
453
+ const due = Math.min(...live.map((prompt) => dueOf(prompt, grace))) - now;
454
+ return due > 0 ? { kind: 'wait', ms: due } : { kind: 'post', prompt: shownOf(live) };
343
455
  }
344
456
  function graceOf(env) {
345
457
  const given = Number(env.PENDINGYOU_PERMISSION_GRACE_MS);
@@ -470,14 +582,25 @@ async function areaFor(io, client, state, name) {
470
582
  throw new McpFailure(-32000, 'Pending You made no area.');
471
583
  return areaId;
472
584
  }
585
+ const byAgent = (state, prompt) => appOf(state) === 'claude-code' && Boolean(prompt.agent);
586
+ function changeNoteOf(state, prompt) {
587
+ if (prompt.doing === 'to answer its question')
588
+ return cut(`Now waiting for your answer: ${prompt.what}`, 120);
589
+ const agent = byAgent(state, prompt) ? BACKGROUND_AGENT : '';
590
+ return `${cut(`Now waiting for your OK: ${prompt.what}`, 120 - [...agent].length)}${agent}`;
591
+ }
473
592
  function cardWords(io, state, prompt, name, machine) {
474
593
  const app = appOf(state);
475
594
  const who = name === defaultName(app) ? APP_NAMES[app] : `${APP_NAMES[app]} (${name})`;
476
595
  const waiting = prompt.doing === 'to answer its question'
477
596
  ? 'is waiting for your answer to its question'
478
- : `is waiting for your OK ${prompt.doing}`;
597
+ : `is waiting for your OK${prompt.doing ? ` ${prompt.doing}` : ''}`;
479
598
  const why = prompt.reason ? ` It says: “${prompt.reason}”.` : '';
480
- const summary = cut(`${who} in ${tilde(io, state.cwd)} on ${machine} ${waiting}.${why} It goes on once you answer it there.`, 400);
599
+ const where = `in ${tilde(io, state.cwd)} on ${machine}`;
600
+ const asker = byAgent(state, prompt)
601
+ ? `A background agent${prompt.agentType ? ` (${prompt.agentType})` : ''} of ${who}, ${where},`
602
+ : `${who} ${where}`;
603
+ const summary = cut(`${asker} ${waiting}.${why} It goes on once you answer it there.`, 400);
481
604
  const place = `${APP_NAMES[app]} on ${machine}`;
482
605
  const linked = state.link ? `${place}, ${state.link}` : place;
483
606
  return {
@@ -548,9 +671,7 @@ async function update(io, client, state, prompt, worker) {
548
671
  requestId: card.requestId,
549
672
  name: card.name,
550
673
  expectedVersion,
551
- changeNote: cut(prompt.doing === 'to answer its question'
552
- ? `Now waiting for your answer: ${prompt.what}`
553
- : `Now waiting for your OK: ${prompt.what}`, 120),
674
+ changeNote: changeNoteOf(state, prompt),
554
675
  title: prompt.title,
555
676
  summary: words.summary,
556
677
  action: words.action,
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.15.0';
1
+ export const VERSION = '0.16.0';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.30.0",
4
+ "version": "2.32.0",
5
5
  "description": "Wakes Claude Code when your person answers a Pending You card it posted, even while it sits idle. Installed by `npx pendingyou init`.",
6
6
  "author": {
7
7
  "name": "RecordPlane",
package/mod/README.md CHANGED
@@ -4,4 +4,4 @@ The pendingyou command line’s copy of the Pending You plugin’s wake mod, for
4
4
 
5
5
  In each session it learns the cards the session posts to Pending You, checks them through the session’s own Pending You connection, and starts a turn when your person answers, writes back or a fallback comes due, or hands the session a question from another of their assistants (CLI 0.14.0), even while it sits idle. It answers `npx pendingyou hold` itself, so nothing waits in the background, and shows “2 waiting on you” under the prompt, after the plugin’s name. With the Pending You plugin installed too, only one copy acts.
6
6
 
7
- Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.30.0; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.32.0; send changes there.
package/mod/hooks/wake.js CHANGED
@@ -346,6 +346,11 @@ function line(card) {
346
346
  `your person answered ${titled(card)}`,
347
347
  `call ${get}, act on their words, then ack_answer with its version and a one-line outcome`,
348
348
  ];
349
+ case 'delegated':
350
+ return [
351
+ `there’s word on ${titled(card)}, which your person handed to another assistant`,
352
+ `call ${get} and do what its helper.next says`,
353
+ ];
349
354
  default:
350
355
  return [
351
356
  `your person wrote to you on ${titled(card)}`,
@@ -355,7 +360,9 @@ function line(card) {
355
360
  }
356
361
  const capital = (sentence) => sentence.charAt(0).toUpperCase() + sentence.slice(1);
357
362
  export function wakeText(cards) {
358
- const asked = cards.some((card) => card.state?.turn === 'agent' && !isClosed(card.state.status));
363
+ const asked = cards.some((card) => card.state?.turn === 'agent' &&
364
+ !isClosed(card.state.status) &&
365
+ card.state.status !== 'delegated');
359
366
  const reopen = asked
360
367
  ? ' If they asked you something or you need more from them, reply_in_thread with reopen instead.'
361
368
  : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Pending You for your coding agents: one command sets up Claude Code, Codex, OpenCode and Pi to ask you through Pending You and hear your answers, even while they sit idle.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.pendingyou.com",