@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.
@@ -0,0 +1,195 @@
1
+ // Which folders the phone may start a session in, and how it finds them.
2
+ //
3
+ // The first version only offered folders that ALREADY had a session on this computer. Walk that as
4
+ // the user: you install AgentPager because you want to start work from your phone, you pair, and
5
+ // the Start button is disabled until you go back to your desk and start a session by hand. The
6
+ // product was unusable on a fresh machine.
7
+ //
8
+ // So we discover the projects you actually work in (git repos) and let the phone browse for
9
+ // anything else — but only inside a few ROOTS, and never into hidden directories, so "the phone can
10
+ // pick a folder" never becomes "the phone can roam the disk".
11
+ //
12
+ // Roots are your home folder, the folder you started the bridge in, and the parent of any folder
13
+ // you already have a session in. That last one matters: this machine keeps its projects on an
14
+ // external drive, where a home-folder-only scan finds nothing at all.
15
+ import { readdirSync, realpathSync, statSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join, resolve, sep } from 'node:path';
18
+ // The REAL path of the home folder: every check below compares against realpath()s, so a home that
19
+ // sits behind a symlink (macOS /tmp, Fedora Silverblue's /home -> /var/home) would otherwise reject
20
+ // its own owner's home folder as "outside your home folder".
21
+ const HOME = (() => {
22
+ const h = resolve(homedir());
23
+ try {
24
+ return realpathSync.native(h);
25
+ }
26
+ catch {
27
+ return h;
28
+ }
29
+ })();
30
+ /** Big, uninteresting trees that a scan must not walk into. */
31
+ const SKIP = new Set([
32
+ 'node_modules',
33
+ 'Library',
34
+ 'Applications',
35
+ 'Pictures',
36
+ 'Music',
37
+ 'Movies',
38
+ 'build',
39
+ 'dist',
40
+ 'vendor',
41
+ 'target',
42
+ 'Pods',
43
+ '__pycache__',
44
+ 'venv',
45
+ 'go',
46
+ ]);
47
+ const MAX_DEPTH = 3;
48
+ const MAX_RESULTS = 40;
49
+ const hidden = (name) => name.startsWith('.');
50
+ /** Extra roots, learned from the sessions this computer already has. */
51
+ const extraRoots = new Set();
52
+ export function addRoot(dir) {
53
+ const full = real(resolve(dir));
54
+ if (!full)
55
+ return;
56
+ // The PARENT of a project, so its siblings are reachable — but never a whole drive: a session in
57
+ // /Volumes/DRIVE/proj must not make every folder on that disk browsable.
58
+ const parent = resolve(full, '..');
59
+ const depth = parent.split(sep).filter(Boolean).length;
60
+ const mountish = /^\/(Volumes|mnt|media|run\/media)(\/[^/]+)?$/.test(parent);
61
+ const root = depth >= 2 && !mountish ? parent : full;
62
+ if (root === HOME || root.startsWith(HOME + sep))
63
+ return;
64
+ // Never keep a root that sits inside another one — nested roots make "up one level" nonsense.
65
+ for (const existing of extraRoots) {
66
+ if (root === existing || root.startsWith(existing + sep))
67
+ return;
68
+ }
69
+ for (const existing of [...extraRoots]) {
70
+ if (existing.startsWith(root + sep))
71
+ extraRoots.delete(existing);
72
+ }
73
+ extraRoots.add(root);
74
+ }
75
+ /** Every folder tree the phone may see, home first. */
76
+ export function roots() {
77
+ return [HOME, ...extraRoots];
78
+ }
79
+ /**
80
+ * The folder rules are a promise we print in the store listing, so they are checked against the REAL
81
+ * path: a symlink in your home folder pointing at /tmp would otherwise walk straight out of it.
82
+ */
83
+ const real = (p) => {
84
+ try {
85
+ return realpathSync.native(p);
86
+ }
87
+ catch {
88
+ return null; // unreadable or dangling: refuse, never assume
89
+ }
90
+ };
91
+ const isDir = (p) => {
92
+ try {
93
+ return statSync(p).isDirectory();
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ };
99
+ const entries = (p) => {
100
+ try {
101
+ return readdirSync(p, { withFileTypes: true })
102
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
103
+ .map((e) => e.name);
104
+ }
105
+ catch {
106
+ return []; // unreadable folder: not an error, just nothing to offer
107
+ }
108
+ };
109
+ /**
110
+ * A folder the phone is allowed to start an agent in: inside your home folder, visible, real.
111
+ * Anything already known to the hub (a folder you have a session in) is allowed even if it sits
112
+ * elsewhere on the disk — you opened it yourself.
113
+ */
114
+ export function allowedCwd(p, known = []) {
115
+ const asked = resolve(p);
116
+ for (const k of known)
117
+ if (resolve(k) === asked)
118
+ return true;
119
+ const full = real(asked);
120
+ if (!full || !isDir(full))
121
+ return false;
122
+ const root = roots().find((r) => full === r || full.startsWith(r + sep));
123
+ if (!root)
124
+ return false;
125
+ return !full
126
+ .slice(root.length)
127
+ .split(sep)
128
+ .some((part) => part && (hidden(part) || SKIP.has(part)));
129
+ }
130
+ /** Git repositories in your roots, most recently touched first. */
131
+ export function discoverProjects() {
132
+ const found = [];
133
+ const seen = new Set();
134
+ const walk = (dir, depth) => {
135
+ if (found.length >= MAX_RESULTS * 3 || depth > MAX_DEPTH)
136
+ return;
137
+ for (const name of entries(dir)) {
138
+ if (hidden(name) || SKIP.has(name))
139
+ continue;
140
+ const child = join(dir, name);
141
+ if (!isDir(child))
142
+ continue;
143
+ // Symlinked folders are followed by isDir, so a link back into home (or a loop) would list the
144
+ // same repo several times and eat the 40 slots the phone's list is built from.
145
+ const truePath = real(child);
146
+ if (!truePath || seen.has(truePath))
147
+ continue;
148
+ seen.add(truePath);
149
+ if (isDir(join(child, '.git'))) {
150
+ let at = 0;
151
+ try {
152
+ at = statSync(child).mtimeMs;
153
+ }
154
+ catch {
155
+ /* keep it, just unranked */
156
+ }
157
+ found.push({ path: truePath, at });
158
+ continue; // a repo's own subfolders are not separate projects
159
+ }
160
+ walk(child, depth + 1);
161
+ }
162
+ };
163
+ for (const root of roots())
164
+ walk(root, 1);
165
+ return found
166
+ .sort((a, b) => b.at - a.at)
167
+ .slice(0, MAX_RESULTS)
168
+ .map((f) => f.path);
169
+ }
170
+ /**
171
+ * One level of folders, for the phone's folder picker. With no path it lists the roots themselves
172
+ * when there is more than one — otherwise a user whose work lives on another drive would be
173
+ * dropped into a home folder that has none of it.
174
+ */
175
+ export function browseFolders(path) {
176
+ const all = roots();
177
+ if (!path && all.length > 1) {
178
+ return {
179
+ path: '',
180
+ dirs: all.map((r) => ({ name: r === HOME ? 'Home' : r.split(sep).pop() || r, path: r, git: isDir(join(r, '.git')) })),
181
+ };
182
+ }
183
+ const at = path && allowedCwd(path) ? resolve(path) : HOME;
184
+ const dirs = entries(at)
185
+ .filter((name) => !hidden(name) && !SKIP.has(name))
186
+ .map((name) => ({ name, path: join(at, name) }))
187
+ .filter((d) => isDir(d.path))
188
+ .sort((a, b) => a.name.localeCompare(b.name))
189
+ .slice(0, 200)
190
+ .map((d) => ({ ...d, git: isDir(join(d.path, '.git')) }));
191
+ // No parent once you are at a root: the picker must not walk up out of what you allowed.
192
+ const atRoot = all.includes(at);
193
+ return { path: at, parent: atRoot ? (all.length > 1 ? '' : undefined) : resolve(at, '..'), dirs };
194
+ }
195
+ export const homeFolder = () => HOME;
@@ -0,0 +1 @@
1
+ export const PROTOCOL = 1;
package/dist/relay.js ADDED
@@ -0,0 +1,134 @@
1
+ // Firebase Realtime Database as a dumb, encrypted mailbox. It stores {n, c, t} and nothing else.
2
+ // ch/{channel}/p2b/{push} phone → computer (deleted once read)
3
+ // ch/{channel}/b2p/{push} computer → phone (deleted by the phone once read)
4
+ // ch/{channel}/hb computer heartbeat {t, on} — lets the phone say "last heard from 3 min ago"
5
+ // ch/{channel}/phb phone heartbeat {t} — lets us skip streaming to a phone nobody is looking at
6
+ import { initializeApp } from 'firebase/app';
7
+ import { child, endAt, get, getDatabase, orderByChild, query, onChildAdded, onDisconnect, onValue, push, ref, remove, serverTimestamp, set, } from 'firebase/database';
8
+ import { openJson, sealJson, unb64u } from './crypto.js';
9
+ const FIREBASE = {
10
+ apiKey: 'AIzaSyBJ8xQRZ9lE7mApTO8rgwiJoBAsVO1DwFM',
11
+ projectId: 'mdpilot-proto',
12
+ databaseURL: 'https://mdpilot-proto-default-rtdb.firebaseio.com',
13
+ };
14
+ export const db = getDatabase(initializeApp(FIREBASE));
15
+ let serverOffset = 0;
16
+ onValue(ref(db, '.info/serverTimeOffset'), (s) => {
17
+ serverOffset = Number(s.val() ?? 0);
18
+ });
19
+ export const serverNow = () => Date.now() + serverOffset;
20
+ const PHONE_ACTIVE_MS = 45_000;
21
+ const PUSH_URL = process.env.AGENTPAGER_PUSH_URL ?? process.env.MDPILOT_PUSH_URL ?? 'https://mdpilot-push.vercel.app/api/push';
22
+ const HEARTBEAT_MS = 30_000;
23
+ const STALE_MS = 7 * 24 * 3600_000;
24
+ export class Channel {
25
+ device;
26
+ persist;
27
+ onMessage;
28
+ unsubs = [];
29
+ timer;
30
+ phoneSeen = 0;
31
+ phoneAway = true;
32
+ kB2P;
33
+ kP2B;
34
+ constructor(device, persist, onMessage) {
35
+ this.device = device;
36
+ this.persist = persist;
37
+ this.onMessage = onMessage;
38
+ this.kB2P = unb64u(device.kB2P);
39
+ this.kP2B = unb64u(device.kP2B);
40
+ }
41
+ path(leaf) {
42
+ return ref(db, `ch/${this.device.channel}/${leaf}`);
43
+ }
44
+ start() {
45
+ this.unsubs.push(onChildAdded(this.path('p2b'), (snap) => {
46
+ const raw = snap.val();
47
+ if (snap.key)
48
+ remove(child(this.path('p2b'), snap.key)).catch(() => { }); // exactly this message
49
+ if (!raw)
50
+ return;
51
+ const env = openJson(this.kP2B, raw, `${this.device.channel}|p2b`);
52
+ if (!env)
53
+ return; // not from our phone
54
+ if (env.seq <= this.device.recvSeq)
55
+ return; // replay
56
+ this.device.recvSeq = env.seq;
57
+ this.persist();
58
+ this.onMessage(env.body, this);
59
+ }));
60
+ this.unsubs.push(onValue(this.path('phb'), (snap) => {
61
+ this.phoneSeen = Number(snap.val()?.t ?? 0);
62
+ this.phoneAway = snap.val()?.away === true;
63
+ }));
64
+ this.sweep();
65
+ const hb = this.path('hb');
66
+ onDisconnect(hb).set({ t: serverTimestamp(), on: false }).catch(() => { });
67
+ const beat = () => set(hb, { t: serverTimestamp(), on: true }).catch(() => { });
68
+ beat();
69
+ this.timer = setInterval(beat, HEARTBEAT_MS);
70
+ }
71
+ /** Messages a phone never collected (uninstalled, lost) don't live in the relay forever. */
72
+ sweep() {
73
+ const cutoff = serverNow() - STALE_MS;
74
+ for (const dir of ['b2p', 'p2b']) {
75
+ get(query(this.path(dir), orderByChild('t'), endAt(cutoff)))
76
+ .then((snap) => snap.forEach((child) => void remove(child.ref).catch(() => { })))
77
+ .catch(() => { });
78
+ }
79
+ }
80
+ /** Is someone looking at the app right now? */
81
+ get phoneActive() {
82
+ return !this.phoneAway && serverNow() - this.phoneSeen < PHONE_ACTIVE_MS;
83
+ }
84
+ /**
85
+ * Wake the phone when nobody is looking: approvals, results, failures only.
86
+ * The payload is sealed with this phone's key; the push service and Google see ciphertext.
87
+ */
88
+ notify(kind, note, force = false) {
89
+ const token = this.device.pushToken;
90
+ if (!token)
91
+ return;
92
+ // Normally we don't buzz a phone someone is already looking at. A test is the exception: its
93
+ // whole purpose is to travel the real path while the screen is locked.
94
+ if (this.phoneActive && !force)
95
+ return;
96
+ const sealed = sealJson(this.kB2P, { kind, ...note }, `push|${this.device.id}`);
97
+ fetch(PUSH_URL, {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ token, d: this.device.id, kind, ...sealed }),
101
+ })
102
+ .then((r) => {
103
+ if (r.status === 410) {
104
+ this.device.pushToken = undefined; // app uninstalled or token rotated
105
+ this.persist();
106
+ }
107
+ })
108
+ .catch(() => { });
109
+ }
110
+ /** Can this phone be buzzed while the app is closed? False = it will only see this if it's open. */
111
+ get canPush() {
112
+ return !!this.device.pushToken;
113
+ }
114
+ get deviceName() {
115
+ return this.device.name;
116
+ }
117
+ setPushToken(token) {
118
+ this.device.pushToken = token ?? undefined;
119
+ this.persist();
120
+ }
121
+ send(body) {
122
+ const seq = ++this.device.sendSeq;
123
+ this.persist();
124
+ const sealed = sealJson(this.kB2P, { seq, ts: Date.now(), body }, `${this.device.channel}|b2p`);
125
+ push(this.path('b2p'), { ...sealed, t: serverTimestamp() }).catch(() => { });
126
+ }
127
+ async stop() {
128
+ if (this.timer)
129
+ clearInterval(this.timer);
130
+ for (const u of this.unsubs.splice(0))
131
+ u();
132
+ await set(this.path('hb'), { t: serverTimestamp(), on: false }).catch(() => { });
133
+ }
134
+ }
package/dist/risk.js ADDED
@@ -0,0 +1,78 @@
1
+ const READ_ONLY = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookRead']);
2
+ const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit']);
3
+ const DANGER = [
4
+ /\brm\s+(-[a-z]*[rf]|--recursive|--force)/i,
5
+ /\bgit\s+push\b/i,
6
+ /\bgit\s+(reset\s+--hard|clean\s+-[a-z]*f|checkout\s+--\s|branch\s+-D)/i,
7
+ /--force\b|\s-f\s.*\bpush\b/i,
8
+ /\bsudo\b/i,
9
+ /\b(mkfs|dd\s+if=|shutdown|reboot)\b/i,
10
+ /\bchmod\s+-R\b|\bchown\s+-R\b/i,
11
+ /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/i,
12
+ /\bdrop\s+(table|database)\b|\btruncate\s+table\b/i,
13
+ /\b(firebase|vercel|netlify|fly|heroku)\s+deploy\b|\bdeploy\b/i,
14
+ /\b(npm|pnpm|yarn|cargo|twine|gem)\s+publish\b/i,
15
+ /\bgh\s+(release|pr\s+merge|repo\s+delete)\b/i,
16
+ /\bkubectl\s+(delete|apply)\b|\bterraform\s+(apply|destroy)\b/i,
17
+ /\bdocker\s+(system\s+prune|rm\s+-f|volume\s+rm)\b/i,
18
+ ];
19
+ export function classify(tool, input) {
20
+ if (READ_ONLY.has(tool))
21
+ return 'low';
22
+ if (EDITS.has(tool))
23
+ return 'edit';
24
+ const command = String(input.command ?? input.cmd ?? '');
25
+ if (command && DANGER.some((re) => re.test(command)))
26
+ return 'danger';
27
+ return 'shell';
28
+ }
29
+ /** One short line a person can read at a glance (or hear): never a whole file. */
30
+ export function describe(tool, input) {
31
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
32
+ const path = String(input.file_path ?? input.path ?? input.notebook_path ?? '');
33
+ switch (tool) {
34
+ case 'Bash':
35
+ case 'shell':
36
+ return { summary: 'Run a command', detail: clip(String(input.command ?? ''), 2000) };
37
+ case 'Edit':
38
+ case 'MultiEdit':
39
+ case 'edit':
40
+ return { summary: `Edit ${basename(path) || 'files'}`, detail: clip(String(input.detail ?? '') || path, 2000) };
41
+ case 'Write':
42
+ return { summary: `Write ${basename(path)}`, detail: clip(path, 2000) };
43
+ case 'WebFetch':
44
+ return { summary: 'Fetch a web page', detail: clip(String(input.url ?? ''), 500) };
45
+ default:
46
+ // 🧨 A JSON.stringify dump is what a phone screen CANNOT read — it is the single most-hated
47
+ // thing about the competing clients, and it is what an unknown tool used to produce here.
48
+ // Pull out the fields that actually mean something, and only fall back to raw JSON when the
49
+ // payload is genuinely unrecognisable.
50
+ return { summary: `Use ${tool}`, detail: clip(readable(input), 1500) };
51
+ }
52
+ }
53
+ /** The human-meaningful parts of an arbitrary tool payload, largest-signal first. */
54
+ export function readable(input) {
55
+ const lines = [];
56
+ const seen = new Set();
57
+ // The keys agents actually use for the thing a person needs to see before saying yes.
58
+ for (const key of ['command', 'cmd', 'script', 'file_path', 'path', 'url', 'pattern', 'query', 'prompt', 'description', 'reason', 'content', 'new_string', 'detail']) {
59
+ const v = input[key];
60
+ if (v == null)
61
+ continue;
62
+ const text = typeof v === 'string' ? v : JSON.stringify(v);
63
+ if (!text || text === '{}' || text === '[]' || seen.has(text))
64
+ continue;
65
+ seen.add(text);
66
+ lines.push(lines.length === 0 && (key === 'command' || key === 'cmd' || key === 'script') ? text : `${key}: ${text}`);
67
+ }
68
+ if (lines.length)
69
+ return lines.join('\n');
70
+ // Nothing recognisable: show the shape rather than a wall of braces and quotes.
71
+ const entries = Object.entries(input).filter(([, v]) => v != null);
72
+ if (!entries.length)
73
+ return '';
74
+ return entries
75
+ .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
76
+ .join('\n');
77
+ }
78
+ const basename = (p) => p.split(/[\\/]/).filter(Boolean).pop() ?? '';
@@ -0,0 +1,117 @@
1
+ // `agentpager service install|uninstall` — keep the bridge running across reboots, as the user
2
+ // (it must see the agents' own logins in the user's keychain/config, so never as root/SYSTEM).
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir, platform, userInfo } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { stateDir } from './store.js';
9
+ const LABEL = 'com.cosmovex.agentpager';
10
+ const cli = fileURLToPath(new URL('./cli.js', import.meta.url));
11
+ const node = process.execPath;
12
+ const logFile = join(stateDir, 'bridge.log');
13
+ function run(cmd, args) {
14
+ execFileSync(cmd, args, { stdio: 'inherit' });
15
+ }
16
+ /** npx runs from a throwaway cache; a service pointing there breaks on the next cache clean. */
17
+ function assertStableInstall() {
18
+ if (/[\\/]_npx[\\/]/.test(cli)) {
19
+ throw new Error('Install it permanently first: npm install -g agentpager then run: agentpager service install');
20
+ }
21
+ // A macOS LaunchAgent cannot read an external/removable volume: the job starts and hangs before
22
+ // the first line of code, with no error anywhere. Measured 2026-09-23.
23
+ if (platform() === 'darwin' && cli.startsWith('/Volumes/')) {
24
+ throw new Error(`This copy lives on an external volume (${cli}).\n` +
25
+ 'macOS login items cannot read those, so the service would start and hang silently.\n' +
26
+ 'Install it on the internal disk instead: npm install -g agentpager then: agentpager service install');
27
+ }
28
+ }
29
+ export function installService() {
30
+ assertStableInstall();
31
+ mkdirSync(stateDir, { recursive: true });
32
+ const os = platform();
33
+ if (os === 'darwin') {
34
+ const dir = join(homedir(), 'Library', 'LaunchAgents');
35
+ mkdirSync(dir, { recursive: true });
36
+ const plist = join(dir, `${LABEL}.plist`);
37
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
38
+ writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
39
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
40
+ <plist version="1.0"><dict>
41
+ <key>Label</key><string>${LABEL}</string>
42
+ <key>ProgramArguments</key><array><string>${esc(node)}</string><string>${esc(cli)}</string><string>start</string></array>
43
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${esc(process.env.PATH ?? '')}</string></dict>
44
+ <key>RunAtLoad</key><true/>
45
+ <key>KeepAlive</key><true/>
46
+ <key>StandardOutPath</key><string>${esc(logFile)}</string>
47
+ <key>StandardErrorPath</key><string>${esc(logFile)}</string>
48
+ </dict></plist>
49
+ `);
50
+ const domain = `gui/${userInfo().uid}`;
51
+ try {
52
+ execFileSync('launchctl', ['bootout', domain, plist], { stdio: 'ignore' });
53
+ }
54
+ catch { }
55
+ run('launchctl', ['bootstrap', domain, plist]);
56
+ return `Running now and at every login (LaunchAgent). Log: ${logFile}\nNote: if Node or your agents live on an external drive, macOS may block a login item from reading it — install them on the main disk.`;
57
+ }
58
+ if (os === 'linux') {
59
+ const dir = join(homedir(), '.config', 'systemd', 'user');
60
+ mkdirSync(dir, { recursive: true });
61
+ writeFileSync(join(dir, 'agentpager.service'), `[Unit]
62
+ Description=AgentPager bridge (phone remote for coding agents)
63
+ After=network-online.target
64
+
65
+ [Service]
66
+ ExecStart=${node} ${cli} start
67
+ Environment=PATH=${process.env.PATH ?? ''}
68
+ Restart=always
69
+ RestartSec=5
70
+ StandardOutput=append:${logFile}
71
+ StandardError=append:${logFile}
72
+
73
+ [Install]
74
+ WantedBy=default.target
75
+ `);
76
+ run('systemctl', ['--user', 'daemon-reload']);
77
+ run('systemctl', ['--user', 'enable', '--now', 'agentpager.service']);
78
+ return `Running now and at every login (systemd user service). Log: ${logFile}\nTo keep it running while you're logged out: loginctl enable-linger ${userInfo().username}`;
79
+ }
80
+ if (os === 'win32') {
81
+ run('schtasks', ['/Create', '/F', '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TN', 'AgentPager', '/TR', `"${node}" "${cli}" start`]);
82
+ run('schtasks', ['/Run', '/TN', 'AgentPager']);
83
+ return 'Running now and at every sign-in (Scheduled Task "AgentPager").';
84
+ }
85
+ throw new Error(`Automatic start isn't supported on ${os} yet. Keep \`agentpager\` running in a terminal.`);
86
+ }
87
+ export function uninstallService() {
88
+ const os = platform();
89
+ if (os === 'darwin') {
90
+ const plist = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
91
+ try {
92
+ execFileSync('launchctl', ['bootout', `gui/${userInfo().uid}`, plist], { stdio: 'ignore' });
93
+ }
94
+ catch { }
95
+ if (existsSync(plist))
96
+ rmSync(plist);
97
+ return 'Stopped and removed from login items.';
98
+ }
99
+ if (os === 'linux') {
100
+ try {
101
+ execFileSync('systemctl', ['--user', 'disable', '--now', 'agentpager.service'], { stdio: 'ignore' });
102
+ }
103
+ catch { }
104
+ const unit = join(homedir(), '.config', 'systemd', 'user', 'agentpager.service');
105
+ if (existsSync(unit))
106
+ rmSync(unit);
107
+ return 'Stopped and removed.';
108
+ }
109
+ if (os === 'win32') {
110
+ try {
111
+ execFileSync('schtasks', ['/Delete', '/F', '/TN', 'AgentPager'], { stdio: 'ignore' });
112
+ }
113
+ catch { }
114
+ return 'Removed the "AgentPager" scheduled task.';
115
+ }
116
+ return 'Nothing to remove.';
117
+ }
package/dist/store.js ADDED
@@ -0,0 +1,83 @@
1
+ // Local state in ~/.mdpilot/state.json (mode 600): this computer's identity and paired phones.
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { homedir, hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { statSync } from 'node:fs';
6
+ import { generateKeyPair } from './crypto.js';
7
+ export const stateDir = process.env.AGENTPAGER_HOME ?? join(homedir(), '.agentpager');
8
+ const stateFile = join(stateDir, 'state.json');
9
+ export function loadState() {
10
+ if (!existsSync(stateDir))
11
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
12
+ if (existsSync(stateFile))
13
+ return JSON.parse(readFileSync(stateFile, 'utf8'));
14
+ const fresh = {
15
+ identity: generateKeyPair(),
16
+ machineName: hostname().replace(/\.local$/, ''),
17
+ devices: [],
18
+ };
19
+ saveState(fresh);
20
+ return fresh;
21
+ }
22
+ /**
23
+ * Change the devices list without losing somebody else's change.
24
+ *
25
+ * `main()` loads state once at startup, and long-lived commands then save that snapshot minutes
26
+ * later. `agentpager pair` sits waiting for a QR scan the whole time. So unpairing two phones in
27
+ * another terminal, watching `devices` confirm they were gone, and then completing a pair brought
28
+ * both of them back — with their original timestamps — because pair wrote its stale copy over the
29
+ * top. That is not untidiness: a device entry is a live credential, so **unpair was not actually
30
+ * revoking access.**
31
+ *
32
+ * Re-reading inside a lock at the moment of writing is what makes a deletion stick.
33
+ */
34
+ export function updateState(mutate) {
35
+ const release = lock();
36
+ try {
37
+ const fresh = loadState();
38
+ mutate(fresh);
39
+ saveState(fresh);
40
+ return fresh;
41
+ }
42
+ finally {
43
+ release();
44
+ }
45
+ }
46
+ /** Crude on purpose: one writer at a time, and never wedged by a crash that left the file behind. */
47
+ function lock() {
48
+ const path = `${stateFile}.lock`;
49
+ const deadline = Date.now() + 5000;
50
+ for (;;) {
51
+ try {
52
+ closeSync(openSync(path, 'wx')); // O_EXCL: fails if another process holds it
53
+ return () => {
54
+ try {
55
+ rmSync(path);
56
+ }
57
+ catch {
58
+ /* already gone */
59
+ }
60
+ };
61
+ }
62
+ catch {
63
+ // Stale lock from a process that died mid-write: take it rather than block for ever.
64
+ try {
65
+ if (Date.now() - statSync(path).mtimeMs > 30_000)
66
+ rmSync(path);
67
+ }
68
+ catch {
69
+ /* raced with the owner releasing it */
70
+ }
71
+ if (Date.now() > deadline)
72
+ return () => { }; // never make a write impossible
73
+ sleepSync(25);
74
+ }
75
+ }
76
+ }
77
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
78
+ export function saveState(state) {
79
+ const tmp = `${stateFile}.tmp`;
80
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
81
+ renameSync(tmp, stateFile);
82
+ chmodSync(stateFile, 0o600);
83
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@cosmovex/agentpager",
3
+ "version": "0.1.0",
4
+ "description": "Pager for your AI coding agents \u2014 approve, reply and hear results on your phone. End-to-end encrypted.",
5
+ "main": "dist/cli.js",
6
+ "scripts": {
7
+ "build": "tsc && node -e \"const fs=require('fs');for(const f of ['dist/cli.js','dist/guard/bin.js'])fs.chmodSync(f,0o755)\"",
8
+ "dev": "tsx src/cli.ts",
9
+ "test": "tsx --test --test-force-exit --test-concurrency=1 src/guard/*.test.ts src/agents/*.test.ts src/*.test.ts && tsx src/selftest.ts",
10
+ "prepublishOnly": "npm run build"
11
+ },
12
+ "author": "",
13
+ "license": "SEE LICENSE IN LICENSE",
14
+ "type": "module",
15
+ "dependencies": {
16
+ "@anthropic-ai/claude-agent-sdk": "^0.3.278",
17
+ "@zed-industries/agent-client-protocol": "^0.4.5",
18
+ "firebase": "^12.19.0",
19
+ "qrcode-terminal": "^0.12.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^26.6.2",
23
+ "tsx": "^4.23.15",
24
+ "typescript": "^7.0.2"
25
+ },
26
+ "bin": {
27
+ "agentpager": "dist/cli.js",
28
+ "agentpager-guard": "dist/guard/bin.js"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "keywords": [
39
+ "claude-code",
40
+ "codex",
41
+ "acp",
42
+ "agent",
43
+ "remote",
44
+ "mobile",
45
+ "approval",
46
+ "notifications",
47
+ "voice",
48
+ "agentpager",
49
+ "gemini-cli",
50
+ "opencode",
51
+ "qwen-code",
52
+ "goose",
53
+ "deepseek",
54
+ "agent-client-protocol",
55
+ "hands-free"
56
+ ],
57
+ "homepage": "https://play.google.com/store/apps/details?id=com.cosmovex.agentpager"
58
+ }