pendingyou 0.32.2 → 0.33.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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0 (2026-10-09)
4
+
5
+ - Answer Claude Code's permission prompts on their Pending You card. Start Claude Code with `npx pendingyou claude
6
+ [args…]` (it runs `claude --dangerously-load-development-channels server:pendingyou-permissions [args…]`) and a
7
+ prompt left waiting gets a card with **Allow** and **Deny** and the whole input, its secrets masked, instead of a
8
+ card that only says where to answer. The terminal dialog stays open: whichever answer comes first wins, and Allow is
9
+ for that one call only. `init` registers the channel (`pendingyou channel --app claude-code`, a stdio MCP server that
10
+ sends nothing to the model) at user scope through the hooks' shim, beside the permission-prompt hooks, on Claude
11
+ Code 2.1.234 or later; `uninstall` and `--no-permission-cards` take it out; `status` says whether it's there. The card
12
+ still waits the same 10 seconds, so a prompt answered at the terminal gets none; answered there later, the card goes
13
+ as before. A session started with plain `claude` keeps today's card, which now says it's a permission
14
+ prompt's (`permissionPrompt` with `relay: false`). Needs Pending You with `post_request`'s `permissionPrompt`
15
+ (deploy the server first). Claude Code warns about the development channel at each start until
16
+ the channel is on its allowlist.
17
+
18
+ ## 0.32.3 (2026-10-09)
19
+
20
+ - Claude Code's and Codex's permission prompts get their cards. The worker posted each card without `context`, which
21
+ Pending You's `post_request` requires, so every post was refused, retried, and given up without a word: a prompt
22
+ left waiting in the terminal never reached the queue. The card now carries a `context` with only what it already
23
+ says (the prompt, as its title says it, and the session's link). The tests' pretend Pending You checks every
24
+ `post_request` and `update_request` with Pending You's own input schemas, so a card the server would refuse fails
25
+ them.
26
+
3
27
  ## 0.32.2 (2026-10-08)
4
28
 
5
29
  - A Claude Code dialog in bypassPermissions or dontAsk mode always gets its card. A subagent's `rm -rf $S/*` met Claude
package/README.md CHANGED
@@ -274,6 +274,21 @@ PermissionRequest hook prints nothing, so Codex asks exactly as before; the card
274
274
  Codex, the turn ends or the thread closes. It needs Codex signing in through this computer's sign-in, and trusting the
275
275
  new hooks once. The Codex app's browser-use prompts don't run that hook, so they get no card.
276
276
 
277
+ ### Allow or Deny on the card (0.33.0)
278
+
279
+ Start Claude Code with `npx pendingyou claude` (any of `claude`'s own arguments after it) and a prompt left waiting gets
280
+ a card with **Allow** and **Deny**, and the whole command or input it asks about, secrets masked: “Allow Claude Code
281
+ (billing-webhooks) to run a command?”. Tap one and the terminal's dialog closes and the call runs, or is refused. The
282
+ dialog stays open meanwhile, so you can still answer at the terminal: the first answer wins, and the card goes. Allow
283
+ is for that one call only; nothing is allowed after it.
284
+
285
+ It works through Claude Code's channels (a research preview): `init` registers a small local MCP server,
286
+ `pendingyou-permissions`, that Claude Code relays its permission prompts to in a session started this way, and that
287
+ sends Claude nothing. Until it's on Claude Code's allowlist, Claude Code shows a warning about development channels at
288
+ each start (`pendingyou claude` runs `claude --dangerously-load-development-channels server:pendingyou-permissions`).
289
+ It needs Claude Code 2.1.234 or later and the permission cards on; a session started with plain `claude` gets the card
290
+ without buttons, as above.
291
+
277
292
  ## Which sessions are open
278
293
 
279
294
  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
@@ -501,6 +516,7 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
501
516
  | `hold <requestId>` | Wait for the answer, print it, exit (`--timeout 4h`); the wake mod answers it itself |
502
517
  | `status` | What's set up for each agent; exits 0 when each hears answers right away (`--app`) |
503
518
  | `login` / `logout` | Sign an agent in again, or out (`--app`, `--device`, `--browser`, `--force`, `--name`, `--all`) |
519
+ | `claude [args…]` | Claude Code with the permission channel: a prompt's card has Allow and Deny; `claude`'s own arguments after it |
504
520
  | `watch -- <command>` | Run a command each time an answer is ready (`--once`) |
505
521
  | `pickup` / `handoff` | The session-start and next-message hooks (`--app`) |
506
522
  | `stopcheck` | The Stop hook: items for you left only in chat (`--app`) |
@@ -1,9 +1,10 @@
1
+ import { CHANNEL_CLAUDE } from "../channel.js";
1
2
  import { addCommand, claudeState, helperServer, installClaude, mcpFromClaude, mcpFromFile, PLUGIN_ORIGINS, permissionBlocker, pluginFor, readManifest, removeCommand, sameUrl, uninstallClaude, } from "../claude.js";
2
3
  import { connectionMachine, readCredential } from "../credentials.js";
3
4
  import { differs, serverForFolder, siteOf } from "../environment.js";
4
5
  import { originArgs } from "../hooks.js";
5
6
  import { modConfigured, pruneLoadedNotes, unwokenLines, unwokenSessions, } from "../loaded.js";
6
- import { claudeVersion, isOurModDir, loadsMods, MOD_CLAUDE } from "../mod.js";
7
+ import { atLeast, claudeVersion, isOurModDir, loadsMods, MOD_CLAUDE } from "../mod.js";
7
8
  import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, machineOf, usesHelper, } from "../remote.js";
8
9
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
9
10
  import { shimPath } from "../shim.js";
@@ -223,6 +224,23 @@ function permissionLine(io, claude, manifest, helper, flag) {
223
224
  ? ` ${label} no card when Claude Code waits for your OK: ${blocker}`
224
225
  : ` missing ${label} no card when Claude Code waits for your OK; run npx pendingyou@latest init${flag}`;
225
226
  }
227
+ function channelLine(claude, flag) {
228
+ if (!claude.permission || claude.channel === 'unknown')
229
+ return [];
230
+ const label = 'Permission answers:';
231
+ if (claude.channel)
232
+ return [
233
+ ` ok ${label} start Claude Code with npx pendingyou claude, and a prompt’s card has Allow and Deny`,
234
+ ];
235
+ const version = claude.claude ? claudeVersion(claude.claude) : null;
236
+ if (!version || !atLeast(version, CHANNEL_CLAUDE))
237
+ return [
238
+ ` ${label} Allow and Deny on a prompt’s card need Claude Code ${CHANNEL_CLAUDE} or later${version ? ` (this is ${version})` : ''}`,
239
+ ];
240
+ return [
241
+ ` missing ${label} no Allow or Deny on a prompt’s card; run npx pendingyou@latest init${flag}`,
242
+ ];
243
+ }
226
244
  function presenceLine(claude, connected, mod, flag) {
227
245
  const label = 'Presence:';
228
246
  if (!connected)
@@ -338,7 +356,7 @@ async function claudeStatus(io, ctx) {
338
356
  ? [
339
357
  ` ok Plugin: ${plugin} is enabled${pluginServer ? '' : mcp !== false && mcp !== 'unknown' ? '; Claude Code uses the server added by hand instead of its own' : ''}`,
340
358
  ]
341
- : []), ` ${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));
359
+ : []), ` ${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), ...channelLine(claude, flag), presenceLine(claude, helper && connected, modOk, flag));
342
360
  const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
343
361
  if (setup)
344
362
  lines.push(` ${setup === 'finished' ? 'ok ' : ' '} Setup: ${setup}`);
package/dist/args.js CHANGED
@@ -84,6 +84,8 @@ const COMMANDS = new Set([
84
84
  'herdr',
85
85
  'machine',
86
86
  'app',
87
+ 'channel',
88
+ 'claude',
87
89
  ]);
88
90
  const FLAGS = {
89
91
  help: {},
@@ -139,6 +141,8 @@ const FLAGS = {
139
141
  refresh: { origin: true, force: false, app: true },
140
142
  'mcp-headers': { origin: true, check: false, app: true },
141
143
  mcp: { origin: true, app: true, check: false },
144
+ channel: { origin: true, app: true },
145
+ claude: {},
142
146
  herdr: {
143
147
  origin: true,
144
148
  app: true,
@@ -233,6 +237,8 @@ export function parseArgs(argv, env = {}) {
233
237
  return { name: 'version' };
234
238
  if (!COMMANDS.has(first))
235
239
  throw new UsageError(`There’s no “${first}” command.`);
240
+ if (first === 'claude')
241
+ return { name: 'claude', args: [...after] };
236
242
  const rest = [...leading, ...after];
237
243
  const allowed = FLAGS[first] ?? {};
238
244
  const flags = new Map();
@@ -425,6 +431,9 @@ export function parseArgs(argv, env = {}) {
425
431
  case 'mcp':
426
432
  none();
427
433
  return { name: 'mcp', origin, ...app(), check: flags.has('check') };
434
+ case 'channel':
435
+ none();
436
+ return { name: 'channel', origin, ...app() };
428
437
  case 'herdr':
429
438
  return herdrCommand(origin, positional, flags, app);
430
439
  case 'machine':
@@ -662,6 +671,10 @@ export const USAGE = `pendingyou: Pending You for your coding agents (Claude Cod
662
671
  npx pendingyou status What's set up for each agent, and whether it hears answers right away
663
672
  npx pendingyou login [--app <app>] | logout
664
673
  Sign an agent in again (Claude Code's when none is named), or out
674
+ npx pendingyou claude [args…]
675
+ Start Claude Code so its permission prompts can be answered on their
676
+ Pending You cards: Allow or Deny there, or in the terminal; the first
677
+ answer wins. Claude Code warns about the development channel each start
665
678
  npx pendingyou watch -- <command> [args…]
666
679
  Run a command each time an answer is ready (always-on scripts)
667
680
  npx pendingyou uninstall Remove exactly what init added
@@ -0,0 +1,276 @@
1
+ import { DEFAULT_APP } from "./apps/ids.js";
2
+ import { McpFailure, mcpClient, SIGN_IN_ERROR } from "./bridge.js";
3
+ import { ALLOW, answeredOnCard, computerOf, DENY, promptWords, RELAY_MATCH_MS, recordRelay, relayedPrompt, } from "./permission.js";
4
+ import { cut, maskSecrets, redactText } from "./redact.js";
5
+ import { VERSION } from "./version.js";
6
+ export const CHANNEL = 'pendingyou-permissions';
7
+ export const CHANNEL_CLAUDE = '2.1.234';
8
+ export const READ_MS = 2000;
9
+ export const READ_FOR_MS = 30 * 60_000;
10
+ export const SLOW_READ_MS = 30_000;
11
+ const MATCH_STEP_MS = 500;
12
+ const CALL_MS = 15_000;
13
+ const INPUT_MAX = 40_000;
14
+ const LINES_MAX = 400;
15
+ const REQUEST_ID = /^[a-km-z]{5}$/;
16
+ const UNSERIALIZABLE = '(value unserializable)';
17
+ const NO_DESCRIPTION = 'Run shell command';
18
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ const INSTRUCTIONS = 'Pending You’s permission channel: it puts Claude Code’s permission prompts on the person’s Pending You card, with Allow and Deny. It sends you no messages and has no tools; ignore it.';
20
+ export function inputLines(tool, preview) {
21
+ let parsed = null;
22
+ try {
23
+ parsed = JSON.parse(preview);
24
+ }
25
+ catch { }
26
+ const input = isObject(parsed) ? parsed : {};
27
+ let lines;
28
+ let lang = 'text';
29
+ if (isObject(parsed) &&
30
+ (tool === 'Bash' || tool === 'PowerShell') &&
31
+ typeof parsed.command === 'string') {
32
+ lines = maskSecrets(parsed.command).split('\n');
33
+ lang = 'shell';
34
+ }
35
+ else if (isObject(parsed))
36
+ lines = Object.entries(parsed).flatMap(([key, value]) => maskSecrets(`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`).split('\n'));
37
+ else
38
+ lines = maskSecrets(preview).split('\n');
39
+ const kept = [];
40
+ let size = 0;
41
+ for (const line of lines) {
42
+ if (kept.length >= LINES_MAX || size + line.length > INPUT_MAX) {
43
+ kept.push('… (cut here: the rest is in Claude Code’s dialog)');
44
+ break;
45
+ }
46
+ kept.push(line);
47
+ size += line.length;
48
+ }
49
+ return { lines: kept.length ? kept : [''], lang, input };
50
+ }
51
+ export function heardOf(io, params) {
52
+ if (!isObject(params))
53
+ return null;
54
+ const { request_id: id, tool_name: tool, description, input_preview: preview } = params;
55
+ if (typeof id !== 'string' || !REQUEST_ID.test(id))
56
+ return null;
57
+ if (typeof tool !== 'string' || !tool || tool.length > 200)
58
+ return null;
59
+ const text = typeof preview === 'string' ? preview : '';
60
+ const said = typeof description === 'string' ? description.trim() : '';
61
+ const shown = inputLines(tool, text);
62
+ return {
63
+ tool,
64
+ words: promptWords(tool, shown.input, io.home),
65
+ relay: {
66
+ id,
67
+ at: io.now(),
68
+ description: said && said !== NO_DESCRIPTION ? redactText(said, 160) : '',
69
+ lines: shown.lines,
70
+ lang: shown.lang,
71
+ ...(text.includes(UNSERIALIZABLE) ? { unserializable: true } : {}),
72
+ },
73
+ };
74
+ }
75
+ export function verdictOf(card, relay) {
76
+ if (!['answered', 'resolved'].includes(String(card.status)))
77
+ return null;
78
+ const answer = isObject(card.answer) ? card.answer : null;
79
+ const picked = Array.isArray(answer?.choiceIds) ? answer.choiceIds : [];
80
+ if (picked.length !== 1)
81
+ return null;
82
+ if (picked[0] === DENY)
83
+ return 'deny';
84
+ if (picked[0] === ALLOW && !relay.unserializable)
85
+ return 'allow';
86
+ return null;
87
+ }
88
+ export async function channel(io, options) {
89
+ const app = options.app ?? DEFAULT_APP;
90
+ const controller = new AbortController();
91
+ const stop = () => controller.abort();
92
+ if (io.signal.aborted)
93
+ stop();
94
+ io.signal.addEventListener('abort', stop, { once: true });
95
+ const write = (message) => io.out(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
96
+ const session = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(io.env.CLAUDE_CODE_SESSION_ID ?? '')
97
+ ? io.env.CLAUDE_CODE_SESSION_ID
98
+ : null;
99
+ const cwd = io.env.CLAUDE_PROJECT_DIR || io.cwd;
100
+ const following = new Set();
101
+ const lines = io.lines()[Symbol.asyncIterator]();
102
+ const stopped = new Promise((resolve) => {
103
+ const done = () => resolve({ done: true, value: undefined });
104
+ if (controller.signal.aborted)
105
+ done();
106
+ else
107
+ controller.signal.addEventListener('abort', done, { once: true });
108
+ });
109
+ let client = null;
110
+ const pending = {
111
+ async call(name, args) {
112
+ try {
113
+ client ??= await mcpClient(io, options.origin, 'claude-code', CALL_MS);
114
+ return await client.call(name, args);
115
+ }
116
+ catch (error) {
117
+ if (error instanceof McpFailure)
118
+ client = null;
119
+ throw error;
120
+ }
121
+ },
122
+ };
123
+ try {
124
+ for (;;) {
125
+ const next = await Promise.race([lines.next(), stopped]);
126
+ if (next.done)
127
+ break;
128
+ let message;
129
+ try {
130
+ message = JSON.parse(next.value);
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ if (!isObject(message) || typeof message.method !== 'string')
136
+ continue;
137
+ const id = message.id;
138
+ const asked = typeof id === 'string' || typeof id === 'number';
139
+ switch (message.method) {
140
+ case 'initialize': {
141
+ const params = isObject(message.params) ? message.params : {};
142
+ if (asked)
143
+ write({
144
+ id,
145
+ result: {
146
+ protocolVersion: typeof params.protocolVersion === 'string'
147
+ ? params.protocolVersion
148
+ : '2025-06-18',
149
+ capabilities: {
150
+ experimental: { 'claude/channel': {}, 'claude/channel/permission': {} },
151
+ },
152
+ serverInfo: { name: CHANNEL, version: VERSION },
153
+ instructions: INSTRUCTIONS,
154
+ },
155
+ });
156
+ break;
157
+ }
158
+ case 'ping':
159
+ if (asked)
160
+ write({ id, result: {} });
161
+ break;
162
+ case 'tools/list':
163
+ if (asked)
164
+ write({ id, result: { tools: [] } });
165
+ break;
166
+ case 'notifications/claude/channel/permission_request': {
167
+ if (app !== 'claude-code')
168
+ break;
169
+ const heard = heardOf(io, message.params);
170
+ if (!heard)
171
+ break;
172
+ const task = follow(io, options.origin, heard, {
173
+ session,
174
+ cwd,
175
+ signal: controller.signal,
176
+ pending,
177
+ verdict: (behavior) => write({
178
+ method: 'notifications/claude/channel/permission',
179
+ params: { request_id: heard.relay.id, behavior },
180
+ }),
181
+ }).catch(() => { });
182
+ following.add(task);
183
+ task.then(() => following.delete(task));
184
+ break;
185
+ }
186
+ default:
187
+ if (asked)
188
+ write({ id, error: { code: -32601, message: 'Method not found' } });
189
+ }
190
+ }
191
+ }
192
+ finally {
193
+ controller.abort();
194
+ io.signal.removeEventListener('abort', stop);
195
+ lines.return?.().catch(() => undefined);
196
+ await Promise.allSettled([...following]);
197
+ await client?.close().catch(() => { });
198
+ }
199
+ await io.flush();
200
+ return 0;
201
+ }
202
+ async function follow(io, origin, heard, options) {
203
+ const { signal } = options;
204
+ const id = heard.relay.id;
205
+ let where = null;
206
+ const waitFrom = io.now();
207
+ for (;;) {
208
+ const last = io.now() - waitFrom >= RELAY_MATCH_MS;
209
+ where = await recordRelay(io, origin, heard, {
210
+ session: options.session,
211
+ cwd: options.cwd,
212
+ last,
213
+ });
214
+ if (where || last || signal.aborted)
215
+ break;
216
+ await io.sleep(MATCH_STEP_MS, signal);
217
+ }
218
+ if (!where)
219
+ return;
220
+ const started = io.now();
221
+ while (!signal.aborted) {
222
+ await io.sleep(io.now() - started < READ_FOR_MS ? READ_MS : SLOW_READ_MS, signal);
223
+ if (signal.aborted)
224
+ return;
225
+ const now = await relayedPrompt(io, where, id);
226
+ if (!now)
227
+ return;
228
+ const card = now.prompt.card;
229
+ if (!card)
230
+ continue;
231
+ let read;
232
+ try {
233
+ read = await options.pending.call('get_request', {
234
+ requestId: card.requestId,
235
+ name: card.name,
236
+ });
237
+ }
238
+ catch (error) {
239
+ if (error instanceof McpFailure && error.code === SIGN_IN_ERROR)
240
+ return;
241
+ continue;
242
+ }
243
+ const status = String(read.status);
244
+ if (['pending', 'snoozed', 'delegated'].includes(status))
245
+ continue;
246
+ const verdict = verdictOf(read, heard.relay);
247
+ if (!verdict) {
248
+ if (status === 'answered' && typeof read.version === 'number') {
249
+ const machine = await computerOf(io, origin, 'claude-code');
250
+ await options.pending
251
+ .call('ack_answer', {
252
+ requestId: card.requestId,
253
+ name: card.name,
254
+ expectedVersion: read.version,
255
+ outcome: cut(`Not allowed or denied: it waits for your answer in Claude Code on ${machine}.`, 200),
256
+ })
257
+ .catch(() => { });
258
+ }
259
+ return;
260
+ }
261
+ options.verdict(verdict);
262
+ await answeredOnCard(io, where, id);
263
+ if (status === 'answered' && typeof read.version === 'number') {
264
+ const machine = await computerOf(io, origin, 'claude-code');
265
+ await options.pending
266
+ .call('ack_answer', {
267
+ requestId: card.requestId,
268
+ name: card.name,
269
+ expectedVersion: read.version,
270
+ outcome: cut(`${verdict === 'allow' ? 'Allowed' : 'Denied'} in Claude Code on ${machine}.`, 200),
271
+ })
272
+ .catch(() => { });
273
+ }
274
+ return;
275
+ }
276
+ }
package/dist/claude.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { rm, rmdir, stat } from 'node:fs/promises';
3
3
  import { basename, dirname, join } from 'node:path';
4
4
  import { DEFAULT_ORIGIN } from "./args.js";
5
+ import { CHANNEL, CHANNEL_CLAUDE } from "./channel.js";
5
6
  import { PlainError } from "./errors.js";
6
7
  import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js";
7
8
  import { findHook, hooksOf, mergeHooks, originArgs, removeHooks } from "./hooks.js";
@@ -11,7 +12,7 @@ import { atLeast, claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pl
11
12
  import { removePermissionFiles } from "./permission.js";
12
13
  import { removePresenceFiles } from "./presence.js";
13
14
  import { helperPath } from "./remote.js";
14
- import { readShim, SHIM } from "./shim.js";
15
+ import { readShim, SHIM, shimPath } from "./shim.js";
15
16
  import { VERSION } from "./version.js";
16
17
  export { isOurHook } from "./hooks.js";
17
18
  export const MCP_NAME = 'pendingyou';
@@ -192,6 +193,105 @@ export function helperServer(url, command) {
192
193
  text: `claude mcp add-json --scope user ${MCP_NAME} ${quote(json)}`,
193
194
  };
194
195
  }
196
+ export function channelEntry(io, origin) {
197
+ return {
198
+ type: 'stdio',
199
+ command: shimPath(io),
200
+ args: [
201
+ 'channel',
202
+ '--app',
203
+ 'claude-code',
204
+ ...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
205
+ ],
206
+ };
207
+ }
208
+ export async function channelFromFile(io) {
209
+ let parsed;
210
+ try {
211
+ const text = await readText(claudeJsonPath(io));
212
+ if (text === null)
213
+ return 'unknown';
214
+ parsed = JSON.parse(text);
215
+ }
216
+ catch {
217
+ return 'unknown';
218
+ }
219
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
220
+ return 'unknown';
221
+ const servers = objectAt(parsed, 'mcpServers');
222
+ return servers ? objectAt(servers, CHANNEL) : null;
223
+ }
224
+ const sameEntry = (entry, wanted) => entry.command === wanted.command && JSON.stringify(entry.args) === JSON.stringify(wanted.args);
225
+ async function applyChannel(io, origin, wanted, before, progress) {
226
+ const entry = channelEntry(io, origin);
227
+ const now = await channelFromFile(io);
228
+ const remove = () => io.run('claude', ['mcp', 'remove', '--scope', 'user', CHANNEL], MCP_TIMEOUT_MS);
229
+ if (!wanted) {
230
+ if (!before?.channel || now === null)
231
+ return { step: null, registered: false };
232
+ progress('Removing the permission channel from Claude Code (this can take a minute)…\n');
233
+ const removed = await remove();
234
+ return removed.code === 0
235
+ ? {
236
+ step: { ok: true, text: `Took out the permission channel (${CHANNEL}).` },
237
+ registered: false,
238
+ }
239
+ : {
240
+ step: {
241
+ ok: false,
242
+ text: `Couldn’t take out the permission channel. Run: claude mcp remove --scope user ${CHANNEL}`,
243
+ },
244
+ registered: true,
245
+ };
246
+ }
247
+ if (now !== null && now !== 'unknown' && sameEntry(now, entry))
248
+ return {
249
+ step: { ok: true, text: 'The permission channel was already registered.' },
250
+ registered: true,
251
+ };
252
+ const json = JSON.stringify(entry);
253
+ const add = `claude mcp add-json --scope user ${CHANNEL} ${quote(json)}`;
254
+ if (now !== null && now !== 'unknown') {
255
+ progress('Updating the permission channel in Claude Code (this can take a minute)…\n');
256
+ if ((await remove()).code !== 0)
257
+ return {
258
+ step: {
259
+ ok: false,
260
+ text: `Couldn’t update the permission channel. Run:\n claude mcp remove --scope user ${CHANNEL}\n ${add}`,
261
+ },
262
+ registered: false,
263
+ };
264
+ }
265
+ else
266
+ progress('Adding the permission channel to Claude Code (this can take a minute)…\n');
267
+ const added = await io.run('claude', ['mcp', 'add-json', '--scope', 'user', CHANNEL, json], MCP_TIMEOUT_MS);
268
+ if (added.code !== 0)
269
+ return {
270
+ step: { ok: false, text: `Claude Code didn’t add the permission channel. Run: ${add}` },
271
+ registered: false,
272
+ };
273
+ return {
274
+ step: {
275
+ ok: true,
276
+ text: `Added the permission channel (${CHANNEL}): start Claude Code with npx pendingyou claude and a prompt’s card has Allow and Deny. The first answer wins, there or in the terminal.`,
277
+ },
278
+ registered: true,
279
+ };
280
+ }
281
+ export async function claudeWithChannel(io, args) {
282
+ if (!io.handOver)
283
+ throw new PlainError('This can’t start Claude Code here.');
284
+ if ((await channelFromFile(io)) === null)
285
+ io.err('pendingyou: the permission channel isn’t set up, so Claude Code’s prompts get no Allow or Deny on their cards. Run: npx pendingyou@latest init\n');
286
+ const code = await io.handOver('claude', [
287
+ '--dangerously-load-development-channels',
288
+ `server:${CHANNEL}`,
289
+ ...args,
290
+ ]);
291
+ if (code === 127)
292
+ io.err('pendingyou: Claude Code isn’t installed here (no claude on PATH).\n');
293
+ return code;
294
+ }
195
295
  async function applyMcp(io, origin, plan, helper, progress) {
196
296
  const url = `${origin}/mcp`;
197
297
  const add = helper
@@ -363,6 +463,10 @@ export async function installClaude(io, origin, options) {
363
463
  ? `${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}`
364
464
  : `${merged.permissionChanged ? 'Took out' : 'Left out'} the permission-prompt hooks: ${blocker}.`,
365
465
  });
466
+ const found = claudeVersion(options.version);
467
+ const channel = await applyChannel(io, origin, Boolean(permissionLines) && found !== null && atLeast(found, CHANNEL_CLAUDE), before, options.progress);
468
+ if (channel.step)
469
+ report(channel.step);
366
470
  const mod = await addMod(io, merged.settings, options.copy, options.version);
367
471
  if (mod.step)
368
472
  report(mod.step);
@@ -387,6 +491,7 @@ export async function installClaude(io, origin, options) {
387
491
  ...(options.plugin ? { plugin: true } : {}),
388
492
  permissionHooks: permissionLines,
389
493
  permissionCards: wanted,
494
+ ...(channel.registered ? { channel: true } : {}),
390
495
  };
391
496
  await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
392
497
  return steps;
@@ -458,6 +563,16 @@ export async function uninstallClaude(io) {
458
563
  const skill = await removeSkill(manifest?.skill ?? null);
459
564
  if (skill)
460
565
  steps.push(skill);
566
+ if (manifest?.channel && (await channelFromFile(io)) !== null) {
567
+ io.out('Removing the permission channel from Claude Code (this can take a minute)…\n');
568
+ const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', CHANNEL], MCP_TIMEOUT_MS);
569
+ steps.push(result.code === 0
570
+ ? { ok: true, text: `Removed the permission channel (${CHANNEL}) from Claude Code.` }
571
+ : {
572
+ ok: false,
573
+ text: `Couldn’t remove the permission channel. Run: claude mcp remove --scope user ${CHANNEL}`,
574
+ });
575
+ }
461
576
  if (manifest?.mcpAdded) {
462
577
  io.out('Removing the pendingyou MCP server from Claude Code (this can take a minute)…\n');
463
578
  const result = await io.run('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], MCP_TIMEOUT_MS);
@@ -523,6 +638,7 @@ export async function claudeState(io) {
523
638
  permission,
524
639
  notify,
525
640
  presence: Boolean(ours('SessionEnd')),
641
+ channel: await channelFromFile(io).then((entry) => entry === 'unknown' ? 'unknown' : entry !== null),
526
642
  form: form ? { ...form, runs: await hookRuns(form), shim } : null,
527
643
  mod,
528
644
  plugins,
package/dist/io.js CHANGED
@@ -68,6 +68,29 @@ export function execProgram(command, args, options) {
68
68
  child.stdin?.end(options.stdin);
69
69
  });
70
70
  }
71
+ let handedTo = null;
72
+ function handOver(command, args) {
73
+ return new Promise((resolve) => {
74
+ let child;
75
+ try {
76
+ child = spawn(command, [...args], { stdio: 'inherit' });
77
+ }
78
+ catch {
79
+ resolve(127);
80
+ return;
81
+ }
82
+ handedTo = (signal) => {
83
+ if (signal !== 'SIGINT')
84
+ child.kill(signal);
85
+ };
86
+ const done = (code) => {
87
+ handedTo = null;
88
+ resolve(code);
89
+ };
90
+ child.on('error', (error) => done(error.code === 'ENOENT' ? 127 : 1));
91
+ child.on('exit', (code, signal) => done(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)));
92
+ });
93
+ }
71
94
  export function sleep(ms, signal) {
72
95
  return new Promise((resolve) => {
73
96
  if (signal?.aborted || ms <= 0) {
@@ -232,6 +255,8 @@ export function realIo() {
232
255
  const controller = new AbortController();
233
256
  for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP'])
234
257
  process.on(name, () => {
258
+ if (handedTo)
259
+ return handedTo(name);
235
260
  if (controller.signal.aborted)
236
261
  process.exit(130);
237
262
  controller.abort();
@@ -250,6 +275,7 @@ export function realIo() {
250
275
  ask,
251
276
  run: runProgram,
252
277
  exec: execProgram,
278
+ handOver,
253
279
  openBrowser,
254
280
  readStdin,
255
281
  lines: () => lines(controller.signal, () => controller.abort()),
package/dist/main.js CHANGED
@@ -4,6 +4,8 @@ import { DEFAULT_APP } from "./apps/ids.js";
4
4
  import { appModule } from "./apps/registry.js";
5
5
  import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
6
6
  import { bridge } from "./bridge.js";
7
+ import { channel } from "./channel.js";
8
+ import { claudeWithChannel } from "./claude.js";
7
9
  import { PlainError } from "./errors.js";
8
10
  import { hold } from "./hold.js";
9
11
  import { init, status, uninstall } from "./init.js";
@@ -93,6 +95,10 @@ async function run(io, command, hook = { fallback: '' }) {
93
95
  return mcpHeaders(io, command);
94
96
  case 'mcp':
95
97
  return bridge(io, command);
98
+ case 'channel':
99
+ return channel(io, command);
100
+ case 'claude':
101
+ return claudeWithChannel(io, command.args);
96
102
  case 'herdr':
97
103
  return (await import("./herdr/command.js")).herdr(io, command);
98
104
  case 'machine':
@@ -7,8 +7,8 @@ import { DEFAULT_ORIGIN } from "./args.js";
7
7
  import { McpFailure, mcpClient, SIGN_IN_ERROR, ToolRefused } from "./bridge.js";
8
8
  import { sessionLink } from "./confirm.js";
9
9
  import { connectionMachine, readCredential } from "./credentials.js";
10
- import { configDir, PERMISSION_FOLDER, readJson, withLock, writeWhole } from "./files.js";
11
- import { cut, redactCommand, redactText, redactUrl, shortPath } from "./redact.js";
10
+ import { claudeDir, configDir, PERMISSION_FOLDER, readJson, withLock, writeWhole } from "./files.js";
11
+ import { BLANK, cut, redactCommand, redactText, redactUrl, shortPath } from "./redact.js";
12
12
  import { machineOf } from "./remote.js";
13
13
  export const GRACE_MS = 10_000;
14
14
  export const TITLE_MAX = 90;
@@ -24,7 +24,9 @@ export const isPromptApp = (app) => PROMPT_APPS.includes(app);
24
24
  export const waitingFor = (app) => `${APP_NAMES[app]} is waiting for your OK`;
25
25
  export const isPermissionCard = (title) => typeof title === 'string' &&
26
26
  PROMPT_APPS.some((app) => title.startsWith(waitingFor(app)) ||
27
- title.startsWith(`${APP_NAMES[app]} is waiting for your answer`));
27
+ title.startsWith(`${APP_NAMES[app]} is waiting for your answer`) ||
28
+ title.startsWith(`Allow ${APP_NAMES[app]} `) ||
29
+ title.startsWith(`Allow a subagent of ${APP_NAMES[app]}`));
28
30
  export const DEFAULT_NAME = 'Claude Code';
29
31
  const defaultName = (app) => APP_NAMES[app];
30
32
  const LEASE_STALE_MS = 120_000;
@@ -172,7 +174,10 @@ async function change(io, session, edit) {
172
174
  });
173
175
  }
174
176
  const alive = (state, now) => Boolean(state.worker && now - state.worker.at < LEASE_STALE_MS);
175
- const seen = (prompt) => !prompt.quiet || prompt.shown !== undefined || prompt.waited !== undefined;
177
+ const seen = (prompt) => !prompt.quiet ||
178
+ prompt.shown !== undefined ||
179
+ prompt.waited !== undefined ||
180
+ prompt.relay !== undefined;
176
181
  const unchecked = (prompt) => !seen(prompt) && !prompt.notice;
177
182
  function claim(state, now) {
178
183
  if (alive(state, now))
@@ -217,6 +222,27 @@ function reasonOf(app, input) {
217
222
  return reason || null;
218
223
  }
219
224
  const wentOn = (agent, now) => (prompt) => Boolean(prompt.quiet) && (prompt.agent ?? '') === agent && prompt.at <= now - BATCH_MS;
225
+ function closeCards(state, settled, how) {
226
+ const cards = settled.flatMap((prompt) => (prompt.card ? [{ ...prompt.card, how }] : []));
227
+ if (cards.length)
228
+ state.closing = [...(state.closing ?? []), ...cards];
229
+ }
230
+ function keepNewest(state) {
231
+ closeCards(state, state.prompts.slice(0, -MAX_PROMPTS), 'answered');
232
+ state.prompts = state.prompts.slice(-MAX_PROMPTS);
233
+ }
234
+ const ADOPT_MS = 30_000;
235
+ function adopt(state, prompt) {
236
+ const own = state.prompts.find((each) => each.relay?.own &&
237
+ each.tool === prompt.tool &&
238
+ Math.abs(prompt.at - each.relay.at) <= ADOPT_MS);
239
+ if (!own?.relay)
240
+ return false;
241
+ const { at: _at, quiet: _quiet, ...hook } = prompt;
242
+ Object.assign(own, hook);
243
+ delete own.relay.own;
244
+ return true;
245
+ }
220
246
  async function asked(io, origin, input, app) {
221
247
  if (io.platform === 'win32')
222
248
  return;
@@ -273,10 +299,12 @@ async function asked(io, origin, input, app) {
273
299
  const kept = app === 'claude-code'
274
300
  ? next.prompts.filter((each) => !wentOn(agent, now)(each))
275
301
  : next.prompts;
302
+ closeCards(next, next.prompts.filter((each) => !kept.includes(each)), 'answered');
276
303
  next.prompts = kept;
277
- if (!next.prompts.some((each) => each.key === prompt.key))
304
+ const adopted = app === 'claude-code' ? adopt(next, prompt) : false;
305
+ if (!adopted && !next.prompts.some((each) => each.key === prompt.key))
278
306
  next.prompts.push(prompt);
279
- next.prompts = next.prompts.slice(-MAX_PROMPTS);
307
+ keepNewest(next);
280
308
  delete next.settled;
281
309
  next.at = now;
282
310
  worker = claim(next, now);
@@ -299,10 +327,11 @@ async function settle(io, session, which, how) {
299
327
  const left = state.prompts.filter((prompt) => !which(prompt));
300
328
  if (left.length === state.prompts.length)
301
329
  return state;
330
+ closeCards(state, state.prompts.filter((prompt) => !left.includes(prompt)), how);
302
331
  state.prompts = left;
303
332
  state.settled = how;
304
333
  state.at = now;
305
- if (!state.card && left.length === 0)
334
+ if (!state.card && !state.closing?.length && left.length === 0)
306
335
  return alive(state, now) ? state : null;
307
336
  worker = claim(state, now);
308
337
  return state;
@@ -322,7 +351,11 @@ async function ran(io, input, app) {
322
351
  return;
323
352
  const exact = before.prompts.some((prompt) => prompt.key === key);
324
353
  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');
354
+ await settle(io, where.session, (prompt) => prompt.key === key ||
355
+ (app === 'codex'
356
+ ? !exact && prompt.tool === tool
357
+ : movedOn(prompt) ||
358
+ (!exact && Boolean(prompt.relay?.own) && prompt.tool === tool && !agent)), 'answered');
326
359
  }
327
360
  export async function endPrompts(io, session) {
328
361
  try {
@@ -447,7 +480,7 @@ export async function permissionHook(io, options) {
447
480
  const dueOf = (prompt, grace) => Math.min(prompt.quiet ? (prompt.waited ?? Number.POSITIVE_INFINITY) : prompt.at + grace, prompt.shown ?? Number.POSITIVE_INFINITY);
448
481
  const shownOf = (prompts) => (prompts.filter((prompt) => !prompt.notice).at(-1) ?? prompts.at(-1));
449
482
  export function planFor(state, now, grace, quietGrace = QUIET_GRACE_MS) {
450
- const plan = cardPlan(state, now, grace);
483
+ const plan = relayPlan(state, now, grace, cardPlan(state, now, grace));
451
484
  const pending = state.prompts.filter(unchecked);
452
485
  if (pending.length === 0)
453
486
  return plan;
@@ -460,8 +493,26 @@ export function planFor(state, now, grace, quietGrace = QUIET_GRACE_MS) {
460
493
  return { kind: 'wait', ms: Math.min(plan.ms, check) };
461
494
  return plan;
462
495
  }
496
+ function relayPlan(state, now, grace, then) {
497
+ const closing = state.closing?.[0];
498
+ if (closing)
499
+ return { kind: 'close', card: closing };
500
+ const waiting = state.prompts.filter((prompt) => prompt.relay && !prompt.card && !prompt.failed);
501
+ if (waiting.length === 0)
502
+ return then;
503
+ const dueAt = (prompt) => Math.min(Math.min(prompt.at, prompt.relay.at) + grace, prompt.shown ?? Number.POSITIVE_INFINITY);
504
+ const first = waiting.reduce((soonest, prompt) => dueAt(prompt) < dueAt(soonest) ? prompt : soonest);
505
+ const due = dueAt(first) - now;
506
+ if (due <= 0)
507
+ return { kind: 'relay', prompt: first };
508
+ if (then.kind === 'done')
509
+ return { kind: 'wait', ms: due };
510
+ if (then.kind === 'wait')
511
+ return { kind: 'wait', ms: Math.min(then.ms, due) };
512
+ return then;
513
+ }
463
514
  function cardPlan(state, now, grace) {
464
- const prompts = state.prompts.filter(seen);
515
+ const prompts = state.prompts.filter((prompt) => seen(prompt) && !prompt.relay);
465
516
  if (prompts.length === 0)
466
517
  return state.card ? { kind: 'cancel' } : { kind: 'done' };
467
518
  if (state.card) {
@@ -481,7 +532,7 @@ function graceOf(env) {
481
532
  const given = Number(env.PENDINGYOU_PERMISSION_GRACE_MS);
482
533
  return Number.isInteger(given) && given >= 0 ? given : GRACE_MS;
483
534
  }
484
- async function computerOf(io, origin, app) {
535
+ export async function computerOf(io, origin, app) {
485
536
  const named = await connectionMachine(io, origin, app).catch(() => null);
486
537
  return cut(named ?? (machineOf(io.host) || 'this computer'), 60);
487
538
  }
@@ -835,6 +886,16 @@ function cardWords(io, state, prompt, name, machine) {
835
886
  },
836
887
  };
837
888
  }
889
+ function contextOf(state, prompt) {
890
+ const app = appOf(state);
891
+ return {
892
+ background: `${APP_NAMES[app]} asked in its terminal; this card stands in for that dialog.`,
893
+ trace: [{ state: 'paused', text: prompt.title }],
894
+ filesTouched: [],
895
+ tried: [],
896
+ links: state.link ? [{ label: `The ${APP_NAMES[app]} session`, url: state.link }] : [],
897
+ };
898
+ }
838
899
  async function record(io, session, worker, edit) {
839
900
  let ours = false;
840
901
  await change(io, session, (state) => {
@@ -859,9 +920,11 @@ async function post(io, client, state, prompt, worker) {
859
920
  kind: 'action',
860
921
  title: prompt.title,
861
922
  ...cardWords(io, state, prompt, name, machine),
923
+ context: contextOf(state, prompt),
862
924
  blocking: false,
863
925
  urgency: 'now',
864
926
  askedFirst: { where: 'terminal', askedAt: new Date(prompt.at).toISOString() },
927
+ permissionPrompt: { app: appOf(state), relay: false },
865
928
  });
866
929
  const requestId = idOf(result, 'requestId', 'req_');
867
930
  const version = typeof result.version === 'number' ? result.version : 1;
@@ -875,6 +938,89 @@ async function post(io, client, state, prompt, worker) {
875
938
  if (!kept)
876
939
  await withdraw(io, client, card, state, 'answered').catch(() => { });
877
940
  }
941
+ export const ALLOW = 'allow';
942
+ export const DENY = 'deny';
943
+ export const IN_TERMINAL = 'terminal';
944
+ export function relayTitle(state, prompt, name) {
945
+ const app = appOf(state);
946
+ const who = name === defaultName(app) ? APP_NAMES[app] : `${APP_NAMES[app]} (${name})`;
947
+ const subject = byAgent(state, prompt) ? `a subagent of ${who}` : who;
948
+ const doing = prompt.doing.startsWith('to ')
949
+ ? prompt.doing
950
+ : `to use ${toolName(prompt.tool ?? '')}`;
951
+ return cut(`Allow ${subject} ${doing}?`, TITLE_MAX);
952
+ }
953
+ async function postRelay(io, client, state, prompt, worker) {
954
+ const relay = prompt.relay;
955
+ const app = appOf(state);
956
+ const name = await nameOf(io, state);
957
+ await client.call('whoami', { name });
958
+ const areaId = state.areaId ?? (await areaFor(io, client, state, name));
959
+ const machine = await computerOf(io, state.origin, app);
960
+ const title = relayTitle(state, prompt, name);
961
+ const who = name === defaultName(app) ? APP_NAMES[app] : `${APP_NAMES[app]} (${name})`;
962
+ const where = `in ${tilde(io, state.cwd)} on ${machine}`;
963
+ const asker = byAgent(state, prompt)
964
+ ? `A subagent${prompt.agentType ? ` (${prompt.agentType})` : ''} of ${who}, ${where},`
965
+ : `${who} ${where}`;
966
+ const wants = prompt.doing.startsWith('to ')
967
+ ? `wants your OK ${prompt.doing}`
968
+ : `wants your OK to use ${toolName(prompt.tool ?? '')}`;
969
+ const why = relay.description ? ` It says: “${relay.description}”.` : '';
970
+ const answer = relay.unserializable
971
+ ? `Part of it couldn’t be shown, so only Deny is here; to allow it, answer in ${APP_NAMES[app]} on ${machine}.`
972
+ : `Allow or Deny here, or answer in ${APP_NAMES[app]} on ${machine}: the first answer wins.`;
973
+ const session = createHash('sha256').update(state.session).digest('hex').slice(0, 24);
974
+ const result = await client.call('post_request', {
975
+ idempotencyKey: `${app}-relay:${session}:${relay.id}:${relay.at}`,
976
+ areaId,
977
+ session: { label: name, machine, cwd: cut(tilde(io, state.cwd), 300) },
978
+ kind: 'choice',
979
+ intent: 'approve',
980
+ title,
981
+ summary: cut(`${asker} ${wants}.${why} ${answer}`, 400),
982
+ options: relay.unserializable
983
+ ? [
984
+ { id: DENY, label: 'Deny' },
985
+ { id: IN_TERMINAL, label: `I’ll answer in ${APP_NAMES[app]}` },
986
+ ]
987
+ : [
988
+ { id: ALLOW, label: 'Allow', detail: 'This call only. Nothing is allowed after it.' },
989
+ { id: DENY, label: 'Deny' },
990
+ ],
991
+ artifacts: [
992
+ {
993
+ kind: 'code',
994
+ title: prompt.tool === 'Bash' ? 'The command' : `${toolName(prompt.tool ?? '')}’s input`,
995
+ ref: `permission-${relay.id}`,
996
+ lang: relay.lang,
997
+ lines: relay.lines,
998
+ },
999
+ ],
1000
+ context: { ...contextOf(state, prompt), trace: [{ state: 'paused', text: title }] },
1001
+ blocking: false,
1002
+ urgency: 'now',
1003
+ askedFirst: { where: 'terminal', askedAt: new Date(prompt.at).toISOString() },
1004
+ permissionPrompt: { app, relay: true },
1005
+ });
1006
+ const requestId = idOf(result, 'requestId', 'req_');
1007
+ const version = typeof result.version === 'number' ? result.version : 1;
1008
+ if (!requestId)
1009
+ throw new McpFailure(-32000, 'Pending You posted no card.');
1010
+ const card = { requestId, version, key: prompt.key, name };
1011
+ const id = promptId(prompt);
1012
+ let kept = false;
1013
+ const ours = await record(io, state.session, worker, (state) => {
1014
+ const now = state.prompts.find((each) => promptId(each) === id && each.relay?.id === relay.id);
1015
+ if (!now)
1016
+ return;
1017
+ now.card = card;
1018
+ state.areaId = areaId;
1019
+ kept = true;
1020
+ });
1021
+ if (!ours || !kept)
1022
+ await withdraw(io, client, card, state, 'answered').catch(() => { });
1023
+ }
878
1024
  async function stillOpen(client, card) {
879
1025
  const now = await client.call('get_request', { requestId: card.requestId, name: card.name });
880
1026
  const open = ['pending', 'snoozed', 'delegated'].includes(text(now.status)) && now.turn !== 'agent';
@@ -974,7 +1120,7 @@ async function letGo(io, session, worker, grace) {
974
1120
  return state;
975
1121
  }
976
1122
  delete state.worker;
977
- return state.prompts.length === 0 && !state.card ? null : state;
1123
+ return state.prompts.length === 0 && !state.card && !state.closing?.length ? null : state;
978
1124
  });
979
1125
  return done;
980
1126
  }
@@ -1027,6 +1173,17 @@ export async function permissionWorker(io, options) {
1027
1173
  await post(io, client, state, plan.prompt, worker);
1028
1174
  else if (plan.kind === 'update')
1029
1175
  await update(io, client, state, plan.prompt, worker);
1176
+ else if (plan.kind === 'relay')
1177
+ await postRelay(io, client, state, plan.prompt, worker);
1178
+ else if (plan.kind === 'close') {
1179
+ const card = plan.card;
1180
+ await withdraw(io, client, card, state, card.how);
1181
+ await record(io, session, worker, (state) => {
1182
+ state.closing = state.closing?.filter((each) => each.requestId !== card.requestId);
1183
+ if (!state.closing?.length)
1184
+ delete state.closing;
1185
+ });
1186
+ }
1030
1187
  else {
1031
1188
  const card = state.card;
1032
1189
  await withdraw(io, client, card, state, state.settled ?? 'answered');
@@ -1046,8 +1203,17 @@ export async function permissionWorker(io, options) {
1046
1203
  if (failures >= BACKOFF_MS.length) {
1047
1204
  failures = 0;
1048
1205
  await record(io, session, worker, (state) => {
1049
- if (plan.kind === 'post')
1050
- state.prompts = state.prompts.map((prompt) => ({ ...prompt, failed: true }));
1206
+ if (plan.kind === 'relay') {
1207
+ const id = promptId(plan.prompt);
1208
+ state.prompts = state.prompts.map((prompt) => promptId(prompt) === id ? { ...prompt, failed: true } : prompt);
1209
+ }
1210
+ else if (plan.kind === 'close') {
1211
+ state.closing = state.closing?.filter((each) => each.requestId !== plan.card.requestId);
1212
+ if (!state.closing?.length)
1213
+ delete state.closing;
1214
+ }
1215
+ else if (plan.kind === 'post')
1216
+ state.prompts = state.prompts.map((prompt) => prompt.relay ? prompt : { ...prompt, failed: true });
1051
1217
  else if (plan.kind === 'update' && state.card)
1052
1218
  state.card = { ...state.card, key: plan.prompt.key };
1053
1219
  else
@@ -1064,6 +1230,123 @@ export async function permissionWorker(io, options) {
1064
1230
  await client?.close().catch(() => { });
1065
1231
  }
1066
1232
  }
1233
+ export const RELAY_MATCH_MS = 5000;
1234
+ function fit(prompt, heard) {
1235
+ if (prompt.relay || prompt.notice || prompt.tool !== heard.tool)
1236
+ return null;
1237
+ if (Math.abs(prompt.at - heard.relay.at) > RELAY_MATCH_MS)
1238
+ return null;
1239
+ const input = heard.relay.lines.join(' ').replace(/\s+/g, ' ');
1240
+ return prompt.what
1241
+ .split(BLANK)
1242
+ .map((part) => part.replace(/\s+/g, ' ').trim())
1243
+ .filter((part) => part.length >= 3 && input.includes(part)).length;
1244
+ }
1245
+ function bestFit(prompts, heard) {
1246
+ let best = null;
1247
+ for (const prompt of prompts) {
1248
+ const score = fit(prompt, heard);
1249
+ if (score === null)
1250
+ continue;
1251
+ const apart = Math.abs(prompt.at - heard.relay.at);
1252
+ if (!best ||
1253
+ score > best.score ||
1254
+ (score === best.score && apart < Math.abs(best.prompt.at - heard.relay.at)))
1255
+ best = { prompt, score };
1256
+ }
1257
+ return best?.prompt ?? null;
1258
+ }
1259
+ async function sessionsFrom(io, first) {
1260
+ const names = await readdir(folderOf(io)).catch(() => []);
1261
+ const others = names
1262
+ .filter((name) => name.endsWith('.json'))
1263
+ .map((name) => name.slice(0, -'.json'.length))
1264
+ .filter((session) => SESSION.test(session) && session !== first);
1265
+ return first ? [first, ...others] : others;
1266
+ }
1267
+ const transcriptOf = (io, cwd, session) => join(claudeDir(io), 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${session}.jsonl`);
1268
+ export async function recordRelay(io, origin, heard, options) {
1269
+ if (io.platform === 'win32')
1270
+ return null;
1271
+ if ((await readCredential(io, origin, CLAUDE))?.kind !== 'connection')
1272
+ return null;
1273
+ for (const session of await sessionsFrom(io, options.session)) {
1274
+ const before = await load(io, session);
1275
+ if (!before || !bestFit(before.prompts, heard))
1276
+ continue;
1277
+ let worker = null;
1278
+ let matched = false;
1279
+ const state = await change(io, session, (state) => {
1280
+ const prompt = state ? bestFit(state.prompts, heard) : null;
1281
+ if (!state || !prompt)
1282
+ return state;
1283
+ prompt.relay = heard.relay;
1284
+ delete prompt.failed;
1285
+ matched = true;
1286
+ delete state.settled;
1287
+ worker = claim(state, io.now());
1288
+ return state;
1289
+ });
1290
+ if (!matched || !state)
1291
+ continue;
1292
+ if (worker)
1293
+ startWorker(io, state.origin, session, worker);
1294
+ return session;
1295
+ }
1296
+ if (!options.last)
1297
+ return null;
1298
+ const session = options.session ?? `channel-${io.ppid ?? process.pid}`;
1299
+ const now = io.now();
1300
+ const prompt = {
1301
+ key: promptKey(heard.tool, { relay: heard.relay.id }),
1302
+ tool: heard.tool.slice(0, 100),
1303
+ ...heard.words,
1304
+ at: heard.relay.at,
1305
+ relay: { ...heard.relay, own: true },
1306
+ };
1307
+ let worker = null;
1308
+ await change(io, session, (state) => {
1309
+ const next = state ?? {
1310
+ version: 1,
1311
+ origin,
1312
+ session,
1313
+ cwd: options.cwd,
1314
+ ...(options.session ? { transcript: transcriptOf(io, options.cwd, options.session) } : {}),
1315
+ prompts: [],
1316
+ at: now,
1317
+ };
1318
+ if (next.prompts.some((each) => each.relay?.id === heard.relay.id))
1319
+ return next;
1320
+ next.prompts.push(prompt);
1321
+ keepNewest(next);
1322
+ delete next.settled;
1323
+ next.at = now;
1324
+ worker = claim(next, now);
1325
+ return next;
1326
+ });
1327
+ if (worker)
1328
+ startWorker(io, origin, session, worker);
1329
+ return session;
1330
+ }
1331
+ export async function relayedPrompt(io, session, id) {
1332
+ const state = await load(io, session);
1333
+ const prompt = state?.prompts.find((each) => each.relay?.id === id);
1334
+ return state && prompt ? { state, prompt } : null;
1335
+ }
1336
+ export async function answeredOnCard(io, session, id) {
1337
+ await change(io, session, (state) => {
1338
+ if (!state)
1339
+ return null;
1340
+ const left = state.prompts.filter((prompt) => prompt.relay?.id !== id);
1341
+ if (left.length === state.prompts.length)
1342
+ return state;
1343
+ state.prompts = left;
1344
+ state.at = io.now();
1345
+ if (left.length === 0 && !state.card && !state.closing?.length && !alive(state, io.now()))
1346
+ return null;
1347
+ return state;
1348
+ });
1349
+ }
1067
1350
  export async function removePermissionFiles(io) {
1068
1351
  const folder = folderOf(io);
1069
1352
  const there = await stat(folder).then(() => true, () => false);
package/dist/redact.js CHANGED
@@ -234,6 +234,12 @@ export function cut(text, max = SUMMARY_MAX) {
234
234
  export function redactText(text, max = SUMMARY_MAX) {
235
235
  return cut(tidy(redactTokens(redactSchemes(redactHeaders(redactUrls(text))))), max);
236
236
  }
237
+ export function maskSecrets(text) {
238
+ return text
239
+ .split('\n')
240
+ .map((line) => redactTokens(redactFlags(redactNamedValues(redactSchemes(redactHeaders(redactUrls(line)))))))
241
+ .join('\n');
242
+ }
237
243
  const INLINE_CODE = /(?:^|[\s;&|(`$/])(?:(?:ba|z|da|k)?sh|fish|python[0-9.]*|node(?:js)?|deno|bun|ruby|perl|php|osascript|pwsh|powershell|lua|Rscript)\b[^;&|]*?\s(?:-c|-e|-E|--eval|-p|--print|-r|-Command|-command|-EncodedCommand)(?=\s|=|$)/i;
238
244
  const TAKES_SECRET = /\bsudo\s+(?:-\w+\s+)*-S\b|--password-stdin|\b(?:ch)?passwd\b|\bhtpasswd\b|\bsecurity\s+(?:add|set|import)-|\bsecrets?\s+(?:set|put|add|create|edit)\b|\bvault\s+(?:kv\s+)?(?:put|write)\b|\bkubectl\s+create\s+secret\b|\bop\s+(?:item|read|run|inject)\b|\bssh-add\b|\bgpg\b|\bpass\s+(?:insert|edit)\b|\bbase64\s+(?:-d|-D|--decode)\b/i;
239
245
  export function unsafeCommand(command) {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.32.2';
1
+ export const VERSION = '0.33.0';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.48.1",
4
+ "version": "2.49.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.48.1; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.49.0; send changes there.
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.32.2",
3
+ "version": "0.33.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",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/recordplane/pendingyou-cli.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/recordplane/pendingyou-cli/issues"
13
+ },
7
14
  "type": "module",
8
15
  "bin": {
9
16
  "pendingyou": "dist/cli.js"
@@ -32,6 +39,7 @@
32
39
  "prepublishOnly": "pnpm run build && pnpm run test",
33
40
  "test": "vitest run",
34
41
  "typecheck": "tsc -p tsconfig.json",
35
- "sdk": "node scripts/sdk.ts"
42
+ "sdk": "node scripts/sdk.ts",
43
+ "sync:repo": "node scripts/sync.ts"
36
44
  }
37
45
  }