pendingyou 0.17.0 → 0.19.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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 (2026-10-06)
4
+
5
+ - The Stop check lets a turn end on a recap of cards already waiting on you. It asked the agent to go on whenever its
6
+ last message had something for you and the turn posted no card, so a closing line like “Two cards are waiting on
7
+ you: CLI 0.18.0 (publish it) and the Herdr check (does the amber title show in the sidebar?).” was asked to go on,
8
+ and Claude Code showed each of those as a “Stop hook error”. Now:
9
+ - A recap counts as posted, for what it says: a sentence that says its cards wait on you (“Two cards are waiting on
10
+ you: …”, “the card is still open for your answer”, “both cards are in your queue”, “… (both cards).”), or a heading
11
+ that does, or names your cards (“**Cards waiting for you:**”, “Your open cards:”), with the list under it.
12
+ Anything else in the message for you is checked as before, so a new ask beside a recap still goes on a card. Cards
13
+ that aren't said to wait on you (“the two cards line up”, “add a credit card”) are no recap.
14
+ - So does a card said to be coming, or one an item already is: “a card is coming”, “there'll be a card”, “I'll
15
+ create a card”, “that one is already a card”, as “I'll send you a card” and “it's on a card” already did.
16
+ - Anything else for you, in a message that talks about cards (not a credit card, a SIM card or a card number), is
17
+ taken as a recap while this folder has a card waiting on you: one request (this folder's open cards, the one the
18
+ next-message hook makes), made only for such a message and given up after 1.5 seconds. None open: the agent is
19
+ asked to go on, as before. A permission prompt's card doesn't count. When Pending You can't say (offline, slow, an
20
+ older server), the turn ends.
21
+ - Everything else is as before: an ask to type something here, a question handed to the agent, once a turn at most,
22
+ quiet when not signed in, silent on any error, the 3-second deadline. Codex's, OpenCode's and Pi's Stop checks do
23
+ the same, each asking for its own app's cards.
24
+ - Hooks run the copy `init` installed: run `npx pendingyou@latest init` once to switch.
25
+
26
+ ## 0.18.0 (2026-10-05)
27
+
28
+ - Presence says which terminal: in a Herdr pane (`HERDR_ENV=1` with `HERDR_BIN_PATH` and `HERDR_PANE_ID`), every
29
+ presence report (`POST /mcp/cli/presence`: each session's start, its 5-minute heartbeat and its end, for Claude Code,
30
+ Codex, OpenCode and Pi) also says where the session runs: `"terminal": { "app": "herdr", "paneId": "w2:p1", "server":
31
+ "<16 hex>" }`. `server` names the Herdr server without the path of its socket: the first 16 hex characters of a
32
+ SHA-256 of the computer's name and `HERDR_SOCKET_PATH`, left out when there's no socket path. Pending You keeps it with
33
+ the session and lists it with the agent's sessions running now, so the Pending You plugin for Herdr can go from a card
34
+ to its agent's pane. It goes whether or not the badges are on. Anywhere else a report is exactly as before, with no
35
+ `terminal`. A Pending You from before it refuses the field (400): the same report goes again without it.
36
+ - This computer's key: Pending You knows a computer by a key, not by its name. The first time `init` (or `login`) signs
37
+ anything in, it makes a P-256 key pair in `~/.config/pendingyou/machine.json` (0600, in the 0700 folder with its
38
+ `.gitignore`), shared by every Pending You address. The private key never leaves the computer and is never printed or
39
+ logged; Pending You keeps only its public key's RFC 7638 thumbprint. One that can't be read is never replaced: `init`
40
+ says so and signs in without it.
41
+ - Every device sign-in it starts (`POST /oauth/device`) carries `machine_attestation`: a JWS the key signs (ES256,
42
+ `typ` `pendingyou-machine+jwt`, the public key in its header) for `<origin>/oauth/device`, the client signing in and
43
+ the computer's name, good for 5 minutes and once (`jti`). It's dated by Pending You's own clock (its answer's
44
+ `Date`), so a computer whose clock is off still proves its key. Whoever allows the sign-in enrolls the computer, and
45
+ the connections it makes belong to it. A client registered again (a forgotten one) gets a new attestation of its
46
+ own.
47
+ - On the next `init`, each agent already signed in proves the key with its own sign-in (`POST /mcp/cli/machine`,
48
+ `{ "attestation": … }` for `<origin>/mcp/cli/machine`): nothing to approve and no new sign-in, and the agents'
49
+ sign-ins stay exactly as they were. That's how a computer set up before 0.18.0 gets its key; until then its hooks
50
+ keep running the copy `init` installed, and nothing changes on it. A Pending You from before machines answers 404,
51
+ taken quietly.
52
+ - `pendingyou machine status` says whether Pending You knows each agent's connection by this key (from
53
+ `/mcp/cli/answers`' `for.machineKey`), and exits 0 only when it knows every one. `machine attest --client-id <id>`
54
+ (`--cnf <thumbprint>`, `--name`) prints an attestation for an app's own device sign-in, and nothing else.
55
+ `machine rotate` makes a new key, after a yes (`--yes`, or at the terminal).
56
+
3
57
  ## 0.17.0 (2026-10-05)
4
58
 
5
59
  - Herdr: an agent running in a Herdr pane (`HERDR_ENV=1` with `HERDR_BIN_PATH` and `HERDR_PANE_ID`) marks its pane with
package/README.md CHANGED
@@ -205,6 +205,13 @@ can", "waiting on you" and the like; or a list under a heading like "Your part",
205
205
  you" or "Left for you". It lets the turn end when the message says nothing needs a card ("No card needed.", "Nothing
206
206
  needs your attention.", "There's nothing waiting on you.").
207
207
 
208
+ Since 0.19.0 it also lets the turn end on a recap of cards already waiting on you. A sentence that says its cards wait
209
+ on you ("Two cards are waiting on you: …", "… (both cards).") covers what it asks, and so does a heading that does
210
+ ("Cards waiting for you:") over its list; anything else for you in the message is checked as before, so a new ask
211
+ beside a recap still goes on a card. And anything else for you, in a message that talks about cards, is taken as a
212
+ recap while this folder has a card waiting on you: only such a message costs a request (this folder's open cards,
213
+ given up after 1.5 seconds), and when Pending You can't say, the turn ends.
214
+
208
215
  Since 0.9.0 it also catches a message asking you to type or reply something in Claude Code's own chat ("Reply “yes,
209
216
  erase disk12” here", "To confirm, type `yes`", "Confirm here"). Some steps an agent takes only on your own words in
210
217
  its conversation, never on a card answer; that's fine, but then a card has to send you there. Unless a card posted in
@@ -284,6 +291,11 @@ It's never in your way: the hooks only start it in the background, each send giv
284
291
  You from before presence is taken quietly. A sign-in that only hears (`--oauth`) sends nothing. `npx pendingyou status`
285
292
  has a Presence line for each agent.
286
293
 
294
+ Since 0.18.0, a session in a [Herdr](https://herdr.dev) pane also says which pane (`HERDR_PANE_ID`, such as `w2:p1`)
295
+ and which Herdr server (16 hex characters of a hash of the computer's name and Herdr's socket path, never the path
296
+ itself), so Pending You can list where each of an agent's running sessions is and the Pending You plugin for Herdr can
297
+ take you to it. Anywhere else nothing of the kind is sent.
298
+
287
299
  ## In Herdr
288
300
 
289
301
  Since 0.17.0, an agent running in a [Herdr](https://herdr.dev) pane marks its pane with what it's waiting on you for,
@@ -401,6 +413,29 @@ Only want this command's own sign-in (for `watch`, below)? `npx pendingyou login
401
413
  hearing answers only, and adds no connection. Your phone asks "Let build-01 hear your answers for the assistants
402
414
  running on it?", with no choice of assistant.
403
415
 
416
+ ## This computer's key
417
+
418
+ The first time `init` (or `login`) signs anything in, it makes this computer a key pair, kept in
419
+ `~/.config/pendingyou/machine.json`: readable only by you, beside your sign-ins. Pending You knows the computer by that
420
+ key, not by its name: two computers called "MacBook Pro" are two computers, and one you rename is still one. The
421
+ private key never leaves this computer and is never printed; Pending You keeps only its public key's fingerprint.
422
+
423
+ Every sign-in with a code proves the key, for that sign-in only and only once, so pressing Allow adds this computer,
424
+ by its key, with the agents' connections it makes. A computer set up before 0.18.0 gets its key the next time you run
425
+ `npx -y pendingyou@latest init`: each agent already signed in proves it with its own sign-in, with nothing to approve,
426
+ and its sign-ins stay exactly as they are. Until then nothing changes on it. Pending You uses the key to keep your
427
+ computers apart: an app you sign in on one computer later (such as the Pending You plugin for Herdr) sees only that
428
+ computer's agents.
429
+
430
+ - `npx pendingyou machine status` says whether Pending You knows each agent's connection here by this key.
431
+ - `npx pendingyou machine rotate` makes a new key (it asks first; `--yes` doesn't). Pending You then knows this
432
+ computer as a new one, and its agents' connections stay with the old key until you remove that computer from your
433
+ account; the next `init` then proves the new one.
434
+ - `npx pendingyou machine attest --client-id <id>` prints a proof of this computer for an app's own sign-in with a code
435
+ (`--cnf <thumbprint>` for that app's own key, `--name` for another name). It never prints the key.
436
+
437
+ `uninstall` leaves the key: it names the computer and grants nothing on its own.
438
+
404
439
  ## Always-on scripts
405
440
 
406
441
  `watch` runs a command each time an answer is ready, for a script on your assistant's own machine:
@@ -431,6 +466,7 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
431
466
  | `uninstall` | Remove what `init` added, and sign out (`--app`) |
432
467
  | `mcp --app <app>` | The stdio bridge: a local MCP server for an agent that can't run a headers helper (OpenCode, Pi), which passes each message on to Pending You with that agent's own sign-in here (`--check`) |
433
468
  | `herdr doctor` | Pending You in Herdr: the badges, the plugin, its toasts and its sidebar row. The plugin runs `herdr next`, `open`, `setup`, `view` and `unconfigure`; the agents' writers `herdr report` |
469
+ | `machine status` / `attest` / `rotate` | This computer's key: whether Pending You knows each agent's connection by it (exits 0 when it does), a proof of it for an app's own sign-in (`--client-id`, `--cnf`, `--name`), or a new key (`--yes`) |
434
470
 
435
471
  Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`). `--app` takes
436
472
  `claude-code`, `codex`, `opencode` or `pi` (several with commas where a command sets up or reports several); with none,
@@ -446,7 +482,8 @@ answer or change anything, and doesn't show up as an assistant. They're stored i
446
482
  (readable only by you, in a folder with a `.gitignore` of `*` so a dotfiles repository never takes it), refresh
447
483
  themselves, and end after 30 days or on `logout`. The headers helper hands an agent's token only to that agent's
448
484
  Pending You (Claude Code says which server it's for; Codex's is the one `init` wrote). `clients.json` remembers which
449
- sign-in each agent's connection used, so signing in again keeps it.
485
+ sign-in each agent's connection used, so signing in again keeps it. `machine.json` (0.18.0) holds this computer's key
486
+ pair, private like the rest; only its proofs leave the computer, each for one sign-in, once.
450
487
 
451
488
  `state.json` beside them remembers which answers were already handed over (ids only) and how many session starts
452
489
  reminded an unfinished setup, and when; `permission-cards/` each Claude Code session's (and Codex thread's) prompts
@@ -461,7 +498,8 @@ command line. `init`, `status` and the hooks read `~/.claude.json` (and a folder
461
498
  whether the agent has a `pendingyou` MCP server and where it points. In a Herdr pane (0.17.0) the agents' writers hand
462
499
  Herdr's own command line, on this computer, the badges above: a card's title (redacted, 80 characters at most) and its
463
500
  request id, never an answer or a note; `<app>-threads.json` remembers the pane each session runs in, and `herdr.json`
464
- whether the badges are off. With a sign-in that only hears, the hooks and
501
+ whether the badges are off. Presence there (0.18.0) also sends Pending You the pane's id and the hash that names its
502
+ Herdr server. With a sign-in that only hears, the hooks and
465
503
  listeners tell this computer's handed questions from another's by its names: its hostname, the names its sign-ins by
466
504
  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
467
505
  the end of the Claude Code session's transcript only for the name the session gave Pending You, and `git remote
package/dist/api.js CHANGED
@@ -127,7 +127,13 @@ function either(signal, ms) {
127
127
  timeout.addEventListener('abort', abort, { once: true });
128
128
  return controller.signal;
129
129
  }
130
- export async function getJson(io, origin, path, timeoutMs, signal, options = {}) {
130
+ export function getJson(io, origin, path, timeoutMs, signal, options = {}) {
131
+ return callJson(io, origin, path, null, timeoutMs, signal, options);
132
+ }
133
+ export function sendJson(io, origin, path, body, timeoutMs, options = {}) {
134
+ return callJson(io, origin, path, { body }, timeoutMs, undefined, options);
135
+ }
136
+ async function callJson(io, origin, path, send, timeoutMs, signal, options = {}) {
131
137
  const app = options.app ?? DEFAULT_APP;
132
138
  for (let attempt = 0;; attempt++) {
133
139
  let token;
@@ -147,7 +153,12 @@ export async function getJson(io, origin, path, timeoutMs, signal, options = {})
147
153
  let response;
148
154
  try {
149
155
  response = await io.fetch(`${origin}${path}`, {
150
- headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
156
+ ...(send ? { method: 'POST', body: JSON.stringify(send.body), redirect: 'manual' } : {}),
157
+ headers: {
158
+ authorization: `Bearer ${token}`,
159
+ accept: 'application/json',
160
+ ...(send ? { 'content-type': 'application/json' } : {}),
161
+ },
151
162
  signal: either(signal, timeoutMs),
152
163
  });
153
164
  }
package/dist/args.js CHANGED
@@ -2,6 +2,7 @@ import { APP_IDS, APP_NAMES, DEFAULT_APP, isAppId } from "./apps/ids.js";
2
2
  export const DEFAULT_ORIGIN = 'https://www.pendingyou.com';
3
3
  export const DEFAULT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
4
4
  const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
5
+ export const MACHINE_SUBS = ['status', 'attest', 'rotate'];
5
6
  export const HERDR_SUBS = [
6
7
  'report',
7
8
  'next',
@@ -80,6 +81,7 @@ const COMMANDS = new Set([
80
81
  'permission-card',
81
82
  'presence',
82
83
  'herdr',
84
+ 'machine',
83
85
  ]);
84
86
  const FLAGS = {
85
87
  help: {},
@@ -148,6 +150,7 @@ const FLAGS = {
148
150
  wait: false,
149
151
  ensure: false,
150
152
  },
153
+ machine: { origin: true, 'client-id': true, cnf: true, name: true, yes: false },
151
154
  };
152
155
  const VALUE_OPTIONS = new Set([
153
156
  'origin',
@@ -162,6 +165,8 @@ const VALUE_OPTIONS = new Set([
162
165
  'transcript',
163
166
  'pid',
164
167
  'at',
168
+ 'client-id',
169
+ 'cnf',
165
170
  ]);
166
171
  function splitCommand(argv) {
167
172
  let index = 0;
@@ -410,6 +415,8 @@ export function parseArgs(argv, env = {}) {
410
415
  return { name: 'mcp', origin, ...app(), check: flags.has('check') };
411
416
  case 'herdr':
412
417
  return herdrCommand(origin, positional, flags, app);
418
+ case 'machine':
419
+ return machineCommand(origin, positional, flags, machine);
413
420
  case 'init':
414
421
  none();
415
422
  return {
@@ -525,6 +532,43 @@ function herdrCommand(origin, positional, flags, app) {
525
532
  return command;
526
533
  }
527
534
  }
535
+ const CLIENT_ID = /^[\x21-\x7e]{1,2048}$/;
536
+ const THUMBPRINT = /^[A-Za-z0-9_-]{43}$/;
537
+ function machineCommand(origin, positional, flags, machine) {
538
+ const [sub, ...extra] = positional;
539
+ if (sub === undefined)
540
+ throw new UsageError('machine needs what to do, like: pendingyou machine status');
541
+ if (!MACHINE_SUBS.includes(sub))
542
+ throw new UsageError(`machine has no “${sub}”. Use ${MACHINE_SUBS.join(', ')}.`);
543
+ if (extra.length)
544
+ throw new UsageError(`machine ${sub} takes no “${extra[0]}”.`);
545
+ const allowed = {
546
+ status: [],
547
+ attest: ['client-id', 'cnf', 'name'],
548
+ rotate: ['yes'],
549
+ };
550
+ for (const key of flags.keys())
551
+ if (key !== 'origin' && !allowed[sub].includes(key))
552
+ throw new UsageError(`machine ${sub} has no --${key} option.`);
553
+ if (sub === 'status')
554
+ return { name: 'machine', origin, sub };
555
+ if (sub === 'rotate')
556
+ return { name: 'machine', origin, sub, yes: flags.has('yes') };
557
+ const clientId = flags.get('client-id');
558
+ if (typeof clientId !== 'string' || !CLIENT_ID.test(clientId))
559
+ throw new UsageError('machine attest needs the client the app signs in with, like: --client-id <its client id>');
560
+ const cnf = flags.get('cnf');
561
+ if (cnf !== undefined && (typeof cnf !== 'string' || !THUMBPRINT.test(cnf)))
562
+ throw new UsageError('--cnf is the app’s own key’s RFC 7638 thumbprint: 43 base64url characters.');
563
+ return {
564
+ name: 'machine',
565
+ origin,
566
+ sub: 'attest',
567
+ clientId,
568
+ ...(typeof cnf === 'string' ? { cnf } : {}),
569
+ ...machine(),
570
+ };
571
+ }
528
572
  export const USAGE = `pendingyou: Pending You for your coding agents (Claude Code, Codex, OpenCode and Pi)
529
573
 
530
574
  npx -y pendingyou@latest init
@@ -553,6 +597,11 @@ export const USAGE = `pendingyou: Pending You for your coding agents (Claude Cod
553
597
  npx pendingyou herdr doctor Pending You in Herdr: the badges on your agents' panes, the plugin, its
554
598
  toasts and its sidebar row. The plugin runs herdr next, open, setup,
555
599
  view and unconfigure
600
+ npx pendingyou machine status | attest | rotate
601
+ This computer's key, which Pending You knows it by (init makes it; it
602
+ never leaves the computer): whether Pending You knows each agent's
603
+ connection by it, a proof of it for an app's own sign-in
604
+ (--client-id, --cnf), or a new key (--yes: without asking)
556
605
 
557
606
  Apps (for --app): ${APP_IDS.map((app) => `${app} (${APP_NAMES[app]})`).join(', ')}.
558
607
 
package/dist/herdr.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { isAbsolute, join } from 'node:path';
2
3
  import { configDir, readJson, writeWhole } from "./files.js";
3
4
  import { cut, redactText } from "./redact.js";
@@ -42,6 +43,19 @@ export function herdrPane(env) {
42
43
  return null;
43
44
  return { bin, pane };
44
45
  }
46
+ export function herdrServer(env, host) {
47
+ const socket = env.HERDR_SOCKET_PATH ?? '';
48
+ if (!socket || !isAbsolute(socket) || /[\0\r\n]/.test(socket))
49
+ return null;
50
+ return createHash('sha256').update(`${host}${socket}`).digest('hex').slice(0, 16);
51
+ }
52
+ export function herdrTerminal(env, host) {
53
+ const pane = herdrPane(env);
54
+ if (!pane)
55
+ return null;
56
+ const server = herdrServer(env, host);
57
+ return { app: 'herdr', paneId: pane.pane, ...(server ? { server } : {}) };
58
+ }
45
59
  const settingsPath = (io) => join(configDir(io), 'herdr.json');
46
60
  export async function badgesOn(io) {
47
61
  const settings = await readJson(settingsPath(io)).catch(() => null);
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { getJson, SignInNeeded, Unavailable } from "./api.js";
2
2
  export { DEFAULT_ORIGIN, parseArgs } from "./args.js";
3
3
  export { readCredential, updateCredential } from "./credentials.js";
4
+ export { attest, ensureMachine, machinePath, proveConnections, readMachine, thumbprintOf, } from "./machine.js";
4
5
  export { main } from "./main.js";
5
6
  export { CLIENT_NAME, deviceLink, discover, register, revoke, SCOPE, signIn, signInWithDevice, startDevice, waitForDevices, } from "./oauth.js";
6
7
  export { qrCode, qrLines } from "./qr.js";
package/dist/init.js CHANGED
@@ -5,6 +5,7 @@ import { hookCommand } from "./claude.js";
5
5
  import { readCredential, saveConnectionClient } from "./credentials.js";
6
6
  import { hookLine, originArgs } from "./hooks.js";
7
7
  import { installCli, pruneCli, removeCli } from "./install.js";
8
+ import { keyForSignIn, proveConnections } from "./machine.js";
8
9
  import { headlessReason, helperCommand, removeHelper, writeHelper } from "./remote.js";
9
10
  import { FINISH_SAY } from "./setup.js";
10
11
  import { removeShim, shimPath, writeShim } from "./shim.js";
@@ -145,12 +146,16 @@ export async function init(io, options) {
145
146
  if (options.login) {
146
147
  if (ctx.headless && !options.device)
147
148
  io.out(`No browser here (${ctx.headless}), so you approve with a code on your phone.\n`);
149
+ const key = await keyForSignIn(io);
148
150
  signed = await signInApps(io, going.map((each) => ({ app: each.app.id, kind: each.signIn })), {
149
151
  origin: options.origin,
150
152
  device: options.device,
151
153
  browser: options.browser,
152
154
  ...(options.machine ? { machine: options.machine } : {}),
155
+ key,
153
156
  });
157
+ if (key)
158
+ await proveConnections(io, options.origin, [...signed].flatMap(([app, how]) => (how === 'already' ? [app] : [])), key);
154
159
  }
155
160
  else
156
161
  io.out(going.length === 1
@@ -0,0 +1,101 @@
1
+ import { APP_NAMES } from "./apps/ids.js";
2
+ import { readCredential, signedInSlots } from "./credentials.js";
3
+ import { originArgs } from "./hooks.js";
4
+ import { attest, clockOffset, machinePath, readMachine, rotateMachine } from "./machine.js";
5
+ import { computerName } from "./remote.js";
6
+ import { checkSignIn } from "./signin.js";
7
+ export async function machineStatus(io, origin) {
8
+ const flag = originArgs(origin);
9
+ const machine = await readMachine(io);
10
+ if (!machine) {
11
+ io.out(`This computer has no key yet. npx -y pendingyou@latest init${flag} makes one, and proves it to Pending You.\n`);
12
+ return 1;
13
+ }
14
+ const lines = [
15
+ `This computer’s key: ${machinePath(io)} (made ${machine.createdAt.slice(0, 10)})`,
16
+ `Its fingerprint: ${machine.thumbprint}`,
17
+ ];
18
+ let ready = true;
19
+ const apps = [];
20
+ for (const slot of await signedInSlots(io))
21
+ if (slot.origin === origin &&
22
+ (await readCredential(io, origin, slot.app))?.kind === 'connection')
23
+ apps.push(slot.app);
24
+ lines.push(apps.length
25
+ ? `At ${origin}:`
26
+ : `No agent here is signed in to ${origin} with a connection of its own, so there’s nothing to prove there.`);
27
+ for (const app of apps) {
28
+ const check = await checkSignIn(io, origin, app);
29
+ const name = APP_NAMES[app];
30
+ if (check.state !== 'ok') {
31
+ ready = false;
32
+ lines.push(check.state === 'unreachable'
33
+ ? ` ${name}: Pending You couldn’t be reached.`
34
+ : ` ${name}: not signed in; run npx -y pendingyou@latest init${flag}.`);
35
+ continue;
36
+ }
37
+ const key = check.for?.machineKey;
38
+ if (key === machine.thumbprint) {
39
+ lines.push(` ${name}: known by this key${check.for?.machine ? ` (as ${check.for.machine})` : ''}.`);
40
+ continue;
41
+ }
42
+ ready = false;
43
+ lines.push(key
44
+ ? ` ${name}: known by another key, this computer’s before it was replaced. Once that computer is removed from your Pending You account, npx -y pendingyou@latest init${flag} proves this one.`
45
+ : ` ${name}: not yet. npx -y pendingyou@latest init${flag} proves this key, with nothing to approve.`);
46
+ }
47
+ io.out(`${lines.join('\n')}\n`);
48
+ return ready ? 0 : 1;
49
+ }
50
+ export async function machineAttest(io, command) {
51
+ const machine = await readMachine(io);
52
+ if (!machine) {
53
+ io.err(`pendingyou: This computer has no key yet. Run npx -y pendingyou@latest init${originArgs(command.origin)} first.\n`);
54
+ return 1;
55
+ }
56
+ const name = command.machine ?? (await computerName(io)).name;
57
+ const offset = await clockOffset(io, command.origin);
58
+ const attestation = await attest(machine, {
59
+ aud: `${command.origin}/oauth/device`,
60
+ client_id: command.clientId,
61
+ name,
62
+ ...(command.cnf ? { cnf: { jkt: command.cnf } } : {}),
63
+ }, io.now() + offset);
64
+ io.out(`${attestation}\n`);
65
+ return 0;
66
+ }
67
+ export async function machineRotate(io, command) {
68
+ const flag = originArgs(command.origin);
69
+ const had = await readMachine(io).then((machine) => machine !== null, () => true);
70
+ if (had && !command.yes) {
71
+ if (!io.interactive) {
72
+ io.err('pendingyou: This replaces this computer’s key, and Pending You will know it as a new computer. Run it again with --yes to go ahead.\n');
73
+ return 1;
74
+ }
75
+ const answer = await io.ask('Replace this computer’s key? Pending You will know it as a new computer. [y/N] ');
76
+ if (!/^y(es)?$/i.test(answer.trim())) {
77
+ io.out('Kept this computer’s key.\n');
78
+ return 1;
79
+ }
80
+ }
81
+ const { before, machine } = await rotateMachine(io);
82
+ const lines = [
83
+ `New key for this computer: ${machinePath(io)}`,
84
+ `Its fingerprint: ${machine.thumbprint}${before ? ` (it was ${before})` : ''}`,
85
+ before
86
+ ? `Pending You knows this computer’s connections by the old key until that computer is removed from your account; then npx -y pendingyou@latest init${flag} proves this one for them.`
87
+ : `npx -y pendingyou@latest init${flag} proves it to Pending You.`,
88
+ ];
89
+ io.out(`${lines.join('\n')}\n`);
90
+ return 0;
91
+ }
92
+ export function machine(io, command) {
93
+ switch (command.sub) {
94
+ case 'status':
95
+ return machineStatus(io, command.origin);
96
+ case 'attest':
97
+ return machineAttest(io, command);
98
+ case 'rotate':
99
+ return machineRotate(io, command);
100
+ }
101
+ }
@@ -0,0 +1,151 @@
1
+ import { createHash, randomBytes, webcrypto } from 'node:crypto';
2
+ import { chmod, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { sendJson } from "./api.js";
5
+ import { connectionMachine, ignoreFolder, readCredential } from "./credentials.js";
6
+ import { PlainError } from "./errors.js";
7
+ import { configDir, readJson, withLock, writeWhole } from "./files.js";
8
+ import { discover } from "./oauth.js";
9
+ import { machineOf } from "./remote.js";
10
+ export const ATTESTATION_TYPE = 'pendingyou-machine+jwt';
11
+ const ATTESTATION_SECONDS = 300;
12
+ export const MACHINE_PATH = '/mcp/cli/machine';
13
+ export const machinePath = (io) => join(configDir(io), 'machine.json');
14
+ const base64url = (bytes) => Buffer.from(bytes).toString('base64url');
15
+ const isCoordinate = (value) => typeof value === 'string' &&
16
+ /^[A-Za-z0-9_-]{43}$/.test(value) &&
17
+ Buffer.from(value, 'base64url').length === 32;
18
+ export function thumbprintOf(jwk) {
19
+ return createHash('sha256')
20
+ .update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }))
21
+ .digest('base64url');
22
+ }
23
+ function keyOf(value) {
24
+ if (typeof value !== 'object' || value === null)
25
+ return null;
26
+ const { kty, crv, x, y, d } = value;
27
+ if (kty !== 'EC' || crv !== 'P-256' || !isCoordinate(x) || !isCoordinate(y) || !isCoordinate(d))
28
+ return null;
29
+ return { kty, crv, x, y, d };
30
+ }
31
+ async function loaded(key, createdAt) {
32
+ const signer = await webcrypto.subtle.importKey('jwk', key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
33
+ const jwk = { kty: key.kty, crv: key.crv, x: key.x, y: key.y };
34
+ return { thumbprint: thumbprintOf(jwk), createdAt, jwk, signer };
35
+ }
36
+ const unreadable = (path) => new PlainError(`This computer’s key (${path}) can’t be read. Run npx pendingyou machine rotate to make a new one.`);
37
+ export async function readMachine(io) {
38
+ const path = machinePath(io);
39
+ let file;
40
+ try {
41
+ file = await readJson(path);
42
+ }
43
+ catch {
44
+ throw unreadable(path);
45
+ }
46
+ if (file === null)
47
+ return null;
48
+ const key = file?.version === 1 && typeof file.createdAt === 'string' ? keyOf(file.key) : null;
49
+ if (!key || typeof file?.createdAt !== 'string')
50
+ throw unreadable(path);
51
+ if ((await stat(path)).mode & 0o077)
52
+ await chmod(path, 0o600);
53
+ try {
54
+ return await loaded(key, file.createdAt);
55
+ }
56
+ catch {
57
+ throw unreadable(path);
58
+ }
59
+ }
60
+ async function writeNew(io) {
61
+ const pair = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
62
+ 'sign',
63
+ 'verify',
64
+ ]);
65
+ const key = keyOf(await webcrypto.subtle.exportKey('jwk', pair.privateKey));
66
+ if (!key)
67
+ throw new PlainError('This computer’s key couldn’t be made.');
68
+ const createdAt = new Date(io.now()).toISOString();
69
+ const file = {
70
+ version: 1,
71
+ createdAt,
72
+ key: { kty: key.kty, crv: key.crv, x: key.x, y: key.y, d: key.d },
73
+ };
74
+ await writeWhole(machinePath(io), `${JSON.stringify(file, null, 2)}\n`, { secret: true });
75
+ await ignoreFolder(io).catch(() => { });
76
+ return loaded(key, createdAt);
77
+ }
78
+ export async function ensureMachine(io) {
79
+ return withLock(machinePath(io), io, async () => {
80
+ const machine = await readMachine(io);
81
+ return machine ? { machine, made: false } : { machine: await writeNew(io), made: true };
82
+ });
83
+ }
84
+ export async function rotateMachine(io) {
85
+ return withLock(machinePath(io), io, async () => {
86
+ const before = await readMachine(io).catch(() => null);
87
+ return { before: before?.thumbprint ?? null, machine: await writeNew(io) };
88
+ });
89
+ }
90
+ export async function attest(machine, claims, now) {
91
+ const iat = Math.floor(now / 1000);
92
+ const header = { typ: ATTESTATION_TYPE, alg: 'ES256', jwk: machine.jwk };
93
+ const payload = {
94
+ ...claims,
95
+ iat,
96
+ exp: iat + ATTESTATION_SECONDS,
97
+ jti: randomBytes(16).toString('base64url'),
98
+ };
99
+ const signing = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
100
+ const signature = await webcrypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, machine.signer, Buffer.from(signing));
101
+ return `${signing}.${base64url(new Uint8Array(signature))}`;
102
+ }
103
+ export async function keyForSignIn(io) {
104
+ try {
105
+ const { machine, made } = await ensureMachine(io);
106
+ if (made)
107
+ io.out(`Made this computer’s key for Pending You (${machinePath(io)}): it stays on this computer, and Pending You knows the computer by it.\n`);
108
+ return machine;
109
+ }
110
+ catch (error) {
111
+ io.err(`pendingyou: ${error instanceof PlainError ? error.message : 'This computer’s key couldn’t be made.'} Signing in without it.\n`);
112
+ return null;
113
+ }
114
+ }
115
+ export async function clockOffset(io, origin) {
116
+ return discover(io, origin, 5000).then((metadata) => metadata.clockOffsetMs ?? 0, () => 0);
117
+ }
118
+ export async function proveConnections(io, origin, apps, machine) {
119
+ const results = new Map();
120
+ const connected = [];
121
+ for (const app of apps) {
122
+ const credential = await readCredential(io, origin, app);
123
+ if (credential?.kind === 'connection')
124
+ connected.push({ app, clientId: credential.clientId });
125
+ }
126
+ if (connected.length === 0)
127
+ return results;
128
+ const offset = await clockOffset(io, origin);
129
+ for (const { app, clientId } of connected) {
130
+ try {
131
+ const attestation = await attest(machine, {
132
+ aud: `${origin}${MACHINE_PATH}`,
133
+ client_id: clientId,
134
+ name: (await connectionMachine(io, origin, app)) ?? machineOf(io.host),
135
+ }, io.now() + offset);
136
+ const { status, body } = await sendJson(io, origin, MACHINE_PATH, { attestation }, 15_000, { app });
137
+ const said = body.connection;
138
+ results.set(app, status === 200 && (said === 'joined' || said === 'already' || said === 'elsewhere')
139
+ ? said
140
+ : status === 404
141
+ ? 'absent'
142
+ : status >= 400 && status < 500
143
+ ? 'refused'
144
+ : 'failed');
145
+ }
146
+ catch {
147
+ results.set(app, 'failed');
148
+ }
149
+ }
150
+ return results;
151
+ }
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import { PlainError } from "./errors.js";
8
8
  import { herdr } from "./herdr/command.js";
9
9
  import { hold } from "./hold.js";
10
10
  import { init, status, uninstall } from "./init.js";
11
+ import { machine } from "./machine-command.js";
11
12
  import { OAuthError } from "./oauth.js";
12
13
  import { isPromptApp, permissionHook, permissionWorker } from "./permission.js";
13
14
  import { pickup } from "./pickup.js";
@@ -95,6 +96,8 @@ async function run(io, command, hook = { fallback: '' }) {
95
96
  return bridge(io, command);
96
97
  case 'herdr':
97
98
  return herdr(io, command);
99
+ case 'machine':
100
+ return machine(io, command);
98
101
  }
99
102
  }
100
103
  export const HOOK_DEADLINE_MS = 3000;
package/dist/oauth.js CHANGED
@@ -14,6 +14,7 @@ export class OAuthError extends Error {
14
14
  this.code = code;
15
15
  }
16
16
  }
17
+ export const serverNow = (io, metadata) => io.now() + (metadata.clockOffsetMs ?? 0);
17
18
  const base64url = (bytes) => bytes.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
18
19
  export function pkce() {
19
20
  const verifier = base64url(randomBytes(32));
@@ -28,7 +29,11 @@ export async function discover(io, origin, timeoutMs = 15_000) {
28
29
  });
29
30
  if (!response.ok)
30
31
  throw new OAuthError('discovery', `Pending You at ${origin} didn’t answer (${response.status}).`);
31
- const metadata = (await response.json());
32
+ const said = Date.parse(response.headers.get('date') ?? '');
33
+ const metadata = {
34
+ ...(await response.json()),
35
+ ...(io.now && Number.isFinite(said) ? { clockOffsetMs: said - io.now() } : {}),
36
+ };
32
37
  const sameOrigin = (url) => {
33
38
  try {
34
39
  return url !== undefined && new URL(url).origin === origin;
@@ -218,17 +223,23 @@ export async function revoke(io, origin, clientId, refreshToken) {
218
223
  export async function startDevice(io, options) {
219
224
  const metadata = await discover(io, options.origin);
220
225
  const assistant = options.assistant ?? (options.connection ? 'claude-code' : undefined);
221
- const begin = (client) => io.fetch(`${options.origin}/oauth/device`, {
222
- method: 'POST',
223
- headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
224
- body: new URLSearchParams({
225
- client_id: client,
226
- scope: SCOPE,
227
- ...(options.machine ? { machine: options.machine } : {}),
228
- ...(assistant ? { assistant } : {}),
229
- }).toString(),
230
- signal: AbortSignal.timeout(15_000),
231
- });
226
+ const begin = async (client) => {
227
+ const attestation = options.attest
228
+ ? await options.attest(client, serverNow(io, metadata))
229
+ : null;
230
+ return io.fetch(`${options.origin}/oauth/device`, {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
233
+ body: new URLSearchParams({
234
+ client_id: client,
235
+ scope: SCOPE,
236
+ ...(options.machine ? { machine: options.machine } : {}),
237
+ ...(assistant ? { assistant } : {}),
238
+ ...(attestation ? { machine_attestation: attestation } : {}),
239
+ }).toString(),
240
+ signal: AbortSignal.timeout(15_000),
241
+ });
242
+ };
232
243
  let clientId = options.clientId || (await register(io, metadata));
233
244
  let started = await begin(clientId);
234
245
  if (started.status === 401 && options.clientId) {
package/dist/presence.js CHANGED
@@ -6,7 +6,7 @@ import { reportThreads, threadNames } from "./apps/codex-wake.js";
6
6
  import { DEFAULT_ORIGIN } from "./args.js";
7
7
  import { readCredential } from "./credentials.js";
8
8
  import { configDir, readJson, withLock, writeWhole } from "./files.js";
9
- import { herdrTarget, reportCommand } from "./herdr.js";
9
+ import { herdrTarget, herdrTerminal, reportCommand } from "./herdr.js";
10
10
  import { endPrompts, sessionName } from "./permission.js";
11
11
  export const PRESENCE_PATH = '/mcp/cli/presence';
12
12
  export const PRESENCE_EVERY_MS = 5 * 60_000;
@@ -60,13 +60,15 @@ export async function sendPresence(io, origin, presence, timeoutMs = SEND_MS, op
60
60
  try {
61
61
  if ((await readCredential(io, origin, presence.app))?.kind !== 'connection')
62
62
  return 'skipped';
63
- const body = {
63
+ const plain = {
64
64
  source: presence.app,
65
65
  name: presenceName(presence.name),
66
66
  sessionId: presence.session,
67
67
  cwd: presence.cwd && presence.cwd.length <= CWD_MAX ? presence.cwd : null,
68
68
  state: presence.state,
69
69
  };
70
+ const terminal = herdrTerminal(io.env, io.host);
71
+ let body = terminal ? { ...plain, terminal } : plain;
70
72
  const send = (force) => postJson(io, origin, PRESENCE_PATH, body, timeoutMs, {
71
73
  app: presence.app,
72
74
  signal: AbortSignal.timeout(timeoutMs + 2000),
@@ -75,6 +77,10 @@ export async function sendPresence(io, origin, presence, timeoutMs = SEND_MS, op
75
77
  let answer = await send(false);
76
78
  if (answer.status === 401)
77
79
  answer = await send(true);
80
+ if (answer.status === 400 && terminal) {
81
+ body = plain;
82
+ answer = await send(false);
83
+ }
78
84
  if (answer.status === 429 && options.patient) {
79
85
  await io.sleep(Math.min(answer.retryAfter ?? 60, RETRY_AFTER_MAX_S) * 1000, io.signal);
80
86
  answer = await send(false);
package/dist/signin.js CHANGED
@@ -3,6 +3,7 @@ import { APP_NAMES, DEFAULT_APP, namesOf } from "./apps/ids.js";
3
3
  import { connectionClient, connectionMachine, readCredential, saveConnectionClient, signedInSlots, updateCredential, } from "./credentials.js";
4
4
  import { PlainError } from "./errors.js";
5
5
  import { originArgs } from "./hooks.js";
6
+ import { attest, keyForSignIn } from "./machine.js";
6
7
  import { OAuthError, revoke, signIn, startDevice, waitForDevices, } from "./oauth.js";
7
8
  import { computerName, headlessReason, machineOf } from "./remote.js";
8
9
  import { connectionTitle } from "./setup.js";
@@ -56,12 +57,17 @@ export async function signInApps(io, needs, options) {
56
57
  return result;
57
58
  }
58
59
  const named = await computerName(io, options.machine);
60
+ const key = options.key === undefined ? await keyForSignIn(io) : options.key;
61
+ const vouch = key
62
+ ? (clientId, now) => attest(key, { aud: `${origin}/oauth/device`, client_id: clientId, name: named.name }, now).catch(() => null)
63
+ : undefined;
59
64
  const started = [];
60
65
  for (const need of todo) {
61
66
  try {
62
67
  const flow = await startDevice(io, {
63
68
  origin,
64
69
  machine: named.name,
70
+ ...(vouch ? { attest: vouch } : {}),
65
71
  ...(need.kind === 'connection'
66
72
  ? { assistant: need.app, clientId: await connectionClient(io, origin, need.app) }
67
73
  : {}),
package/dist/stopcheck.js CHANGED
@@ -5,17 +5,35 @@ import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
5
5
  import { asksToTypeHere, cardFits, confirmReason, sessionLink } from "./confirm.js";
6
6
  import { readCredential } from "./credentials.js";
7
7
  import { isHandedWake } from "./format.js";
8
- import { isPromptApp, settlePrompts, subagentsRunning } from "./permission.js";
8
+ import { isPermissionCard, isPromptApp, settlePrompts, subagentsRunning } from "./permission.js";
9
9
  import { folderForms } from "./pickup.js";
10
10
  export const READ_CAP = 4 * 1024 * 1024;
11
11
  const CHUNK = 64 * 1024;
12
12
  export const STOP_REASON = 'Items for your person are in chat but not in Pending You: post them as cards (ask in both places), or say they don’t need one.';
13
13
  export const DELEGATED_REASON = 'Your person handed you that question to answer, not to ask them back: answer_delegated if you know, or hand_back with what you checked, unless you already did. Never post it as a card of your own.';
14
14
  const POSTING_TOOL = /^mcp__.+__(post_request|update_request|reply_in_thread|answer_delegated|hand_back)$/;
15
- const OPEN_TIME_BOX_MS = 1500;
15
+ export const OPEN_TIME_BOX_MS = 1500;
16
16
  const OPEN_CARDS = 5;
17
17
  const CARD_TEXT = 20_000;
18
- const SAYS_POSTED = /\b(posted|put|added|raised|filed)\b[^.\n]{0,60}\b(cards?|pending ?you)\b|\bon (a|the|its|their|your|that|this) cards?\b|\b(in|to|through|via|on) pending ?you\b|\bpending ?you cards?\b|\b(i|we|you)(?:(?:'|’)ll| will)\s+(?:\w+\s+)?(post|send|put|add|raise|file|get|see|find)\b[^.\n]{0,40}\bcards?\b/i;
18
+ const SAYS_POSTED = new RegExp([
19
+ String.raw `\b(posted|put|added|raised|filed)\b[^.\n]{0,60}\b(cards?|pending ?you)\b`,
20
+ String.raw `\bon (a|the|its|their|your|that|this) cards?\b`,
21
+ String.raw `\b(in|to|through|via|on) pending ?you\b`,
22
+ String.raw `\bpending ?you cards?\b`,
23
+ String.raw `\b(i|we|you)(?:(?:'|’)ll| will)\s+(?:\w+\s+)?(post|send|put|add|raise|file|get|see|find|make|create|open)\b[^.\n]{0,40}\bcards?\b`,
24
+ String.raw `\balready (?:a |on (?:a )?)?cards?\b`,
25
+ String.raw `\bcards? (?:is|are|will be) (?:coming|on (?:its|their|the) way)\b(?! (?:together|along))`,
26
+ String.raw `\bcards? (?:will|to) follow\b`,
27
+ String.raw `\bthere(?:(?:'|’)ll| will) be (?:a |another |one )?cards?\b`,
28
+ ].join('|'), 'i');
29
+ const RECAP = new RegExp([
30
+ String.raw `\bcards?\b[^.!?\n]{0,40}?\b(?:(?:waiting|pending|open|parked|queued|left)\b[^.!?\n]{0,20}?\b(?:on|for)\s+your?|in\s+your\s+queue)\b`,
31
+ String.raw `\b(?:waiting|pending|parked)\s+(?:on|for)\s+you\b[^.!?\n]{0,30}?\b(?:as|in)\s+(?:(?:a|two|both|three|its|their|the|separate)\s+)?cards?\b`,
32
+ String.raw `\((?:(?:both|all|two|three|each)\s+)?(?:(?:are|is|on|as)\s+)?(?:a\s+|(?:its|their)\s+own\s+)?cards?(?:\s+(?:already|too))?\)`,
33
+ ].join('|'), 'i');
34
+ const CARDS_HEADING = /\b(?:your|open|pending|waiting)\s+(?:(?:open|pending|waiting)\s+)?cards\b/i;
35
+ const SENTENCE_END = /(?<=[.!?])\s+/;
36
+ const OTHER_CARDS = /\b(?:credit|debit|payment|gift|sim|sd|business|graphics|video|sound|network|memory|smart)\s+cards?\b|\bcards?\s+(?:numbers?|readers?|details|holders?)\b/gi;
19
37
  const STEPS_FOR_THEM = /\b(you(?:'|’)ll need to|you will need to|you(?:'|’)ll have to|you will have to|your (?:next )?steps?\b|can you|could you|would you mind|please (?:run|confirm|approve|check|add|set|sign|create|open|paste|reply|send|choose|pick|decide|let me know)|in your terminal|only you can|(?<!\b(?:no|nothing|none|not)\b[^.,;:!?\n]{0,40})waiting (?:on|for) you|(?<!\bif (?:it|he|she|they|that|this|anything|something|someone|anyone|one) )needs? you to|over to you|parked for you|when you(?:'|’)re ready|up to you|your call\b|is yours\b|say the word|let me know (?:which|whether)|tell me (?:which|whether)|needs? your (?:ok|okay|approval|decision|sign-?off|go-ahead))/i;
20
38
  const SAYS_NO_CARD = /\bno (?:new |more |further )?cards? (?:is |are )?(?:needed|necessary|required)\b|\b(?:doesn(?:'|’)t|don(?:'|’)t|does not|do not|won(?:'|’)t|will not) need (?:a |any )?(?:new )?cards?\b|\bnothing (?:here |else |more |now )?(?:needs|requires) your (?:attention|input|action|answer)\b|\b(?:nothing|no cards?)\b[^.,;:!?\n]{0,40}\b(?:waiting|pending) (?:on|for) you\b/i;
21
39
  const FOR_THEM_HEADING = /\b(your part|your steps?|your to-?dos?|your turn|your call|for you|waiting on you|left for you|parked for you|needs you|over to you|what you need to do|you(?:'|’)ll need)\b/i;
@@ -47,8 +65,7 @@ function headingOf(line) {
47
65
  return null;
48
66
  return /^\s*#/.test(line) || bare.endsWith(':') || /^\s*\*\*.+\*\*\s*$/.test(line) ? bare : null;
49
67
  }
50
- export function itemsForPerson(text) {
51
- const plain = plainText(text);
68
+ function itemsIn(plain) {
52
69
  const lines = plain.split('\n');
53
70
  for (let index = 0; index < lines.length; index++) {
54
71
  const heading = headingOf(lines[index]);
@@ -65,8 +82,50 @@ export function itemsForPerson(text) {
65
82
  return 'question';
66
83
  return null;
67
84
  }
85
+ export function itemsForPerson(text) {
86
+ return itemsIn(plainText(text));
87
+ }
88
+ function listEnd(lines, index) {
89
+ let end = index;
90
+ for (let next = index + 1; next < lines.length; next++) {
91
+ const line = lines[next];
92
+ if (!line.trim()) {
93
+ if (end === index && next - index >= 3)
94
+ return index;
95
+ }
96
+ else if (LIST_ITEM.test(line) || (end > index && /^\s/.test(line)))
97
+ end = next;
98
+ else
99
+ break;
100
+ }
101
+ return end;
102
+ }
103
+ function withoutRecaps(plain) {
104
+ const lines = plain.split('\n');
105
+ const kept = [];
106
+ for (let index = 0; index < lines.length; index++) {
107
+ const line = lines[index];
108
+ const heading = headingOf(line);
109
+ if (heading && (RECAP.test(heading) || CARDS_HEADING.test(heading))) {
110
+ const end = listEnd(lines, index);
111
+ if (end > index) {
112
+ index = end;
113
+ continue;
114
+ }
115
+ }
116
+ kept.push(line
117
+ .split(SENTENCE_END)
118
+ .filter((sentence) => !RECAP.test(sentence))
119
+ .join(' '));
120
+ }
121
+ return kept.join('\n');
122
+ }
123
+ export function itemsOutsideRecaps(text) {
124
+ return itemsIn(withoutRecaps(plainText(text)));
125
+ }
68
126
  export const saysPosted = (text) => SAYS_POSTED.test(plainText(text));
69
127
  export const saysNoCard = (text) => SAYS_NO_CARD.test(plainText(text));
128
+ export const mentionsCards = (text) => /\bcards?\b/i.test(plainText(text).replace(OTHER_CARDS, ''));
70
129
  function wordsIn(value, max = CARD_TEXT) {
71
130
  const found = [];
72
131
  let left = max;
@@ -306,18 +365,23 @@ export async function stopcheck(io, options) {
306
365
  return 0;
307
366
  return asksToTypeHere(text) || itemsForPerson(text) ? block(DELEGATED_REASON) : 0;
308
367
  }
368
+ const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : io.cwd;
309
369
  const ask = asksToTypeHere(text);
310
370
  if (ask) {
311
371
  if (saysNoCard(text) || turn.cards.some((card) => cardFits(card, ask.words, 'posted')))
312
372
  return 0;
313
- const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : io.cwd;
314
373
  const open = await openTitles(io, options.origin, cwd, app);
315
374
  if (open ? open.some((title) => cardFits(title, ask.words, 'open')) : saysPosted(text))
316
375
  return 0;
317
376
  return block(confirmReason(ask.words, sessionLink(io.env), APP_NAMES[app]));
318
377
  }
319
- if (turn.posted || saysPosted(text) || saysNoCard(text) || !itemsForPerson(text))
378
+ if (turn.posted || saysPosted(text) || saysNoCard(text) || !itemsOutsideRecaps(text))
320
379
  return 0;
380
+ if (mentionsCards(text)) {
381
+ const open = await openTitles(io, options.origin, cwd, app);
382
+ if (open === null || open.some((title) => !isPermissionCard(title)))
383
+ return 0;
384
+ }
321
385
  return block(STOP_REASON);
322
386
  }
323
387
  catch {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.17.0';
1
+ export const VERSION = '0.19.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",