agent-pager 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,441 @@
1
+ import { createServer } from "node:http";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { dirname, extname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { canonicalRequest, newId, nowIso, signText, stableJson, verifyText } from "./crypto.js";
6
+ import { Store } from "./store.js";
7
+ const MESSAGE_MAX = 600;
8
+ const RATE_LIMIT_WINDOW_MS = 60_000;
9
+ const RATE_LIMIT_MAX = 8;
10
+ const WEB_ROOT = findWebRoot();
11
+ export async function runServer(argv) {
12
+ const port = Number(readArg(argv, "--port") || process.env.PORT || 8787);
13
+ const host = readArg(argv, "--host") || process.env.HOST || "127.0.0.1";
14
+ const publicUrl = process.env.AGENT_PAGER_PUBLIC_URL || `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
15
+ const store = new Store();
16
+ const listeners = new Set();
17
+ const server = createServer(async (req, res) => {
18
+ try {
19
+ setCors(req, res);
20
+ if (req.method === "OPTIONS") {
21
+ res.writeHead(204).end();
22
+ return;
23
+ }
24
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
25
+ if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
26
+ serveStatic(res, url.pathname);
27
+ return;
28
+ }
29
+ if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
30
+ serveIndex(res);
31
+ return;
32
+ }
33
+ if (req.method === "GET" && url.pathname === "/api/health") {
34
+ sendJson(res, { ok: true, service: "agent-pager", users: store.data().users.length, publicUrl });
35
+ return;
36
+ }
37
+ if (req.method === "GET" && url.pathname === "/api/config") {
38
+ sendJson(res, { mode: "prototype", publicUrl });
39
+ return;
40
+ }
41
+ if (req.method === "GET" && url.pathname === "/api/public/state") {
42
+ const db = store.data();
43
+ sendJson(res, {
44
+ publicUrl,
45
+ users: db.users.map((user) => publicUser(user, db.devices.filter((device) => device.userId === user.id))),
46
+ pages: db.pages.slice(-30).reverse(),
47
+ auditEvents: db.auditEvents.slice(0, 60),
48
+ friendRequests: db.friendRequests.slice(0, 30),
49
+ friendships: db.friendships
50
+ });
51
+ return;
52
+ }
53
+ if (req.method === "GET" && url.pathname.startsWith("/api/public/users/")) {
54
+ const username = decodeURIComponent(url.pathname.split("/").pop() || "");
55
+ const user = store.data().users.find((candidate) => candidate.username === username);
56
+ if (!user || !user.publicProfileEnabled) {
57
+ sendJson(res, { error: "profile not found" }, 404);
58
+ return;
59
+ }
60
+ sendJson(res, publicUser(user, store.data().devices.filter((device) => device.userId === user.id)));
61
+ return;
62
+ }
63
+ if (req.method === "POST" && url.pathname === "/api/dev/login") {
64
+ const body = await readJson(req);
65
+ const username = cleanUsername(body.username);
66
+ const displayName = cleanText(body.displayName || username, 80);
67
+ const publicKeyPem = String(body.publicKeyPem || "");
68
+ const deviceName = cleanText(body.deviceName || "local terminal", 80);
69
+ if (!username || !publicKeyPem.includes("PUBLIC KEY")) {
70
+ sendJson(res, { error: "username and publicKeyPem are required" }, 400);
71
+ return;
72
+ }
73
+ const db = store.data();
74
+ let user = db.users.find((candidate) => candidate.username === username);
75
+ const at = nowIso();
76
+ if (!user) {
77
+ user = {
78
+ id: newId("usr"),
79
+ username,
80
+ displayName,
81
+ status: "offline",
82
+ publicProfileEnabled: true,
83
+ createdAt: at,
84
+ updatedAt: at
85
+ };
86
+ db.users.push(user);
87
+ }
88
+ const device = {
89
+ id: newId("dev"),
90
+ userId: user.id,
91
+ name: deviceName,
92
+ publicKeyPem,
93
+ createdAt: at,
94
+ lastSeenAt: at
95
+ };
96
+ db.devices.push(device);
97
+ store.save();
98
+ store.audit({ actorUserId: user.id, action: "device.login", target: device.id, meta: { username } });
99
+ sendJson(res, { user, device, serverPublicKeyPem: db.serverKeyPair.publicKeyPem, publicUrl });
100
+ return;
101
+ }
102
+ if (req.method === "GET" && url.pathname === "/api/events") {
103
+ const authed = await authenticate(req, url, store);
104
+ authed.user.status = authed.user.status === "offline" ? "online" : authed.user.status;
105
+ authed.user.updatedAt = nowIso();
106
+ authed.device.lastSeenAt = nowIso();
107
+ store.save();
108
+ openEvents(res, listeners, authed.user.id, store);
109
+ return;
110
+ }
111
+ const authed = await authenticate(req, url, store);
112
+ if (req.method === "GET" && url.pathname === "/api/me") {
113
+ sendJson(res, {
114
+ user: authed.user,
115
+ device: authed.device,
116
+ friends: getFriends(store, authed.user.id),
117
+ pendingPages: pendingPages(store, authed.user.id)
118
+ });
119
+ return;
120
+ }
121
+ if (req.method === "POST" && url.pathname === "/api/presence") {
122
+ const body = parseBody(authed.bodyText);
123
+ const status = ["online", "busy", "muted", "offline"].includes(body.status) ? body.status : "online";
124
+ authed.user.status = status;
125
+ authed.user.updatedAt = nowIso();
126
+ authed.device.lastSeenAt = nowIso();
127
+ store.save();
128
+ store.audit({ actorUserId: authed.user.id, action: "presence.update", meta: { status } });
129
+ sendJson(res, { ok: true, user: authed.user });
130
+ return;
131
+ }
132
+ if (req.method === "POST" && url.pathname === "/api/invites") {
133
+ const body = parseBody(authed.bodyText);
134
+ const ttlHours = Math.min(Math.max(Number(body.ttlHours || 24), 1), 168);
135
+ const invite = {
136
+ code: randomCode(),
137
+ createdByUserId: authed.user.id,
138
+ createdAt: nowIso(),
139
+ expiresAt: new Date(Date.now() + ttlHours * 3600_000).toISOString()
140
+ };
141
+ store.data().invites.push(invite);
142
+ store.save();
143
+ store.audit({ actorUserId: authed.user.id, action: "invite.create", target: invite.code });
144
+ sendJson(res, { invite, url: `${publicUrl}/invite/${invite.code}` });
145
+ return;
146
+ }
147
+ const inviteAcceptMatch = url.pathname.match(/^\/api\/invites\/([^/]+)\/accept$/);
148
+ if (req.method === "POST" && inviteAcceptMatch) {
149
+ const code = inviteAcceptMatch[1];
150
+ const db = store.data();
151
+ const invite = db.invites.find((candidate) => candidate.code === code);
152
+ if (!invite || invite.revokedAt || invite.acceptedByUserId || Date.parse(invite.expiresAt) < Date.now()) {
153
+ sendJson(res, { error: "invite is invalid or expired" }, 400);
154
+ return;
155
+ }
156
+ if (invite.createdByUserId === authed.user.id) {
157
+ sendJson(res, { error: "cannot accept your own invite" }, 400);
158
+ return;
159
+ }
160
+ ensureFriendship(store, invite.createdByUserId, authed.user.id);
161
+ invite.acceptedByUserId = authed.user.id;
162
+ store.save();
163
+ store.audit({ actorUserId: authed.user.id, action: "invite.accept", target: code });
164
+ sendJson(res, { ok: true, friends: getFriends(store, authed.user.id) });
165
+ return;
166
+ }
167
+ if (req.method === "GET" && url.pathname === "/api/friends") {
168
+ sendJson(res, { friends: getFriends(store, authed.user.id) });
169
+ return;
170
+ }
171
+ if (req.method === "POST" && url.pathname === "/api/pages") {
172
+ const body = parseBody(authed.bodyText);
173
+ const to = cleanUsername(body.to);
174
+ const message = cleanText(body.message, MESSAGE_MAX);
175
+ const urgency = normalizeUrgency(body.urgency);
176
+ const recipient = store.data().users.find((user) => user.username === to);
177
+ if (!recipient) {
178
+ sendJson(res, { error: "recipient not found" }, 404);
179
+ return;
180
+ }
181
+ const allowed = canPage(store, authed.user.id, recipient.id);
182
+ if (!allowed.ok) {
183
+ sendJson(res, { error: allowed.reason }, 403);
184
+ return;
185
+ }
186
+ const limited = isRateLimited(store, authed.user.id, recipient.id);
187
+ if (limited) {
188
+ sendJson(res, { error: "rate limited for this friend" }, 429);
189
+ return;
190
+ }
191
+ if (!message) {
192
+ sendJson(res, { error: "message is required" }, 400);
193
+ return;
194
+ }
195
+ const page = {
196
+ id: newId("page"),
197
+ fromUserId: authed.user.id,
198
+ toUserId: recipient.id,
199
+ fromUsername: authed.user.username,
200
+ toUsername: recipient.username,
201
+ message,
202
+ urgency,
203
+ replyToPageId: body.replyToPageId ? String(body.replyToPageId) : undefined,
204
+ createdAt: nowIso()
205
+ };
206
+ store.data().pages.push(page);
207
+ store.save();
208
+ store.audit({ actorUserId: authed.user.id, action: "page.send", target: recipient.username, meta: { urgency } });
209
+ pushPage(listeners, store, page);
210
+ sendJson(res, { ok: true, page });
211
+ return;
212
+ }
213
+ if (req.method === "GET" && url.pathname === "/api/pages") {
214
+ const pages = store.data().pages
215
+ .filter((page) => page.fromUserId === authed.user.id || page.toUserId === authed.user.id)
216
+ .slice(-100)
217
+ .reverse();
218
+ sendJson(res, { pages });
219
+ return;
220
+ }
221
+ const pageAckMatch = url.pathname.match(/^\/api\/pages\/([^/]+)\/ack$/);
222
+ if (req.method === "POST" && pageAckMatch) {
223
+ const page = store.data().pages.find((candidate) => candidate.id === pageAckMatch[1] && candidate.toUserId === authed.user.id);
224
+ if (page && !page.deliveredAt) {
225
+ page.deliveredAt = nowIso();
226
+ store.save();
227
+ store.audit({ actorUserId: authed.user.id, action: "page.deliver", target: page.id });
228
+ }
229
+ sendJson(res, { ok: true });
230
+ return;
231
+ }
232
+ sendJson(res, { error: "not found" }, 404);
233
+ }
234
+ catch (error) {
235
+ sendJson(res, { error: error instanceof Error ? error.message : "server error" }, 500);
236
+ }
237
+ });
238
+ await new Promise((resolveListen) => server.listen(port, host, resolveListen));
239
+ console.log(`Agent Pager service: ${publicUrl}`);
240
+ console.log("Authorized Human Contact Service is online.");
241
+ }
242
+ function readArg(argv, name) {
243
+ const index = argv.indexOf(name);
244
+ return index >= 0 ? argv[index + 1] : undefined;
245
+ }
246
+ async function authenticate(req, url, store) {
247
+ const bodyText = ["POST", "PUT", "PATCH"].includes(req.method || "") ? await readText(req) : "";
248
+ const deviceId = String(req.headers["x-ap-device-id"] || "");
249
+ const timestamp = String(req.headers["x-ap-timestamp"] || "");
250
+ const signature = String(req.headers["x-ap-signature"] || "");
251
+ const device = store.data().devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt);
252
+ if (!device) {
253
+ throw new Error("unknown device");
254
+ }
255
+ const user = store.data().users.find((candidate) => candidate.id === device.userId);
256
+ if (!user) {
257
+ throw new Error("device has no user");
258
+ }
259
+ const ageMs = Math.abs(Date.now() - Date.parse(timestamp));
260
+ if (!timestamp || !Number.isFinite(ageMs) || ageMs > 5 * 60_000) {
261
+ throw new Error("stale request timestamp");
262
+ }
263
+ const pathAndQuery = `${url.pathname}${url.search}`;
264
+ const canonical = canonicalRequest(req.method || "GET", pathAndQuery, timestamp, bodyText);
265
+ if (!verifyText(device.publicKeyPem, canonical, signature)) {
266
+ throw new Error("invalid request signature");
267
+ }
268
+ device.lastSeenAt = nowIso();
269
+ return { device, user, bodyText };
270
+ }
271
+ function publicUser(user, devices) {
272
+ return {
273
+ id: user.id,
274
+ username: user.username,
275
+ displayName: user.displayName,
276
+ status: user.status,
277
+ reachable: user.status !== "offline" && devices.some((device) => !device.revokedAt),
278
+ deviceCount: devices.filter((device) => !device.revokedAt).length
279
+ };
280
+ }
281
+ function getFriends(store, userId) {
282
+ const db = store.data();
283
+ return db.friendships
284
+ .filter((friendship) => friendship.userA === userId || friendship.userB === userId)
285
+ .map((friendship) => {
286
+ const friendId = friendship.userA === userId ? friendship.userB : friendship.userA;
287
+ const user = db.users.find((candidate) => candidate.id === friendId);
288
+ return user ? { ...publicUser(user, db.devices.filter((device) => device.userId === user.id)), muted: friendship.mutedByUserIds.includes(userId) } : null;
289
+ })
290
+ .filter(Boolean);
291
+ }
292
+ function ensureFriendship(store, userA, userB) {
293
+ const [left, right] = [userA, userB].sort();
294
+ const exists = store.data().friendships.some((friendship) => friendship.userA === left && friendship.userB === right);
295
+ if (!exists) {
296
+ store.data().friendships.push({
297
+ id: newId("fr"),
298
+ userA: left,
299
+ userB: right,
300
+ createdAt: nowIso(),
301
+ mutedByUserIds: [],
302
+ blockedByUserIds: []
303
+ });
304
+ }
305
+ }
306
+ function canPage(store, fromUserId, toUserId) {
307
+ const friendship = store.data().friendships.find((candidate) => (candidate.userA === fromUserId && candidate.userB === toUserId) || (candidate.userA === toUserId && candidate.userB === fromUserId));
308
+ if (!friendship) {
309
+ return { ok: false, reason: "not friends" };
310
+ }
311
+ if (friendship.blockedByUserIds.includes(toUserId) || friendship.mutedByUserIds.includes(toUserId)) {
312
+ return { ok: false, reason: "recipient is not accepting pages from you" };
313
+ }
314
+ const recipient = store.data().users.find((user) => user.id === toUserId);
315
+ if (recipient?.status === "muted") {
316
+ return { ok: false, reason: "recipient is muted" };
317
+ }
318
+ return { ok: true };
319
+ }
320
+ function isRateLimited(store, fromUserId, toUserId) {
321
+ const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
322
+ const recent = store.data().pages.filter((page) => page.fromUserId === fromUserId && page.toUserId === toUserId && Date.parse(page.createdAt) >= cutoff);
323
+ return recent.length >= RATE_LIMIT_MAX;
324
+ }
325
+ function pendingPages(store, userId) {
326
+ return store.data().pages.filter((page) => page.toUserId === userId && !page.deliveredAt);
327
+ }
328
+ function openEvents(res, listeners, userId, store) {
329
+ res.writeHead(200, {
330
+ "content-type": "text/event-stream",
331
+ "cache-control": "no-cache",
332
+ connection: "keep-alive"
333
+ });
334
+ const listener = { userId, res };
335
+ listeners.add(listener);
336
+ writeEvent(res, "ready", { ok: true, at: nowIso() });
337
+ for (const page of pendingPages(store, userId)) {
338
+ writeEvent(res, "page", signedEnvelope(store, page));
339
+ }
340
+ const keepAlive = setInterval(() => writeEvent(res, "heartbeat", { at: nowIso() }), 15000);
341
+ res.on("close", () => {
342
+ clearInterval(keepAlive);
343
+ listeners.delete(listener);
344
+ });
345
+ }
346
+ function pushPage(listeners, store, page) {
347
+ for (const listener of listeners) {
348
+ if (listener.userId === page.toUserId) {
349
+ writeEvent(listener.res, "page", signedEnvelope(store, page));
350
+ }
351
+ }
352
+ }
353
+ function signedEnvelope(store, page) {
354
+ const unsigned = {
355
+ page,
356
+ serverTime: nowIso(),
357
+ nonce: newId("nonce")
358
+ };
359
+ return {
360
+ ...unsigned,
361
+ signature: signText(store.data().serverKeyPair.privateKeyPem, stableJson(unsigned))
362
+ };
363
+ }
364
+ function writeEvent(res, event, data) {
365
+ res.write(`event: ${event}\n`);
366
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
367
+ }
368
+ function parseBody(bodyText) {
369
+ return bodyText ? JSON.parse(bodyText) : {};
370
+ }
371
+ async function readJson(req) {
372
+ return parseBody(await readText(req));
373
+ }
374
+ function readText(req) {
375
+ return new Promise((resolveRead, reject) => {
376
+ let body = "";
377
+ req.setEncoding("utf8");
378
+ req.on("data", (chunk) => {
379
+ body += chunk;
380
+ if (body.length > 32_000) {
381
+ reject(new Error("request body too large"));
382
+ req.destroy();
383
+ }
384
+ });
385
+ req.on("end", () => resolveRead(body));
386
+ req.on("error", reject);
387
+ });
388
+ }
389
+ function sendJson(res, body, status = 200) {
390
+ res.writeHead(status, { "content-type": "application/json" });
391
+ res.end(JSON.stringify(body, null, 2));
392
+ }
393
+ function setCors(req, res) {
394
+ res.setHeader("access-control-allow-origin", req.headers.origin || "*");
395
+ res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
396
+ res.setHeader("access-control-allow-headers", "content-type,x-ap-device-id,x-ap-timestamp,x-ap-signature");
397
+ }
398
+ function serveIndex(res) {
399
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
400
+ res.end(readFileSync(resolve(WEB_ROOT, "index.html"), "utf8"));
401
+ }
402
+ function serveStatic(res, pathname) {
403
+ const path = resolve(WEB_ROOT, pathname.replace(/^\/assets\//, ""));
404
+ if (!existsSync(path)) {
405
+ sendJson(res, { error: "asset not found" }, 404);
406
+ return;
407
+ }
408
+ const contentTypes = {
409
+ ".css": "text/css",
410
+ ".js": "application/javascript",
411
+ ".png": "image/png",
412
+ ".svg": "image/svg+xml"
413
+ };
414
+ res.writeHead(200, { "content-type": contentTypes[extname(path)] || "application/octet-stream" });
415
+ res.end(readFileSync(path));
416
+ }
417
+ function findWebRoot() {
418
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
419
+ for (const candidate of [
420
+ resolve(moduleDir, "..", "web"),
421
+ resolve(moduleDir, "..", "..", "web"),
422
+ resolve(process.cwd(), "web")
423
+ ]) {
424
+ if (existsSync(resolve(candidate, "index.html")))
425
+ return candidate;
426
+ }
427
+ return resolve(moduleDir, "..", "..", "web");
428
+ }
429
+ function cleanUsername(value) {
430
+ return String(value || "").toLowerCase().replace(/[^a-z0-9_.-]/g, "").slice(0, 32);
431
+ }
432
+ function cleanText(value, max) {
433
+ return String(value || "").trim().slice(0, max);
434
+ }
435
+ function normalizeUrgency(value) {
436
+ const urgency = String(value || "normal");
437
+ return ["gentle", "normal", "firm", "professionally-dramatic"].includes(urgency) ? urgency : "normal";
438
+ }
439
+ function randomCode() {
440
+ return Math.random().toString(36).slice(2, 8) + Math.random().toString(36).slice(2, 8);
441
+ }
@@ -0,0 +1,23 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { inboxPath } from "./local-config.js";
5
+ import { pageBox } from "./render.js";
6
+ export async function deliverPageLocally(page) {
7
+ const path = inboxPath();
8
+ mkdirSync(dirname(path), { recursive: true });
9
+ appendFileSync(path, `${JSON.stringify({ ...page, receivedAt: new Date().toISOString() })}\n`);
10
+ console.log(`\n${pageBox(page)}\n`);
11
+ await notify(page).catch(() => undefined);
12
+ }
13
+ async function notify(page) {
14
+ if (process.platform !== "darwin") {
15
+ return;
16
+ }
17
+ await new Promise((resolve, reject) => {
18
+ execFile("osascript", [
19
+ "-e",
20
+ `display notification ${JSON.stringify(page.message.slice(0, 120))} with title "Agent Pager" subtitle ${JSON.stringify(`From ${page.fromUsername}`)}`
21
+ ], (error) => (error ? reject(error) : resolve()));
22
+ });
23
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const codexSkill = `---
5
+ name: agent-pager
6
+ description: Use Agent Pager to contact trusted friends through their active coding agent sessions. Trigger when the user asks to page, ping, message, reach, contact, or get a friend's attention through their agent.
7
+ ---
8
+
9
+ # Agent Pager
10
+
11
+ Agent Pager lets this agent contact trusted friends' active coding agent sessions.
12
+
13
+ Use the Agent Pager MCP tools when the user wants to reach a person through their agent.
14
+
15
+ Rules:
16
+ - Page only approved friends.
17
+ - Keep pages concise and action-oriented.
18
+ - Include the requested action and enough context for the recipient.
19
+ - Do not fake urgency.
20
+ - Use "professionally-dramatic" only when the user is clearly joking.
21
+ - If the user asks who is reachable, call list_friends.
22
+ - If the user asks for their Page my agent link or share link, call get_share_link.
23
+ - If the user wants to add or request access to a person, call request_friend with a human-approved note.
24
+ - If page_friend fails because the recipient is not approved, explain that friendship is required and ask before calling request_friend.
25
+ - If the user asks about pending access requests, call list_friend_requests.
26
+ - If the user explicitly asks to approve, deny, or cancel a specific friend request, call approve_friend_request, deny_friend_request, or cancel_friend_request.
27
+ - If the user asks whether anyone paged them, call check_pages.
28
+ - If the user explicitly asks to remove, unfriend, or end access for a friend, call remove_friend.
29
+ - If the user asks to silence or pause a friend's pages, call mute_friend with a clear duration.
30
+ - If the user explicitly asks to unmute or resume a friend's pages, call unmute_friend.
31
+ - If the user explicitly asks to block or unblock someone, call block_friend or unblock_friend.
32
+ - If the user explicitly asks to report abuse or spam, call report_abuse with a short reason and page id if available.
33
+ `;
34
+ const claudeSkill = `---
35
+ description: Use Agent Pager to contact trusted friends through their active coding agent sessions. Trigger when the user asks to page, ping, message, reach, contact, or get a friend's attention through their agent.
36
+ ---
37
+
38
+ # Agent Pager
39
+
40
+ Agent Pager lets Claude contact trusted friends' active coding agent sessions.
41
+
42
+ Use the Agent Pager MCP tools when the user wants to reach a person through their agent.
43
+
44
+ Rules:
45
+ - Page only approved friends.
46
+ - Keep pages concise and action-oriented.
47
+ - Include the requested action and enough context for the recipient.
48
+ - Do not fake urgency.
49
+ - Use "professionally-dramatic" only when the user is clearly joking.
50
+ - If the user asks who is reachable, call list_friends.
51
+ - If the user asks for their Page my agent link or share link, call get_share_link.
52
+ - If the user wants to add or request access to a person, call request_friend with a human-approved note.
53
+ - If page_friend fails because the recipient is not approved, explain that friendship is required and ask before calling request_friend.
54
+ - If the user asks about pending access requests, call list_friend_requests.
55
+ - If the user explicitly asks to approve, deny, or cancel a specific friend request, call approve_friend_request, deny_friend_request, or cancel_friend_request.
56
+ - If the user asks whether anyone paged them, call check_pages.
57
+ - If the user explicitly asks to remove, unfriend, or end access for a friend, call remove_friend.
58
+ - If the user asks to silence or pause a friend's pages, call mute_friend with a clear duration.
59
+ - If the user explicitly asks to unmute or resume a friend's pages, call unmute_friend.
60
+ - If the user explicitly asks to block or unblock someone, call block_friend or unblock_friend.
61
+ - If the user explicitly asks to report abuse or spam, call report_abuse with a short reason and page id if available.
62
+ `;
63
+ export function setupCodex() {
64
+ const skillPath = join(homedir(), ".codex", "skills", "agent-pager", "SKILL.md");
65
+ mkdirSync(dirname(skillPath), { recursive: true });
66
+ writeFileSync(skillPath, codexSkill);
67
+ const configPath = join(homedir(), ".codex", "config.toml");
68
+ mkdirSync(dirname(configPath), { recursive: true });
69
+ const snippet = `[mcp_servers.agent_pager]
70
+ command = "agent-pager"
71
+ args = ["mcp"]
72
+ `;
73
+ const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
74
+ if (!current.includes("[mcp_servers.agent_pager]")) {
75
+ writeFileSync(configPath, `${current.trimEnd()}\n\n${snippet}`);
76
+ }
77
+ return `Installed Codex skill at ${skillPath}\nConfigured MCP in ${configPath}\nRestart Codex or start a new thread to load it.`;
78
+ }
79
+ export function setupClaude() {
80
+ const skillPath = join(homedir(), ".claude", "skills", "agent-pager", "SKILL.md");
81
+ mkdirSync(dirname(skillPath), { recursive: true });
82
+ writeFileSync(skillPath, claudeSkill);
83
+ return [
84
+ `Installed Claude skill at ${skillPath}`,
85
+ "Add the MCP server with:",
86
+ " claude mcp add agent-pager -- agent-pager mcp",
87
+ "For true push into Claude Code sessions, Agent Pager should later ship as a Claude channel plugin and be launched with:",
88
+ " claude --channels plugin:agent-pager"
89
+ ].join("\n");
90
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { createDeviceKeyPair, nowIso } from "./crypto.js";
4
+ export const DEFAULT_DATA_PATH = resolve(process.cwd(), "data", "agent-pager-db.json");
5
+ export class Store {
6
+ path;
7
+ db;
8
+ constructor(path = process.env.AGENT_PAGER_DB || DEFAULT_DATA_PATH) {
9
+ this.path = path;
10
+ this.db = this.load();
11
+ }
12
+ data() {
13
+ return this.db;
14
+ }
15
+ save() {
16
+ mkdirSync(dirname(this.path), { recursive: true });
17
+ writeFileSync(this.path, `${JSON.stringify(this.db, null, 2)}\n`);
18
+ }
19
+ audit(event) {
20
+ this.db.auditEvents.unshift({
21
+ id: `audit_${Date.now()}_${Math.random().toString(36).slice(2)}`,
22
+ at: nowIso(),
23
+ ...event
24
+ });
25
+ this.db.auditEvents = this.db.auditEvents.slice(0, 500);
26
+ this.save();
27
+ }
28
+ load() {
29
+ if (existsSync(this.path)) {
30
+ return JSON.parse(readFileSync(this.path, "utf8"));
31
+ }
32
+ const serverKeyPair = createDeviceKeyPair();
33
+ const createdAt = nowIso();
34
+ return {
35
+ serverKeyPair,
36
+ users: [],
37
+ devices: [],
38
+ friendships: [],
39
+ invites: [],
40
+ friendRequests: [],
41
+ pages: [],
42
+ auditEvents: [
43
+ {
44
+ id: "audit_bootstrap",
45
+ at: createdAt,
46
+ action: "server.bootstrap",
47
+ meta: { mode: "local-dev" }
48
+ }
49
+ ]
50
+ };
51
+ }
52
+ }
@@ -0,0 +1,32 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ export function loadSupabaseRuntimeConfig(env = process.env) {
3
+ const url = env.NEXT_PUBLIC_SUPABASE_URL || env.SUPABASE_URL || "";
4
+ const anonKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY || env.SUPABASE_ANON_KEY || "";
5
+ if (!url || !anonKey) {
6
+ throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.");
7
+ }
8
+ return {
9
+ url,
10
+ anonKey,
11
+ serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY
12
+ };
13
+ }
14
+ export function createSupabaseUserClient(config = loadSupabaseRuntimeConfig()) {
15
+ return createClient(config.url, config.anonKey, {
16
+ auth: {
17
+ persistSession: false,
18
+ autoRefreshToken: false
19
+ }
20
+ });
21
+ }
22
+ export function createSupabaseServiceClient(config = loadSupabaseRuntimeConfig()) {
23
+ if (!config.serviceRoleKey) {
24
+ throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY.");
25
+ }
26
+ return createClient(config.url, config.serviceRoleKey, {
27
+ auth: {
28
+ persistSession: false,
29
+ autoRefreshToken: false
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,13 @@
1
+ export const Constants = {
2
+ graphql_public: {
3
+ Enums: {},
4
+ },
5
+ public: {
6
+ Enums: {
7
+ delivery_status: ["pending", "delivered", "acked", "failed"],
8
+ friend_request_status: ["pending", "approved", "denied", "canceled"],
9
+ page_urgency: ["gentle", "normal", "firm", "professionally-dramatic"],
10
+ user_status: ["online", "busy", "muted", "offline"],
11
+ },
12
+ },
13
+ };
@@ -0,0 +1 @@
1
+ export {};