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 +54 -0
- package/README.md +40 -2
- package/dist/api.js +13 -2
- package/dist/args.js +49 -0
- package/dist/herdr.js +14 -0
- package/dist/index.js +1 -0
- package/dist/init.js +5 -0
- package/dist/machine-command.js +101 -0
- package/dist/machine.js +151 -0
- package/dist/main.js +3 -0
- package/dist/oauth.js +23 -12
- package/dist/presence.js +8 -2
- package/dist/signin.js +6 -0
- package/dist/stopcheck.js +71 -7
- package/dist/version.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/machine.js
ADDED
|
@@ -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
|
|
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) =>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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) || !
|
|
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.
|
|
1
|
+
export const VERSION = '0.19.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "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",
|