pendingyou 0.27.0 → 0.29.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.0 (2026-10-07)
4
+
5
+ - The command line links this computer's sign-ins to it by its key, so Pending You names every assistant here by this
6
+ computer and shows one computer, not two. Besides the connections `init` made, it now finds the sign-ins your apps
7
+ made on their own at this Pending You (Claude Code's `/mcp` Authenticate, or the Pending You plugin's server, kept in
8
+ Claude Code's own credentials; Codex's and OpenCode's MCP sign-ins kept in their files) and proves this computer's key
9
+ for each, reading only their OAuth clients, never a token. Pending You links the ones that are yours and on no other
10
+ computer, and merges an app's older sign-ins here into the one in use. Nothing is ever linked by a computer's name.
11
+ `init` links at once; the hooks, `hold`, the MCP headers helper and the wake do it in the background every few hours.
12
+ `npx pendingyou machine link` does it now.
13
+ - Proving the key again no longer renames the computer: a name you gave it in Pending You stays.
14
+
15
+ ## 0.28.0 (2026-10-07)
16
+
17
+ - When a card you asked was handed to another assistant and comes back to your person without its answer (handed
18
+ back, brought back after the two of you traded too many messages or went quiet, taken back, its answer back with
19
+ your person to send, or the helper removed), the session that asked hears it: “Pending You: req_… came back from
20
+ codex-setup to your person.”, Pending You's line saying why, and to ack_answer it (nothing to answer; your person's
21
+ answer reaches you as usual). Codex threads are woken with the same. Before, the card went quiet.
22
+
3
23
  ## 0.27.0 (2026-10-07)
4
24
 
5
25
  - Claude Code's pendingyou MCP server no longer gets stuck on “needs authentication” after a computer sleeps. When it
package/README.md CHANGED
@@ -467,6 +467,12 @@ computer's agents.
467
467
  account; the next `init` then proves the new one.
468
468
  - `npx pendingyou machine attest --client-id <id>` prints a proof of this computer for an app's own sign-in with a code
469
469
  (`--cnf <thumbprint>` for that app's own key, `--name` for another name). It never prints the key.
470
+ - `npx pendingyou machine link` (0.29.0) links this computer's sign-ins to it now: the agents' connections, and the
471
+ sign-ins your apps made on their own (Claude Code's `/mcp` Authenticate or the Pending You plugin, Codex's and
472
+ OpenCode's MCP sign-ins), found by their OAuth clients in those apps' own stores here, never a token. Pending You then
473
+ names them all by this computer and merges an app's older sign-ins here into the one in use. `init` does it, and the
474
+ hooks every few hours in the background; nothing is linked by a computer's name. Rename a computer once, in Settings
475
+ › Apps and devices, and every assistant on it follows.
470
476
 
471
477
  `uninstall` leaves the key: it names the computer and grants nothing on its own.
472
478
 
@@ -500,7 +506,7 @@ its environment. A failing command is tried twice more, then skipped. `--once` s
500
506
  | `uninstall` | Remove what `init` added, and sign out (`--app`) |
501
507
  | `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`) |
502
508
  | `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` |
503
- | `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`) |
509
+ | `machine status` / `attest` / `rotate` / `link` | 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`), a new key (`--yes`), or this computer's sign-ins linked to it now, your apps' own ones too (`--quiet`) |
504
510
  | `app login` / `logout` / `status` | An app that answers your cards when you press a key in it, signed in on this computer (`herdr`, or another app with `--client-id` and `--scope`; `--device`, `--browser`, `--name`), out again, or what each may do and until when |
505
511
 
506
512
  Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`). `--app` takes
@@ -8,6 +8,7 @@ import { configDir, readJson, withLock, writeWhole } from "../files.js";
8
8
  import { APPROVED_DRAFT_NOTICE, approvesDraft, clip, delegatedOf, handledOf, isClosed, noticeOf, } from "../format.js";
9
9
  import { chooseThread, goesBy, HANDED_FOR_MS, handedCard, handedMessage, namesAnotherComputer, } from "../handed.js";
10
10
  import { clearHerdr, herdrTarget, isUrgency, PANE_ID, reportCommand, writeHerdr, } from "../herdr.js";
11
+ import { linkSoon } from "../link.js";
11
12
  import { macName, tailscaleName } from "../remote.js";
12
13
  import { claimHanded, markHanded, momentOf, releaseHanded, wasHanded } from "../state.js";
13
14
  import { findCodex } from "./codex-app.js";
@@ -390,6 +391,11 @@ function line(heard, card) {
390
391
  `your person handed your open cards to ${from}, ${titled} among them`,
391
392
  `stop working on them: ${from} answers them and follows up; ack_answer ${heard.requestId} with its version to say you’ve stopped`,
392
393
  ];
394
+ if (notice?.kind === 'came_back')
395
+ return [
396
+ `${titled} came back from ${from} to your person`,
397
+ `call ${get} to read why (helper.ended), then ack_answer with its version to say you’ve read it; their answer reaches you as usual`,
398
+ ];
393
399
  if (notice?.kind === 'follow_up' && heard.status !== 'answered')
394
400
  return [
395
401
  `${from} followed up on ${titled}, which it answered for your person`,
@@ -558,6 +564,7 @@ export async function listen(io, options) {
558
564
  const lease = await takeLease(io, app, thread);
559
565
  if (!lease)
560
566
  return 0;
567
+ await linkSoon(io, origin);
561
568
  const controller = new AbortController();
562
569
  const stop = () => controller.abort();
563
570
  if (io.signal.aborted)
package/dist/args.js CHANGED
@@ -3,7 +3,7 @@ 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
5
  export const APP_SUBS = ['login', 'logout', 'status'];
6
- export const MACHINE_SUBS = ['status', 'attest', 'rotate'];
6
+ export const MACHINE_SUBS = ['status', 'attest', 'rotate', 'link'];
7
7
  export const HERDR_SUBS = [
8
8
  'report',
9
9
  'next',
@@ -152,7 +152,7 @@ const FLAGS = {
152
152
  wait: false,
153
153
  ensure: false,
154
154
  },
155
- machine: { origin: true, 'client-id': true, cnf: true, name: true, yes: false },
155
+ machine: { origin: true, 'client-id': true, cnf: true, name: true, yes: false, quiet: false },
156
156
  app: {
157
157
  origin: true,
158
158
  'client-id': true,
@@ -560,6 +560,7 @@ function machineCommand(origin, positional, flags, machine) {
560
560
  status: [],
561
561
  attest: ['client-id', 'cnf', 'name'],
562
562
  rotate: ['yes'],
563
+ link: ['quiet'],
563
564
  };
564
565
  for (const key of flags.keys())
565
566
  if (key !== 'origin' && !allowed[sub].includes(key))
@@ -568,6 +569,8 @@ function machineCommand(origin, positional, flags, machine) {
568
569
  return { name: 'machine', origin, sub };
569
570
  if (sub === 'rotate')
570
571
  return { name: 'machine', origin, sub, yes: flags.has('yes') };
572
+ if (sub === 'link')
573
+ return { name: 'machine', origin, sub, quiet: flags.has('quiet') };
571
574
  const clientId = flags.get('client-id');
572
575
  if (typeof clientId !== 'string' || !CLIENT_ID.test(clientId))
573
576
  throw new UsageError('machine attest needs the client the app signs in with, like: --client-id <its client id>');
@@ -671,7 +674,7 @@ export const USAGE = `pendingyou: Pending You for your coding agents (Claude Cod
671
674
  npx pendingyou herdr doctor Pending You in Herdr: the badges on your agents' panes, the plugin, its
672
675
  toasts and its sidebar row. The plugin runs herdr next, open, setup,
673
676
  view and unconfigure
674
- npx pendingyou machine status | attest | rotate
677
+ npx pendingyou machine status | attest | rotate | link
675
678
  This computer's key, which Pending You knows it by (init makes it; it
676
679
  never leaves the computer): whether Pending You knows each agent's
677
680
  connection by it, a proof of it for an app's own sign-in
package/dist/format.js CHANGED
@@ -1,11 +1,12 @@
1
+ const NOTICES = ['follow_up', 'handed_over', 'came_back'];
1
2
  export function noticeOf(heard) {
2
3
  const given = heard.notice;
3
4
  if (typeof given !== 'object' || given === null)
4
5
  return null;
5
6
  const { kind, from } = given;
6
- if ((kind !== 'follow_up' && kind !== 'handed_over') || typeof from !== 'string')
7
+ if (!NOTICES.includes(kind) || typeof from !== 'string')
7
8
  return null;
8
- return { kind, from };
9
+ return { kind: kind, from };
9
10
  }
10
11
  export function handledOf(heard) {
11
12
  const given = heard.handled;
@@ -32,10 +33,15 @@ export function delegatedOf(heard) {
32
33
  const given = heard.delegated;
33
34
  if (typeof given !== 'object' || given === null)
34
35
  return null;
35
- const { from, mode, note } = given;
36
+ const { from, mode, note, authority } = given;
36
37
  if (typeof from !== 'string' || (mode !== 'freely' && mode !== 'loop'))
37
38
  return null;
38
- return { from, mode, ...(typeof note === 'string' && note.trim() ? { note } : {}) };
39
+ return {
40
+ from,
41
+ mode,
42
+ ...(typeof authority === 'string' && authority.trim() ? { authority } : {}),
43
+ ...(typeof note === 'string' && note.trim() ? { note } : {}),
44
+ };
39
45
  }
40
46
  export const modeWords = (mode, from) => mode === 'freely'
41
47
  ? `answer freely: your answer goes straight to ${from}`
@@ -44,6 +50,7 @@ export const HANDED_TODO = 'If you know, answer_delegated with your answer and h
44
50
  const MAX_ANSWER = 600;
45
51
  const MAX_MESSAGE = 400;
46
52
  const MAX_TITLE = 140;
53
+ const MAX_AUTHORITY = 500;
47
54
  const MAX_QUESTION = 100;
48
55
  const MAX_QUESTION_ANSWER = 300;
49
56
  export function clip(text, max) {
@@ -91,6 +98,7 @@ function describeHanded(heard, delegated) {
91
98
  }
92
99
  return [
93
100
  `${HANDED_PREFIX} ${id}, a question from ${from} (${modeWords(delegated.mode, from)}).`,
101
+ ...(delegated.authority ? [clip(delegated.authority, MAX_AUTHORITY).text] : []),
94
102
  `Question: ${clip(heard.title, MAX_TITLE).text}`,
95
103
  ...(note ? [`Their note: “${note.text}”`] : []),
96
104
  `Call get_request (requestId ${id}, name ${named(heard)}) to read it. ${HANDED_TODO}`,
@@ -106,6 +114,16 @@ function describeNotice(heard, notice) {
106
114
  `Pending You: your person handed your open cards to ${from}, ${id} among them.`,
107
115
  `Stop working on them: ${from} answers them and follows up. Call ${ack} to say you’ve stopped.`,
108
116
  ];
117
+ if (notice.kind === 'came_back') {
118
+ const why = heard.messages.at(-1)?.body.split(' Nothing to answer:')[0] ?? '';
119
+ const said = clip(why, MAX_MESSAGE);
120
+ return [
121
+ `Pending You: ${id} came back from ${from} to your person.`,
122
+ ...(said.text ? [said.text] : []),
123
+ `Nothing to answer: call ${ack} to say you’ve read it. Your person’s answer reaches you as usual.`,
124
+ ...(said.cut ? [`(Cut short: get_request ${id} has all of it.)`] : []),
125
+ ];
126
+ }
109
127
  let cut = false;
110
128
  const wrote = heard.messages.map((message) => {
111
129
  const said = quoted(message.body, MAX_MESSAGE);
package/dist/hold.js CHANGED
@@ -4,6 +4,7 @@ import { readPiManifest } from "./apps/pi.js";
4
4
  import { DEFAULT_ORIGIN, DEFAULT_TIMEOUT_MS } from "./args.js";
5
5
  import { readCredential } from "./credentials.js";
6
6
  import { describe } from "./format.js";
7
+ import { linkSoon } from "./link.js";
7
8
  import { markHanded, momentOf } from "./state.js";
8
9
  export const LONG_POLL_SECONDS = 25;
9
10
  const PATIENT_AFTER_MS = 30 * 60 * 1000;
@@ -57,6 +58,7 @@ export async function hold(io, options) {
57
58
  };
58
59
  if (!(await readCredential(io, origin)))
59
60
  return signInNeeded(false);
61
+ await linkSoon(io, origin);
60
62
  let failures = 0;
61
63
  for (;;) {
62
64
  if (io.signal.aborted) {
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 { appClients, clientsIn, LINK_PATH, linkClients, machineLink } from "./link.js";
4
5
  export { attest, ensureMachine, machinePath, proveConnections, readMachine, thumbprintOf, } from "./machine.js";
5
6
  export { main } from "./main.js";
6
7
  export { CLIENT_NAME, deviceLink, discover, register, revoke, SCOPE, signIn, signInWithDevice, startDevice, waitForDevices, } from "./oauth.js";
package/dist/init.js CHANGED
@@ -5,11 +5,13 @@ 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 { linkClients } from "./link.js";
8
9
  import { keyForSignIn, proveConnections } from "./machine.js";
9
10
  import { headlessReason, helperCommand, removeHelper, writeHelper } from "./remote.js";
10
11
  import { FINISH_SAY } from "./setup.js";
11
12
  import { removeShim, shimPath, writeShim } from "./shim.js";
12
13
  import { checkSignIn, logout, signInApps, titleFrom, titleHere } from "./signin.js";
14
+ import { markLinked } from "./state.js";
13
15
  import { trustHooks } from "./trust.js";
14
16
  import { VERSION } from "./version.js";
15
17
  const line = (step) => ` ${step.ok ? 'ok ' : 'check '} ${step.text}\n`;
@@ -155,8 +157,13 @@ export async function init(io, options) {
155
157
  ...(options.machine ? { machine: options.machine } : {}),
156
158
  key,
157
159
  });
158
- if (key)
160
+ if (key) {
159
161
  await proveConnections(io, options.origin, [...signed].flatMap(([app, how]) => (how === 'already' ? [app] : [])), key);
162
+ const linked = await linkClients(io, options.origin, key);
163
+ await markLinked(io, options.origin).catch(() => { });
164
+ if (linked.joined)
165
+ io.out(`Linked ${linked.joined === 1 ? 'a sign-in' : `${linked.joined} sign-ins`} your apps made on this computer to it, so Pending You names them by this computer.\n`);
166
+ }
160
167
  }
161
168
  else
162
169
  io.out(going.length === 1
package/dist/link.js ADDED
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { sendJson } from "./api.js";
4
+ import { APP_IDS } from "./apps/ids.js";
5
+ import { connectionMachine, readCredential } from "./credentials.js";
6
+ import { claudeDir, readJson } from "./files.js";
7
+ import { attest, clockOffset, proveConnections, readMachine } from "./machine.js";
8
+ import { machineOf } from "./remote.js";
9
+ import { linkDue, markLinked } from "./state.js";
10
+ export const LINK_PATH = '/mcp/cli/machine/link';
11
+ export const LINK_EVERY_MS = 6 * 60 * 60 * 1000;
12
+ const MAX_LINKS = 8;
13
+ const CLIENT_ID = /^[\x21-\x7e]{1,300}$/;
14
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ const sameUrl = (a, b) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
16
+ export function clientsIn(entries, url) {
17
+ if (!isRecord(entries))
18
+ return [];
19
+ const found = [];
20
+ for (const entry of Object.values(entries)) {
21
+ if (!isRecord(entry))
22
+ continue;
23
+ const address = entry.serverUrl ?? entry.server_url ?? entry.url;
24
+ const info = isRecord(entry.clientInfo) ? entry.clientInfo : {};
25
+ const client = entry.clientId ?? entry.client_id ?? info.clientId ?? info.client_id;
26
+ if (typeof address !== 'string' || !sameUrl(address, url))
27
+ continue;
28
+ if (typeof client === 'string' && CLIENT_ID.test(client) && !found.includes(client))
29
+ found.push(client);
30
+ }
31
+ return found;
32
+ }
33
+ async function claudeCodeStore(io) {
34
+ if (io.platform === 'darwin') {
35
+ const custom = io.env.CLAUDE_SECURESTORAGE_CONFIG_DIR ?? io.env.CLAUDE_CONFIG_DIR;
36
+ const suffix = custom
37
+ ? `-${createHash('sha256').update(custom.normalize('NFC')).digest('hex').slice(0, 8)}`
38
+ : '';
39
+ const user = io.env.USER || io.env.LOGNAME || '';
40
+ const account = /^[a-zA-Z0-9._-]+$/.test(user) ? user : 'claude-code-user';
41
+ const found = await io
42
+ .run('security', ['find-generic-password', '-a', account, '-w', '-s', `Claude Code-credentials${suffix}`], 5000)
43
+ .catch(() => null);
44
+ if (found?.code === 0 && found.stdout.trim()) {
45
+ try {
46
+ return JSON.parse(found.stdout.trim());
47
+ }
48
+ catch { }
49
+ }
50
+ }
51
+ return readJson(join(claudeDir(io), '.credentials.json')).catch(() => null);
52
+ }
53
+ export async function appClients(io, origin) {
54
+ const url = `${origin}/mcp`;
55
+ const found = [];
56
+ const add = (app, clients) => {
57
+ for (const clientId of clients)
58
+ if (!found.some((each) => each.clientId === clientId))
59
+ found.push({ app, clientId });
60
+ };
61
+ const claude = await claudeCodeStore(io);
62
+ add('claude-code', clientsIn(isRecord(claude) ? claude.mcpOAuth : null, url));
63
+ const codexHome = io.env.CODEX_HOME || join(io.home, '.codex');
64
+ add('codex', clientsIn(await readJson(join(codexHome, '.credentials.json')).catch(() => null), url));
65
+ const data = io.env.XDG_DATA_HOME || join(io.home, '.local', 'share');
66
+ add('opencode', clientsIn(await readJson(join(data, 'opencode', 'mcp-auth.json')).catch(() => null), url));
67
+ return found.slice(0, MAX_LINKS);
68
+ }
69
+ export async function linkClients(io, origin, machine) {
70
+ const clients = await appClients(io, origin);
71
+ const none = { found: clients.length, joined: 0, merged: 0, failed: false };
72
+ if (clients.length === 0)
73
+ return none;
74
+ let signedIn = null;
75
+ for (const app of APP_IDS)
76
+ if (await readCredential(io, origin, app)) {
77
+ signedIn = app;
78
+ break;
79
+ }
80
+ if (!signedIn)
81
+ return { ...none, failed: true };
82
+ try {
83
+ const name = (await connectionMachine(io, origin, signedIn)) ?? machineOf(io.host);
84
+ const offset = await clockOffset(io, origin);
85
+ const links = [];
86
+ for (const { clientId } of clients)
87
+ links.push({
88
+ clientId,
89
+ attestation: await attest(machine, { aud: `${origin}${LINK_PATH}`, client_id: clientId, name }, io.now() + offset),
90
+ });
91
+ const { status, body } = await sendJson(io, origin, LINK_PATH, { links }, 15_000, { app: signedIn });
92
+ if (status !== 200)
93
+ return { ...none, failed: true };
94
+ return {
95
+ found: clients.length,
96
+ joined: (body.links ?? []).filter((each) => each.connection === 'joined').length,
97
+ merged: typeof body.merged === 'number' ? body.merged : 0,
98
+ failed: false,
99
+ };
100
+ }
101
+ catch {
102
+ return { ...none, failed: true };
103
+ }
104
+ }
105
+ export async function machineLink(io, command) {
106
+ const say = (text) => {
107
+ if (!command.quiet)
108
+ io.out(text);
109
+ };
110
+ const machine = await readMachine(io).catch(() => null);
111
+ if (!machine) {
112
+ say('This computer has no key yet. npx -y pendingyou@latest init makes one, and links its sign-ins.\n');
113
+ return 1;
114
+ }
115
+ await markLinked(io, command.origin).catch(() => { });
116
+ const own = [];
117
+ for (const app of APP_IDS)
118
+ if ((await readCredential(io, command.origin, app))?.kind === 'connection')
119
+ own.push(app);
120
+ const proven = own.length
121
+ ? await proveConnections(io, command.origin, own, machine).catch(() => new Map())
122
+ : new Map();
123
+ const linked = await linkClients(io, command.origin, machine);
124
+ const joined = [...proven.values()].filter((each) => each === 'joined').length + linked.joined;
125
+ if (linked.failed && linked.found > 0)
126
+ say(`Found ${linked.found === 1 ? 'a sign-in' : `${linked.found} sign-ins`} of this computer’s apps, but Pending You couldn’t be asked to link ${linked.found === 1 ? 'it' : 'them'} now. Try again later.\n`);
127
+ else if (joined === 0)
128
+ say('Every sign-in on this computer is linked to it already.\n');
129
+ else
130
+ say(`Linked ${joined === 1 ? 'a sign-in' : `${joined} sign-ins`} on this computer to it${linked.merged ? `, and Pending You merged ${linked.merged === 1 ? 'an older one' : `${linked.merged} older ones`} into the one in use` : ''}.\n`);
131
+ return 0;
132
+ }
133
+ export async function linkSoon(io, origin) {
134
+ try {
135
+ if (!(await linkDue(io, origin, LINK_EVERY_MS)))
136
+ return;
137
+ if (!(await readMachine(io)))
138
+ return;
139
+ await markLinked(io, origin);
140
+ io.background(['machine', 'link', '--quiet', '--origin', origin]);
141
+ }
142
+ catch { }
143
+ }
@@ -1,6 +1,7 @@
1
1
  import { APP_NAMES } from "./apps/ids.js";
2
2
  import { readCredential, signedInSlots } from "./credentials.js";
3
3
  import { originArgs } from "./hooks.js";
4
+ import { machineLink } from "./link.js";
4
5
  import { attest, clockOffset, machinePath, readMachine, rotateMachine } from "./machine.js";
5
6
  import { computerName } from "./remote.js";
6
7
  import { checkSignIn } from "./signin.js";
@@ -97,5 +98,7 @@ export function machine(io, command) {
97
98
  return machineAttest(io, command);
98
99
  case 'rotate':
99
100
  return machineRotate(io, command);
101
+ case 'link':
102
+ return machineLink(io, command);
100
103
  }
101
104
  }
package/dist/pickup.js CHANGED
@@ -8,6 +8,7 @@ import { clip, delegatedOf, describe, HANDED_PREFIX, isHandedWake, } from "./for
8
8
  import { goesBy, HANDED_GRACE_MS, namesAnotherComputer } from "./handed.js";
9
9
  import { herdrTarget, reportCommand } from "./herdr.js";
10
10
  import { holdCommand } from "./hold.js";
11
+ import { linkSoon } from "./link.js";
11
12
  import { unwokenLine } from "./loaded.js";
12
13
  import { stuckLine } from "./mcp-health.js";
13
14
  import { isPermissionCard, isPromptApp, POSTED_MAX, sessionName, sessionPosted, settlePrompts, } from "./permission.js";
@@ -147,6 +148,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
147
148
  try {
148
149
  const input = await hookInput(io);
149
150
  const { cwd } = input;
151
+ await linkSoon(io, origin);
150
152
  if (mode === 'handoff' && isPromptApp(app))
151
153
  await settlePrompts(io, { session: input.session, event: 'prompt' });
152
154
  const known = mode === 'handoff' && app === DEFAULT_APP && input.session
package/dist/remote.js CHANGED
@@ -5,6 +5,7 @@ import { APP_NAMES, DEFAULT_APP } from "./apps/ids.js";
5
5
  import { cleanName, DEFAULT_ORIGIN } from "./args.js";
6
6
  import { readCredential } from "./credentials.js";
7
7
  import { configDir, writeWhole } from "./files.js";
8
+ import { linkSoon } from "./link.js";
8
9
  import { recordHelper } from "./mcp-health.js";
9
10
  import { VERSION } from "./version.js";
10
11
  export const HELPER = 'pendingyou-mcp-headers';
@@ -261,6 +262,7 @@ export async function mcpHeaders(io, options) {
261
262
  const token = await helperToken(io, options.origin, HELPER_WAIT_MS, app);
262
263
  headers(token);
263
264
  await recordHelper(io, options.origin, app, 'ok');
265
+ await linkSoon(io, options.origin);
264
266
  return 0;
265
267
  }
266
268
  catch (error) {
@@ -0,0 +1,138 @@
1
+ import { generateKey, importKey, sha256 } from "./dpop.js";
2
+ import { SignInError } from "./errors.js";
3
+ import { nonces, tokenRequest } from "./oauth.js";
4
+ export const AUTHORIZE_PATH = '/oauth/authorize';
5
+ const random = (bytes) => {
6
+ let binary = '';
7
+ for (const byte of crypto.getRandomValues(new Uint8Array(bytes)))
8
+ binary += String.fromCharCode(byte);
9
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
10
+ };
11
+ export async function codeSignIn(options) {
12
+ const { jwk } = await generateKey();
13
+ const pending = {
14
+ version: 1,
15
+ origin: options.origin.replace(/\/+$/, ''),
16
+ clientId: options.clientId,
17
+ redirectUri: options.redirectUri,
18
+ scopes: [...options.scopes],
19
+ state: random(16),
20
+ verifier: random(32),
21
+ key: jwk,
22
+ };
23
+ return resumeCodeSignIn(pending, options);
24
+ }
25
+ export async function resumeCodeSignIn(pending, options = {}) {
26
+ const http = {
27
+ fetch: options.fetch ?? ((input, init) => fetch(input, init)),
28
+ now: options.now ?? (() => Date.now()),
29
+ };
30
+ const key = await importKey(pending.key);
31
+ const url = new URL(`${pending.origin}${AUTHORIZE_PATH}`);
32
+ url.search = new URLSearchParams({
33
+ response_type: 'code',
34
+ client_id: pending.clientId,
35
+ redirect_uri: pending.redirectUri,
36
+ scope: pending.scopes.join(' '),
37
+ state: pending.state,
38
+ code_challenge: await sha256(pending.verifier),
39
+ code_challenge_method: 'S256',
40
+ resource: `${pending.origin}/v1`,
41
+ dpop_jkt: key.jkt,
42
+ }).toString();
43
+ let finished = false;
44
+ async function finish(callback) {
45
+ if (finished)
46
+ throw new SignInError('finished', 'This sign-in is finished already.');
47
+ const back = new URL(String(callback));
48
+ if (back.searchParams.get('state') !== pending.state)
49
+ throw new SignInError('state_mismatch', 'This isn’t the answer to this sign-in.');
50
+ const iss = back.searchParams.get('iss');
51
+ if (iss !== null && iss !== pending.origin)
52
+ throw new SignInError('issuer_mismatch', 'This answer came from somewhere else.');
53
+ const error = back.searchParams.get('error');
54
+ if (error)
55
+ throw new SignInError(error, error === 'access_denied'
56
+ ? 'The person didn’t allow it.'
57
+ : `Pending You refused the sign-in (${error}).`);
58
+ const code = back.searchParams.get('code');
59
+ if (!code)
60
+ throw new SignInError('no_code', 'The answer had no code.');
61
+ finished = true;
62
+ const result = await tokenRequest(http, pending.origin, key, {
63
+ grant_type: 'authorization_code',
64
+ code,
65
+ redirect_uri: pending.redirectUri,
66
+ client_id: pending.clientId,
67
+ code_verifier: pending.verifier,
68
+ resource: `${pending.origin}/v1`,
69
+ }, nonces());
70
+ if (!result.ok)
71
+ throw new SignInError(result.error, `Pending You didn’t finish the sign-in (${result.error}). Start it again.`);
72
+ const credentials = {
73
+ version: 1,
74
+ origin: pending.origin,
75
+ clientId: pending.clientId,
76
+ accessToken: result.tokens.access_token,
77
+ refreshToken: result.tokens.refresh_token,
78
+ expiresAt: http.now() + result.tokens.expires_in * 1000,
79
+ scopes: result.tokens.scope ? result.tokens.scope.split(' ') : [...pending.scopes],
80
+ key: pending.key,
81
+ };
82
+ const store = options.store;
83
+ if (store)
84
+ await store.exclusive(() => store.save(credentials));
85
+ return credentials;
86
+ }
87
+ return { url: url.toString(), pending, finish };
88
+ }
89
+ export function isPendingCodeSignIn(value) {
90
+ const entry = value;
91
+ return (typeof entry === 'object' &&
92
+ entry !== null &&
93
+ entry.version === 1 &&
94
+ typeof entry.origin === 'string' &&
95
+ typeof entry.clientId === 'string' &&
96
+ typeof entry.redirectUri === 'string' &&
97
+ Array.isArray(entry.scopes) &&
98
+ typeof entry.state === 'string' &&
99
+ typeof entry.verifier === 'string' &&
100
+ typeof entry.key === 'object' &&
101
+ entry.key !== null);
102
+ }
103
+ export const PENDING_KEY = 'pendingyou.signin';
104
+ const sessionStorageHere = () => globalThis.sessionStorage;
105
+ export async function redirectToSignIn(options) {
106
+ const storage = options.storage ?? sessionStorageHere();
107
+ if (!storage)
108
+ throw new TypeError('No sessionStorage here: pass `storage`');
109
+ const started = await codeSignIn(options);
110
+ storage.setItem(PENDING_KEY, JSON.stringify(started.pending));
111
+ const go = options.go ??
112
+ ((url) => {
113
+ const here = globalThis.location;
114
+ if (!here)
115
+ throw new TypeError('No location here: pass `go`');
116
+ here.assign(url);
117
+ });
118
+ go(started.url);
119
+ }
120
+ export async function finishRedirectSignIn(options = {}) {
121
+ const storage = options.storage ?? sessionStorageHere();
122
+ if (!storage)
123
+ throw new TypeError('No sessionStorage here: pass `storage`');
124
+ const kept = storage.getItem(PENDING_KEY);
125
+ storage.removeItem(PENDING_KEY);
126
+ let pending = null;
127
+ try {
128
+ pending = kept ? JSON.parse(kept) : null;
129
+ }
130
+ catch { }
131
+ if (!isPendingCodeSignIn(pending))
132
+ throw new SignInError('not_started', 'No sign-in was started in this tab.');
133
+ const url = options.url ?? globalThis.location?.href ?? undefined;
134
+ if (!url)
135
+ throw new TypeError('No location here: pass `url`');
136
+ const started = await resumeCodeSignIn(pending, options);
137
+ return started.finish(url);
138
+ }
package/dist/sdk/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { backoffMs, RESYNC_MS } from "./changes.js";
2
2
  export { createClient } from "./client.js";
3
+ export { codeSignIn, finishRedirectSignIn, redirectToSignIn, resumeCodeSignIn, } from "./code.js";
3
4
  export { deviceSignIn, } from "./device.js";
4
5
  export { generateKey, importKey, thumbprint } from "./dpop.js";
5
6
  export { ApiError, SignedOutError, SignInError } from "./errors.js";
package/dist/sdk/node.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
4
5
  import { dirname, join } from 'node:path';
6
+ import { codeSignIn } from "./code.js";
7
+ import { SignInError } from "./errors.js";
5
8
  import { isCredentials } from "./store.js";
6
9
  export const STALE_LOCK_MS = 30_000;
7
10
  export const LOCK_WAIT_MS = 15_000;
@@ -87,6 +90,77 @@ export function fileStore(path) {
87
90
  exclusive: (work) => withFileLock(path, work),
88
91
  };
89
92
  }
93
+ const page = (title, body) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title><body style="font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 16px"><h1 style="font-weight:500">${title}</h1><p>${body}</p></body>`;
94
+ export async function loopbackSignIn(options) {
95
+ const registered = new URL(options.redirectUri ?? 'http://127.0.0.1/callback');
96
+ if (registered.protocol !== 'http:' ||
97
+ !['127.0.0.1', '[::1]', 'localhost'].includes(registered.hostname))
98
+ throw new TypeError('A loopback sign-in comes back to http://127.0.0.1');
99
+ const server = createServer();
100
+ await new Promise((done, fail) => {
101
+ server.once('error', fail);
102
+ server.listen(options.port ?? 0, registered.hostname === '[::1]' ? '::1' : '127.0.0.1', () => done());
103
+ });
104
+ const address = server.address();
105
+ const port = typeof address === 'object' && address ? address.port : 0;
106
+ const back = new URL(registered.toString());
107
+ back.port = String(port);
108
+ const redirectUri = back.toString();
109
+ const started = await codeSignIn({ ...options, redirectUri });
110
+ let timer;
111
+ let closed = false;
112
+ const close = () => {
113
+ if (closed)
114
+ return;
115
+ closed = true;
116
+ if (timer)
117
+ clearTimeout(timer);
118
+ server.close();
119
+ };
120
+ const html = { 'content-type': 'text/html; charset=utf-8', connection: 'close' };
121
+ const signedIn = new Promise((resolve, reject) => {
122
+ const stop = (error) => {
123
+ close();
124
+ reject(error);
125
+ };
126
+ timer = setTimeout(() => stop(new SignInError('expired_token', 'The browser didn’t come back in time.')), options.timeoutMs ?? 10 * 60_000);
127
+ options.signal?.addEventListener('abort', () => stop(new SignInError('aborted', 'The sign-in was stopped.')));
128
+ let taken = false;
129
+ server.on('request', (request, response) => {
130
+ const url = new URL(request.url ?? '/', redirectUri);
131
+ if (request.method !== 'GET' || url.pathname !== back.pathname) {
132
+ response
133
+ .writeHead(404, { 'content-type': 'text/plain', connection: 'close' })
134
+ .end('Not found');
135
+ return;
136
+ }
137
+ if (taken) {
138
+ response
139
+ .writeHead(409, html)
140
+ .end(page('Already answered', 'Close this tab and go back to the app.'));
141
+ return;
142
+ }
143
+ taken = true;
144
+ started.finish(url).then((credentials) => {
145
+ response
146
+ .writeHead(200, html)
147
+ .end(page('You’re signed in', 'Close this tab and go back to the app.'));
148
+ close();
149
+ resolve(credentials);
150
+ }, (error) => {
151
+ response
152
+ .writeHead(400, html)
153
+ .end(page('Not signed in', 'Go back to the app and start signing in again.'));
154
+ close();
155
+ reject(error);
156
+ });
157
+ });
158
+ });
159
+ signedIn.catch(() => { });
160
+ if (options.open)
161
+ await options.open(started.url);
162
+ return { url: started.url, redirectUri, signedIn, close };
163
+ }
90
164
  const runFile = (command, args) => new Promise((resolve, reject) => {
91
165
  execFile(command, args, { timeout: 60_000, maxBuffer: 64 * 1024 }, (error, stdout, stderr) => {
92
166
  if (error)
package/dist/state.js CHANGED
@@ -12,6 +12,7 @@ async function load(io) {
12
12
  handed: file?.handed && typeof file.handed === 'object' ? file.handed : {},
13
13
  ...(file?.reminded && typeof file.reminded === 'object' ? { reminded: file.reminded } : {}),
14
14
  ...(file?.unwoken && typeof file.unwoken === 'object' ? { unwoken: file.unwoken } : {}),
15
+ ...(file?.linked && typeof file.linked === 'object' ? { linked: file.linked } : {}),
15
16
  };
16
17
  }
17
18
  async function change(io, edit) {
@@ -85,3 +86,12 @@ export async function claimUnwoken(io, session) {
85
86
  });
86
87
  return claimed;
87
88
  }
89
+ export async function linkDue(io, origin, every) {
90
+ const at = (await load(io)).linked?.[origin];
91
+ return typeof at !== 'number' || io.now() - at >= every || at > io.now();
92
+ }
93
+ export function markLinked(io, origin) {
94
+ return change(io, (state) => {
95
+ state.linked = { ...state.linked, [origin]: io.now() };
96
+ });
97
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.27.0';
1
+ export const VERSION = '0.29.0';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.39.0",
4
+ "version": "2.44.0",
5
5
  "description": "Wakes Claude Code when your person answers a Pending You card it posted, even while it sits idle. Installed by `npx pendingyou init`.",
6
6
  "author": {
7
7
  "name": "RecordPlane",
package/mod/README.md CHANGED
@@ -4,4 +4,4 @@ The pendingyou command line’s copy of the Pending You plugin’s wake mod, for
4
4
 
5
5
  In each session it learns the cards the session posts to Pending You, checks them through the session’s own Pending You connection, and starts a turn when your person answers, writes back or a fallback comes due, or hands the session a question from another of their assistants (CLI 0.14.0), even while it sits idle. It answers `npx pendingyou hold` itself, so nothing waits in the background, and shows “2 waiting on you” under the prompt, after the plugin’s name. With the Pending You plugin installed too, only one copy acts.
6
6
 
7
- Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.39.0; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.44.0; send changes there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",