@cosmovex/agentpager 0.1.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/dist/awake.js ADDED
@@ -0,0 +1,73 @@
1
+ // Keep this computer awake while an agent is actually working.
2
+ //
3
+ // The whole promise is "walk away and it keeps going". A laptop that naps ten minutes after you
4
+ // leave the desk breaks it in the most confusing way possible: the phone says the computer went
5
+ // offline, the run is frozen mid-task, and nothing explains why.
6
+ //
7
+ // So while a session is running — and only then — we hold an idle-sleep inhibitor. It is the same
8
+ // thing a video call or a long download does. It does NOT fight a closed lid or a deliberate sleep,
9
+ // and it releases the moment the last agent goes idle, so an idle bridge never keeps a laptop up.
10
+ import { spawn } from 'node:child_process';
11
+ import { platform } from 'node:os';
12
+ export class AwakeLock {
13
+ enabled;
14
+ log;
15
+ child;
16
+ holders = 0;
17
+ announced = false;
18
+ constructor(enabled, log) {
19
+ this.enabled = enabled;
20
+ this.log = log;
21
+ }
22
+ /** Something started working. Balanced by release(). */
23
+ acquire() {
24
+ this.holders++;
25
+ if (!this.enabled || this.child || this.holders === 0)
26
+ return;
27
+ const cmd = this.command();
28
+ if (!cmd)
29
+ return;
30
+ try {
31
+ this.child = spawn(cmd[0], cmd.slice(1), { stdio: 'ignore' });
32
+ this.child.on('error', () => (this.child = undefined)); // tool missing: not worth a word
33
+ this.child.on('exit', () => (this.child = undefined));
34
+ if (!this.announced) {
35
+ this.announced = true;
36
+ this.log('keeping this computer awake while an agent is working');
37
+ }
38
+ }
39
+ catch {
40
+ this.child = undefined;
41
+ }
42
+ }
43
+ release() {
44
+ this.holders = Math.max(0, this.holders - 1);
45
+ if (this.holders === 0)
46
+ this.stop();
47
+ }
48
+ stop() {
49
+ this.child?.kill();
50
+ this.child = undefined;
51
+ }
52
+ command() {
53
+ switch (platform()) {
54
+ case 'darwin':
55
+ // -i: no idle sleep. Not -d/-s, so the screen still sleeps and a closed lid still wins.
56
+ return ['caffeinate', '-i'];
57
+ case 'linux':
58
+ return ['systemd-inhibit', '--what=idle', '--who=AgentPager', '--why=an agent is running', 'sleep', 'infinity'];
59
+ case 'win32':
60
+ // ES_CONTINUOUS | ES_SYSTEM_REQUIRED is held by the calling thread for as long as this
61
+ // PowerShell lives, and is dropped when we kill it. No native module needed.
62
+ return [
63
+ 'powershell',
64
+ '-NoProfile',
65
+ '-NonInteractive',
66
+ '-Command',
67
+ "Add-Type -Namespace AP -Name K -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint f);'; [AP.K]::SetThreadExecutionState(2147483649) | Out-Null; Start-Sleep -Seconds 2147483",
68
+ ];
69
+ default:
70
+ return null;
71
+ }
72
+ }
73
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ // agentpager — keep your coding agents one tap away on your phone.
3
+ // agentpager pair if needed, then stay connected
4
+ // agentpager pair pair another phone
5
+ // agentpager devices list paired phones
6
+ // agentpager unpair ID remove a phone
7
+ // agentpager doctor check which agents this computer can drive
8
+ // agentpager guard PreToolUse hook: asks before anything irreversible, denies past a budget
9
+ // agentpager notify "<text>" page your phone from any script
10
+ // agentpager ask "<question>" ask your phone: exit 0 approved · 1 denied · 2 nobody answered
11
+ // agentpager service install|uninstall start automatically at login
12
+ // --no-keep-awake let this computer idle-sleep even while an agent is working
13
+ import qrcode from 'qrcode-terminal';
14
+ import { ACP_AGENTS, AcpAgent, userAgents } from './agents/acp.js';
15
+ import { ClaudeAgent } from './agents/claude.js';
16
+ import { CodexAgent } from './agents/codex.js';
17
+ import { BRIDGE_VERSION, Hub } from './hub.js';
18
+ import { offerPairing } from './pair.js';
19
+ import { callControl, serveControl } from './control.js';
20
+ import { staleBuildWarning } from './freshness.js';
21
+ import { runRuleCommand, USAGE as GUARD_USAGE } from './guard/cli.js';
22
+ import { install as guardInstall, runDeskHook, runHook, status as guardStatus, uninstall as guardUninstall } from './guard/index.js';
23
+ import { Channel } from './relay.js';
24
+ import { installService, uninstallService } from './service.js';
25
+ import { loadState, saveState, updateState } from './store.js';
26
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
27
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
28
+ const log = (line) => console.log(`${dim(new Date().toLocaleTimeString())} ${line}`);
29
+ /** `--timeout 90s | 10m | 2h | 600` (bare numbers are seconds). */
30
+ function duration(v) {
31
+ const m = /^(\d+)(s|m|h)?$/.exec((v ?? '').trim());
32
+ if (!m)
33
+ return undefined;
34
+ const n = Number(m[1]);
35
+ return n * (m[2] === 'h' ? 3600_000 : m[2] === 'm' ? 60_000 : 1000);
36
+ }
37
+ async function pair(state) {
38
+ const offer = offerPairing(state);
39
+ console.log(`\n${bold('Pair your phone')} — in the AgentPager app tap "Scan the code" (or point your phone camera here):\n`);
40
+ qrcode.generate(offer.link, { small: true });
41
+ console.log(dim(`Can't scan? Paste this into the app:\n${offer.link}\n`));
42
+ const device = await offer.wait;
43
+ // Not `state.devices.push(...)`: this snapshot is as old as the QR code on screen, and anything
44
+ // that changed the device list while we waited would be silently undone.
45
+ updateState((s) => s.devices.push(device));
46
+ state.devices.push(device); // keep the in-memory copy usable for a caller that goes on to serve
47
+ console.log(`\n✅ Paired with ${bold(device.name)}.\n`);
48
+ // Pairing does not make this computer reachable — serving does. Standalone `agentpager pair`
49
+ // printed this tick and exited, leaving the phone attached to nothing: it showed "waiting for the
50
+ // computer to say which agents it has", then "computer is offline". Both were true, and neither
51
+ // was the phone's fault. Say what is missing, and only when it is actually missing.
52
+ if (!(await bridgeRunning())) {
53
+ console.log(`${bold('One more step:')} run ${bold('agentpager')} to keep this computer reachable.`);
54
+ console.log(dim('Until something is serving, your phone has nothing to talk to.\n'));
55
+ }
56
+ }
57
+ /** Is a bridge already serving on this machine? The control socket is the cheapest honest answer. */
58
+ async function bridgeRunning() {
59
+ try {
60
+ await callControl({ kind: 'ping', title: '', text: '' }, 1500);
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /** Claude and Codex natively; everything else through ACP, if it's installed. */
68
+ async function buildAgents() {
69
+ const codex = new CodexAgent();
70
+ const agents = new Map([
71
+ ['claude', new ClaudeAgent()],
72
+ ['codex', codex],
73
+ ]);
74
+ const acp = [...ACP_AGENTS, ...userAgents()].map((spec) => new AcpAgent(spec));
75
+ const present = await Promise.all(acp.map(async (a) => ((await a.status()).available ? a : null)));
76
+ for (const a of present)
77
+ if (a)
78
+ agents.set(a.id, a);
79
+ return { agents, codex };
80
+ }
81
+ async function serve(state, keepAwake) {
82
+ // Native agents are cheap to construct; ACP agents are PROBED, and a probe can hang without a
83
+ // terminal (it did, as a login service) — so the relay must come up first, never behind them.
84
+ const codex = new CodexAgent();
85
+ const agents = new Map([
86
+ ['claude', new ClaudeAgent()],
87
+ ['codex', codex],
88
+ ]);
89
+ const hub = new Hub(agents, state.machineName, log, keepAwake);
90
+ const persist = () => saveState(state);
91
+ const channels = state.devices.map((d) => new Channel(d, persist, (msg, ch) => hub.handle(msg, ch)));
92
+ for (const c of channels) {
93
+ hub.attach(c);
94
+ c.start();
95
+ }
96
+ const stopControl = serveControl((req) => hub.job(req));
97
+ // Detect the ACP agents in the background and announce them as they appear.
98
+ void (async () => {
99
+ for (const spec of [...ACP_AGENTS, ...userAgents(log)]) {
100
+ const agent = new AcpAgent(spec);
101
+ if ((await agent.status()).available) {
102
+ await hub.addAgent(agent);
103
+ log(`${agent.name} available`);
104
+ }
105
+ }
106
+ })();
107
+ const statuses = await Promise.all([...agents.values()].map((a) => a.status()));
108
+ console.log(`\n${bold('AgentPager bridge')} ${BRIDGE_VERSION} · ${state.machineName}`);
109
+ const stale = staleBuildWarning();
110
+ if (stale)
111
+ console.log(` ⚠️ ${stale}`);
112
+ for (const s of statuses)
113
+ console.log(` ${s.available ? '●' : '○'} ${s.name}${s.version ? ` ${dim(s.version)}` : ''}${s.available ? '' : dim(` ${s.reason}`)}`);
114
+ console.log(` ${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired`);
115
+ console.log(dim('\nKeep this running (or run `agentpager service install` to start it at login).'));
116
+ // Someone who reinstalls the app, or picks up a second phone, reasonably runs `agentpager` again
117
+ // and waits for a code that never comes — this is the only place they will look for why.
118
+ console.log(dim('Pairing another phone (or the same one after a reinstall)? Run `agentpager pair`.'));
119
+ console.log(dim('Your sessions stay on this computer; the relay only sees encrypted bytes.\n'));
120
+ const quit = async () => {
121
+ console.log(dim('\nDisconnecting…'));
122
+ hub.shutdown();
123
+ stopControl();
124
+ codex.shutdown();
125
+ await Promise.all(channels.map((c) => c.stop()));
126
+ process.exit(0);
127
+ };
128
+ process.on('SIGINT', quit);
129
+ process.on('SIGTERM', quit);
130
+ }
131
+ async function main() {
132
+ const [cmd, arg] = process.argv.slice(2);
133
+ // Before anything else: the guard runs once per tool call, so it must not pay for loading state,
134
+ // reading the keychain, or touching the relay. Bare `guard` is the hook itself and speaks only
135
+ // JSON on stdout — nothing else may print to stdout on that path.
136
+ if (cmd === 'guard') {
137
+ // Every branch must exit: Firebase is loaded by this file and keeps the loop alive.
138
+ if (arg === undefined)
139
+ await runHook();
140
+ else if (arg === 'desk')
141
+ await runDeskHook();
142
+ else if (arg === 'install')
143
+ guardInstall();
144
+ else if (arg === 'uninstall')
145
+ guardUninstall();
146
+ else if (arg === 'status')
147
+ await guardStatus();
148
+ else if (!runRuleCommand(arg, process.argv.slice(4)))
149
+ console.log(GUARD_USAGE);
150
+ return process.exit(process.exitCode ?? 0);
151
+ }
152
+ const state = loadState();
153
+ switch (cmd ?? 'start') {
154
+ case 'start': {
155
+ // Two bridges on one computer answer the same phone twice. Notice, and say so.
156
+ const running = await callControl({ kind: 'ping', title: '', text: '' }, 2000).catch(() => null);
157
+ if (running) {
158
+ console.log(`AgentPager is already running on this computer (${running.phones} phone${running.phones === 1 ? '' : 's'} paired).`);
159
+ console.log(dim('Use that window, or stop it before starting another.'));
160
+ process.exit(0);
161
+ }
162
+ if (!state.devices.length)
163
+ await pair(state);
164
+ await serve(state, !process.argv.includes('--no-keep-awake'));
165
+ break;
166
+ }
167
+ case 'pair':
168
+ await pair(state);
169
+ process.exit(0);
170
+ case 'devices':
171
+ if (!state.devices.length)
172
+ console.log('No phones paired. Run `agentpager pair`.');
173
+ for (const d of state.devices)
174
+ console.log(`${d.id} ${d.name} ${dim(`paired ${new Date(d.pairedAt).toLocaleDateString()}`)}`);
175
+ process.exit(0);
176
+ case 'unpair': {
177
+ const before = state.devices.length;
178
+ const after = updateState((s) => {
179
+ s.devices = s.devices.filter((d) => d.id !== arg);
180
+ });
181
+ state.devices = after.devices;
182
+ console.log(before === state.devices.length ? `No phone with id ${arg}.` : 'Removed. That phone can no longer reach this computer.');
183
+ process.exit(0);
184
+ }
185
+ case 'notify':
186
+ case 'ask': {
187
+ // Parse flags properly: a flag's VALUE must not leak into the message body.
188
+ const argv = process.argv.slice(3);
189
+ const words = [];
190
+ let title = '';
191
+ let danger = false;
192
+ let json = false;
193
+ let timeoutMs;
194
+ for (let i = 0; i < argv.length; i++) {
195
+ if (argv[i] === '--title')
196
+ title = argv[++i] ?? '';
197
+ else if (argv[i] === '--danger')
198
+ danger = true;
199
+ else if (argv[i] === '--timeout')
200
+ timeoutMs = duration(argv[++i]);
201
+ else if (argv[i] === '--json')
202
+ json = true;
203
+ else
204
+ words.push(argv[i]);
205
+ }
206
+ const text = words.join(' ').trim();
207
+ if (!text) {
208
+ console.log(`Usage: agentpager ${cmd} "<text>" [--title "<short title>"] [--json]` +
209
+ (cmd === 'ask' ? ' [--timeout 10m] [--danger]' : ''));
210
+ process.exit(1);
211
+ }
212
+ const res = await callControl({
213
+ kind: cmd,
214
+ title: title || (cmd === 'ask' ? text.slice(0, 60) : 'Job finished'),
215
+ text,
216
+ risk: danger ? 'danger' : undefined,
217
+ cwd: process.cwd(),
218
+ timeoutMs,
219
+ });
220
+ // A workflow step needs to record WHO approved and WHY it stopped, not just an exit code.
221
+ // Exit codes stay exactly as they were — 0 approve, 1 deny, 2 could-not-ask — because they are
222
+ // what every `if` in every pipeline is already written against.
223
+ const report = (outcome, extra = {}) => {
224
+ if (!json)
225
+ return;
226
+ console.log(JSON.stringify({
227
+ outcome,
228
+ phone: res.phone ?? null,
229
+ phones: res.phones,
230
+ pushable: res.pushable,
231
+ ...extra,
232
+ }));
233
+ };
234
+ // Say what actually happened. "Sent to your phone" when no phone is paired is how a script
235
+ // ends up trusting a message nobody could ever have seen.
236
+ if (!res.phones) {
237
+ report('no_phone');
238
+ if (!json)
239
+ console.error('✖ No phone is paired with this computer. Run `agentpager pair` first.');
240
+ process.exit(2);
241
+ }
242
+ const where = res.phone ? ` (${res.phone})` : '';
243
+ const deaf = res.pushable === 0 ? `\n${dim('Notifications are off in the app, so this only shows while the app is open.')}` : '';
244
+ if (cmd === 'notify') {
245
+ report('sent');
246
+ if (!json)
247
+ console.log(`sent to your phone${where}${deaf}`);
248
+ process.exit(0);
249
+ }
250
+ if (res.decision === 'timeout') {
251
+ report('timeout');
252
+ if (!json)
253
+ console.error(`⏳ Nobody answered on your phone${where} in time — nothing was approved.${deaf}`);
254
+ process.exit(2);
255
+ }
256
+ report(res.decision === 'allow' ? 'approved' : 'denied');
257
+ if (!json) {
258
+ console.log(res.decision === 'allow' ? `✅ approved on your phone${where}` : `⛔️ denied on your phone${where}`);
259
+ }
260
+ process.exit(res.decision === 'allow' ? 0 : 1);
261
+ }
262
+ case 'doctor': {
263
+ const staleDoctor = staleBuildWarning();
264
+ if (staleDoctor)
265
+ console.log(`⚠️ ${staleDoctor}\n`);
266
+ const codex = new CodexAgent();
267
+ const native = [new ClaudeAgent(), codex];
268
+ for (const a of native) {
269
+ const s = await a.status();
270
+ console.log(`${s.available ? '●' : '○'} ${s.name} ${dim(s.version ?? s.reason ?? '')}`);
271
+ if (s.available) {
272
+ const list = await a.list().catch((e) => (console.log(dim(` can't list sessions: ${e.message}`)), []));
273
+ console.log(dim(` ${list.length} recent sessions`));
274
+ }
275
+ }
276
+ // Every ACP candidate, including the ones that are not here — "why isn't my agent showing up"
277
+ // is unanswerable when the tool only prints what worked.
278
+ const specs = [...ACP_AGENTS, ...userAgents((l) => console.log(dim(l)))];
279
+ const missing = [];
280
+ for (const spec of specs) {
281
+ const s = await new AcpAgent(spec).status();
282
+ if (s.available) {
283
+ console.log(`● ${s.name} ${dim(s.version ?? '')}`);
284
+ }
285
+ else if (s.reason?.includes('did not answer')) {
286
+ console.log(`○ ${s.name} ${dim(s.reason)}`); // installed but not speaking ACP: worth saying
287
+ }
288
+ else {
289
+ missing.push(`${spec.name} (${spec.command})`);
290
+ }
291
+ }
292
+ if (missing.length) {
293
+ console.log(dim(`\nNot installed here: ${missing.join(' · ')}`));
294
+ console.log(dim('Any other ACP agent: add it to ~/.agentpager/agents.json'));
295
+ console.log(dim(' [{ "id": "mine", "name": "My Agent", "command": "my-agent", "args": ["acp"] }]'));
296
+ }
297
+ codex.shutdown();
298
+ process.exit(0);
299
+ }
300
+ case 'service': {
301
+ if (arg === 'install') {
302
+ if (!state.devices.length)
303
+ throw new Error('Pair your phone first: run `agentpager`, scan the code, then install the service.');
304
+ console.log(installService());
305
+ }
306
+ else if (arg === 'uninstall') {
307
+ console.log(uninstallService());
308
+ }
309
+ else {
310
+ console.log('Usage: agentpager service install | uninstall');
311
+ }
312
+ process.exit(0);
313
+ }
314
+ default:
315
+ console.log('Usage: agentpager [start | pair | devices | unpair <id> | doctor | guard install|status|rules|test | notify <text> | ask <text> | service install|uninstall]');
316
+ process.exit(1);
317
+ }
318
+ }
319
+ main().catch((e) => {
320
+ // 🧨 Exit 1 means DENIED. A pipeline that cannot reach the bridge was not denied by anybody, and
321
+ // treating the two the same is how a workflow decides a human said no when nothing ever asked.
322
+ // `ask` and `notify` are the commands whose exit code something else branches on, so a failure
323
+ // there is 2 — could-not-ask — exactly like a timeout.
324
+ const cmd = process.argv[2];
325
+ const couldNotAsk = cmd === 'ask' || cmd === 'notify';
326
+ if (process.argv.includes('--json')) {
327
+ console.log(JSON.stringify({ outcome: 'unreachable', error: `${e?.message ?? e}` }));
328
+ }
329
+ else {
330
+ console.error(`✖ ${e?.message ?? e}`);
331
+ }
332
+ process.exit(couldNotAsk ? 2 : 1);
333
+ });
@@ -0,0 +1,110 @@
1
+ // Local control socket: lets ANY script on this computer page the phone.
2
+ // agentpager notify "AAB built" → a notification
3
+ // agentpager ask "Deploy to prod?" → exit 0 if you approve, 1 if you deny
4
+ // Only processes on this machine can reach it (unix socket in ~/.agentpager, mode 600;
5
+ // a named pipe on Windows). Nothing is exposed to the network.
6
+ import { createServer, connect } from 'node:net';
7
+ import { createHash } from 'node:crypto';
8
+ import { chmodSync, existsSync, mkdirSync, rmSync } from 'node:fs';
9
+ import { platform, tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { stateDir } from './store.js';
12
+ /**
13
+ * Unix sockets have a ~104-character path limit, and a long state dir silently breaks
14
+ * bind(). Fall back to a short, deterministic path in the temp dir when that would happen.
15
+ */
16
+ export const socketPath = () => {
17
+ if (platform() === 'win32')
18
+ return '\\\\.\\pipe\\agentpager';
19
+ const preferred = join(stateDir, 'control.sock');
20
+ if (preferred.length <= 100)
21
+ return preferred;
22
+ const tag = createHash('sha256').update(stateDir).digest('hex').slice(0, 10);
23
+ return join(tmpdir(), `agentpager-${tag}.sock`);
24
+ };
25
+ export function serveControl(handle) {
26
+ const path = socketPath();
27
+ if (platform() !== 'win32') {
28
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
29
+ if (existsSync(path))
30
+ rmSync(path);
31
+ }
32
+ const open = new Set();
33
+ const server = createServer((socket) => {
34
+ open.add(socket);
35
+ socket.on('close', () => open.delete(socket));
36
+ let data = '';
37
+ socket.on('data', (d) => {
38
+ data += d.toString();
39
+ const line = data.indexOf('\n');
40
+ if (line < 0)
41
+ return;
42
+ let req;
43
+ try {
44
+ req = JSON.parse(data.slice(0, line));
45
+ }
46
+ catch {
47
+ socket.end(JSON.stringify({ error: 'bad request' }) + '\n');
48
+ return;
49
+ }
50
+ handle(req).then((res) => socket.end(JSON.stringify(res) + '\n'), (e) => socket.end(JSON.stringify({ error: String(e?.message ?? e) }) + '\n'));
51
+ });
52
+ socket.on('error', () => { });
53
+ });
54
+ server.listen(path, () => {
55
+ if (platform() !== 'win32')
56
+ chmodSync(path, 0o600);
57
+ });
58
+ // A second bridge instance simply doesn't take the socket — but say so, because a silent
59
+ // failure here means `agentpager notify|ask` stops working with no explanation.
60
+ server.on('error', (e) => {
61
+ if (e.code !== 'EADDRINUSE')
62
+ console.error(`notify/ask socket unavailable (${e.code ?? e.message}) at ${path}`);
63
+ });
64
+ // Resolves once the address is really free. A Windows named pipe has a fixed name and is not
65
+ // released until close() completes, so a caller that restarts straight after stop() would fail
66
+ // with EADDRINUSE and silently lose the socket.
67
+ return () => new Promise((done) => {
68
+ server.close(() => {
69
+ if (platform() !== 'win32' && existsSync(path))
70
+ rmSync(path);
71
+ done();
72
+ });
73
+ for (const s of open)
74
+ s.destroy();
75
+ });
76
+ }
77
+ /** Client side of the socket, used by the `notify` / `ask` commands. */
78
+ export function callControl(req, timeoutMs = 3600_000) {
79
+ return new Promise((resolve, reject) => {
80
+ const socket = connect(socketPath());
81
+ // The bridge applies the real deadline and withdraws the card from the phone when it passes;
82
+ // this one is only a backstop for a bridge that stopped answering at all.
83
+ const timer = setTimeout(() => {
84
+ socket.destroy();
85
+ reject(new Error('The AgentPager bridge stopped responding.'));
86
+ }, timeoutMs + 15_000);
87
+ let data = '';
88
+ socket.on('connect', () => socket.write(JSON.stringify(req) + '\n'));
89
+ socket.on('data', (d) => {
90
+ data += d.toString();
91
+ if (!data.includes('\n'))
92
+ return;
93
+ clearTimeout(timer);
94
+ socket.end();
95
+ try {
96
+ const res = JSON.parse(data.trim());
97
+ if (res.error)
98
+ return reject(new Error(res.error));
99
+ resolve(res);
100
+ }
101
+ catch (e) {
102
+ reject(new Error('Unreadable reply from the bridge.'));
103
+ }
104
+ });
105
+ socket.on('error', () => {
106
+ clearTimeout(timer);
107
+ reject(new Error('AgentPager is not running on this computer. Start it with `agentpager`.'));
108
+ });
109
+ });
110
+ }
package/dist/crypto.js ADDED
@@ -0,0 +1,65 @@
1
+ // End-to-end crypto shared with the phone (lib/remote/remote_crypto.dart).
2
+ // X25519 key agreement, HKDF-SHA256, ChaCha20-Poly1305 (IETF, 12-byte nonce).
3
+ // The relay only ever stores {n, c}; it can neither read nor forge messages.
4
+ import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, } from 'node:crypto';
5
+ export const b64u = (b) => Buffer.from(b).toString('base64url');
6
+ export const unb64u = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
7
+ export const utf8 = (s) => new Uint8Array(Buffer.from(s, 'utf8'));
8
+ export const random = (n) => new Uint8Array(randomBytes(n));
9
+ export function generateKeyPair() {
10
+ const { publicKey, privateKey } = generateKeyPairSync('x25519');
11
+ const jwk = privateKey.export({ format: 'jwk' });
12
+ void publicKey;
13
+ return { pub: jwk.x, priv: jwk.d };
14
+ }
15
+ export function sharedSecret(own, peerPub) {
16
+ const privateKey = createPrivateKey({
17
+ key: { kty: 'OKP', crv: 'X25519', d: own.priv, x: own.pub },
18
+ format: 'jwk',
19
+ });
20
+ const publicKey = createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: peerPub }, format: 'jwk' });
21
+ return new Uint8Array(diffieHellman({ privateKey, publicKey }));
22
+ }
23
+ export function hkdf(ikm, salt, info) {
24
+ return new Uint8Array(hkdfSync('sha256', ikm, salt, utf8(info), 32));
25
+ }
26
+ export function seal(key, plaintext, aad) {
27
+ const nonce = random(12);
28
+ const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
29
+ cipher.setAAD(utf8(aad), { plaintextLength: plaintext.length });
30
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
31
+ return { n: b64u(nonce), c: b64u(body) };
32
+ }
33
+ /** Returns null when the message was not produced by the holder of `key`. */
34
+ export function open(key, sealed, aad) {
35
+ try {
36
+ const body = unb64u(sealed.c);
37
+ if (body.length < 16)
38
+ return null;
39
+ const decipher = createDecipheriv('chacha20-poly1305', key, unb64u(sealed.n), { authTagLength: 16 });
40
+ decipher.setAAD(utf8(aad), { plaintextLength: body.length - 16 });
41
+ decipher.setAuthTag(body.subarray(body.length - 16));
42
+ return new Uint8Array(Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export const sealJson = (key, value, aad) => seal(key, utf8(JSON.stringify(value)), aad);
49
+ export function openJson(key, sealed, aad) {
50
+ const plain = open(key, sealed, aad);
51
+ if (!plain)
52
+ return null;
53
+ try {
54
+ return JSON.parse(Buffer.from(plain).toString('utf8'));
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /** Keys for one paired phone. Separate keys per direction so nonces never collide across them. */
61
+ export function deriveChannelKeys(shared, otk) {
62
+ const root = hkdf(shared, otk, 'mdpilot-root-v1');
63
+ return { b2p: hkdf(root, new Uint8Array(0), 'b2p'), p2b: hkdf(root, new Uint8Array(0), 'p2b') };
64
+ }
65
+ export const pairKey = (otk) => hkdf(otk, new Uint8Array(0), 'mdpilot-pair-v1');
@@ -0,0 +1,60 @@
1
+ // Is the code running the one that was written?
2
+ //
3
+ // The bridge runs from `dist/`, and `agentpager` is not on npm yet, so everyone — including us —
4
+ // launches a locally compiled copy. A `dist/` built before the last edit to `src/` is invisible:
5
+ // it starts, pairs, says "AgentPager bridge", and simply behaves like an older program. That is
6
+ // what made a phone show Codex alone with Claude Code missing, and no amount of reading the
7
+ // current source could explain it.
8
+ import { readdirSync, statSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ /** Newest mtime under a directory tree, or 0 if it cannot be read. */
12
+ function newest(dir) {
13
+ let latest = 0;
14
+ const walk = (d, depth) => {
15
+ if (depth > 6)
16
+ return;
17
+ let names;
18
+ try {
19
+ names = readdirSync(d);
20
+ }
21
+ catch {
22
+ return;
23
+ }
24
+ for (const name of names) {
25
+ if (name === 'node_modules' || name.startsWith('.'))
26
+ continue;
27
+ const p = join(d, name);
28
+ let st;
29
+ try {
30
+ st = statSync(p);
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ if (st.isDirectory())
36
+ walk(p, depth + 1);
37
+ else if (/\.(ts|js|json)$/.test(name))
38
+ latest = Math.max(latest, st.mtimeMs);
39
+ }
40
+ };
41
+ walk(dir, 0);
42
+ return latest;
43
+ }
44
+ /**
45
+ * Returns a warning when the compiled bridge is older than its sources, or null when it is current.
46
+ * Never throws and never blocks startup: a packaged install has no `src/` and must stay silent.
47
+ */
48
+ export function staleBuildWarning(distFile = fileURLToPath(import.meta.url)) {
49
+ const distDir = dirname(distFile);
50
+ const root = dirname(distDir);
51
+ const srcAt = newest(join(root, 'src'));
52
+ if (!srcAt)
53
+ return null; // published package: sources aren't shipped, nothing to compare
54
+ const distAt = newest(distDir);
55
+ if (!distAt || distAt >= srcAt)
56
+ return null;
57
+ const behind = Math.round((srcAt - distAt) / 1000);
58
+ const ago = behind > 86400 ? `${Math.round(behind / 86400)}d` : behind > 3600 ? `${Math.round(behind / 3600)}h` : `${Math.round(behind / 60)}m`;
59
+ return `This bridge was built ${ago} BEFORE the last source change — you are running old code. Run \`npm run build\` in bridge/ and start again.`;
60
+ }