pendingyou 0.17.0 → 0.18.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 (2026-10-05)
4
+
5
+ - Presence says which terminal: in a Herdr pane (`HERDR_ENV=1` with `HERDR_BIN_PATH` and `HERDR_PANE_ID`), every
6
+ presence report (`POST /mcp/cli/presence`: each session's start, its 5-minute heartbeat and its end, for Claude Code,
7
+ Codex, OpenCode and Pi) also says where the session runs: `"terminal": { "app": "herdr", "paneId": "w2:p1", "server":
8
+ "<16 hex>" }`. `server` names the Herdr server without the path of its socket: the first 16 hex characters of a
9
+ SHA-256 of the computer's name and `HERDR_SOCKET_PATH`, left out when there's no socket path. Pending You keeps it with
10
+ the session and lists it with the agent's sessions running now, so the Pending You plugin for Herdr can go from a card
11
+ to its agent's pane. It goes whether or not the badges are on. Anywhere else a report is exactly as before, with no
12
+ `terminal`. A Pending You from before it refuses the field (400): the same report goes again without it.
13
+ - This computer's key: Pending You knows a computer by a key, not by its name. The first time `init` (or `login`) signs
14
+ anything in, it makes a P-256 key pair in `~/.config/pendingyou/machine.json` (0600, in the 0700 folder with its
15
+ `.gitignore`), shared by every Pending You address. The private key never leaves the computer and is never printed or
16
+ logged; Pending You keeps only its public key's RFC 7638 thumbprint. One that can't be read is never replaced: `init`
17
+ says so and signs in without it.
18
+ - Every device sign-in it starts (`POST /oauth/device`) carries `machine_attestation`: a JWS the key signs (ES256,
19
+ `typ` `pendingyou-machine+jwt`, the public key in its header) for `<origin>/oauth/device`, the client signing in and
20
+ the computer's name, good for 5 minutes and once (`jti`). It's dated by Pending You's own clock (its answer's
21
+ `Date`), so a computer whose clock is off still proves its key. Whoever allows the sign-in enrolls the computer, and
22
+ the connections it makes belong to it. A client registered again (a forgotten one) gets a new attestation of its
23
+ own.
24
+ - On the next `init`, each agent already signed in proves the key with its own sign-in (`POST /mcp/cli/machine`,
25
+ `{ "attestation": … }` for `<origin>/mcp/cli/machine`): nothing to approve and no new sign-in, and the agents'
26
+ 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
27
+ keep running the copy `init` installed, and nothing changes on it. A Pending You from before machines answers 404,
28
+ taken quietly.
29
+ - `pendingyou machine status` says whether Pending You knows each agent's connection by this key (from
30
+ `/mcp/cli/answers`' `for.machineKey`), and exits 0 only when it knows every one. `machine attest --client-id <id>`
31
+ (`--cnf <thumbprint>`, `--name`) prints an attestation for an app's own device sign-in, and nothing else.
32
+ `machine rotate` makes a new key, after a yes (`--yes`, or at the terminal).
33
+
3
34
  ## 0.17.0 (2026-10-05)
4
35
 
5
36
  - 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
@@ -284,6 +284,11 @@ It's never in your way: the hooks only start it in the background, each send giv
284
284
  You from before presence is taken quietly. A sign-in that only hears (`--oauth`) sends nothing. `npx pendingyou status`
285
285
  has a Presence line for each agent.
286
286
 
287
+ Since 0.18.0, a session in a [Herdr](https://herdr.dev) pane also says which pane (`HERDR_PANE_ID`, such as `w2:p1`)
288
+ and which Herdr server (16 hex characters of a hash of the computer's name and Herdr's socket path, never the path
289
+ itself), so Pending You can list where each of an agent's running sessions is and the Pending You plugin for Herdr can
290
+ take you to it. Anywhere else nothing of the kind is sent.
291
+
287
292
  ## In Herdr
288
293
 
289
294
  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 +406,29 @@ Only want this command's own sign-in (for `watch`, below)? `npx pendingyou login
401
406
  hearing answers only, and adds no connection. Your phone asks "Let build-01 hear your answers for the assistants
402
407
  running on it?", with no choice of assistant.
403
408
 
409
+ ## This computer's key
410
+
411
+ The first time `init` (or `login`) signs anything in, it makes this computer a key pair, kept in
412
+ `~/.config/pendingyou/machine.json`: readable only by you, beside your sign-ins. Pending You knows the computer by that
413
+ key, not by its name: two computers called "MacBook Pro" are two computers, and one you rename is still one. The
414
+ private key never leaves this computer and is never printed; Pending You keeps only its public key's fingerprint.
415
+
416
+ Every sign-in with a code proves the key, for that sign-in only and only once, so pressing Allow adds this computer,
417
+ 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
418
+ `npx -y pendingyou@latest init`: each agent already signed in proves it with its own sign-in, with nothing to approve,
419
+ and its sign-ins stay exactly as they are. Until then nothing changes on it. Pending You uses the key to keep your
420
+ computers apart: an app you sign in on one computer later (such as the Pending You plugin for Herdr) sees only that
421
+ computer's agents.
422
+
423
+ - `npx pendingyou machine status` says whether Pending You knows each agent's connection here by this key.
424
+ - `npx pendingyou machine rotate` makes a new key (it asks first; `--yes` doesn't). Pending You then knows this
425
+ computer as a new one, and its agents' connections stay with the old key until you remove that computer from your
426
+ account; the next `init` then proves the new one.
427
+ - `npx pendingyou machine attest --client-id <id>` prints a proof of this computer for an app's own sign-in with a code
428
+ (`--cnf <thumbprint>` for that app's own key, `--name` for another name). It never prints the key.
429
+
430
+ `uninstall` leaves the key: it names the computer and grants nothing on its own.
431
+
404
432
  ## Always-on scripts
405
433
 
406
434
  `watch` runs a command each time an answer is ready, for a script on your assistant's own machine:
@@ -431,6 +459,7 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
431
459
  | `uninstall` | Remove what `init` added, and sign out (`--app`) |
432
460
  | `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
461
  | `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` |
462
+ | `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
463
 
435
464
  Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`). `--app` takes
436
465
  `claude-code`, `codex`, `opencode` or `pi` (several with commas where a command sets up or reports several); with none,
@@ -446,7 +475,8 @@ answer or change anything, and doesn't show up as an assistant. They're stored i
446
475
  (readable only by you, in a folder with a `.gitignore` of `*` so a dotfiles repository never takes it), refresh
447
476
  themselves, and end after 30 days or on `logout`. The headers helper hands an agent's token only to that agent's
448
477
  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.
478
+ sign-in each agent's connection used, so signing in again keeps it. `machine.json` (0.18.0) holds this computer's key
479
+ pair, private like the rest; only its proofs leave the computer, each for one sign-in, once.
450
480
 
451
481
  `state.json` beside them remembers which answers were already handed over (ids only) and how many session starts
452
482
  reminded an unfinished setup, and when; `permission-cards/` each Claude Code session's (and Codex thread's) prompts
@@ -461,7 +491,8 @@ command line. `init`, `status` and the hooks read `~/.claude.json` (and a folder
461
491
  whether the agent has a `pendingyou` MCP server and where it points. In a Herdr pane (0.17.0) the agents' writers hand
462
492
  Herdr's own command line, on this computer, the badges above: a card's title (redacted, 80 characters at most) and its
463
493
  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
494
+ whether the badges are off. Presence there (0.18.0) also sends Pending You the pane's id and the hash that names its
495
+ Herdr server. With a sign-in that only hears, the hooks and
465
496
  listeners tell this computer's handed questions from another's by its names: its hostname, the names its sign-ins by
466
497
  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
498
  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/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.17.0';
1
+ export const VERSION = '0.18.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",