@gamalan/pi-gateway 1.0.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.
Files changed (75) hide show
  1. package/README.md +366 -0
  2. package/config/config.default.json +51 -0
  3. package/dist/adapters/base.d.ts +85 -0
  4. package/dist/adapters/base.js +34 -0
  5. package/dist/adapters/base.js.map +1 -0
  6. package/dist/adapters/discord.d.ts +53 -0
  7. package/dist/adapters/discord.js +224 -0
  8. package/dist/adapters/discord.js.map +1 -0
  9. package/dist/adapters/index.d.ts +13 -0
  10. package/dist/adapters/index.js +8 -0
  11. package/dist/adapters/index.js.map +1 -0
  12. package/dist/adapters/slack.d.ts +49 -0
  13. package/dist/adapters/slack.js +231 -0
  14. package/dist/adapters/slack.js.map +1 -0
  15. package/dist/adapters/telegram.d.ts +64 -0
  16. package/dist/adapters/telegram.js +274 -0
  17. package/dist/adapters/telegram.js.map +1 -0
  18. package/dist/adapters/twitch.d.ts +75 -0
  19. package/dist/adapters/twitch.js +222 -0
  20. package/dist/adapters/twitch.js.map +1 -0
  21. package/dist/adapters/websocket.d.ts +30 -0
  22. package/dist/adapters/websocket.js +132 -0
  23. package/dist/adapters/websocket.js.map +1 -0
  24. package/dist/adapters/whatsapp.d.ts +49 -0
  25. package/dist/adapters/whatsapp.js +251 -0
  26. package/dist/adapters/whatsapp.js.map +1 -0
  27. package/dist/background/index.d.ts +1 -0
  28. package/dist/background/index.js +2 -0
  29. package/dist/background/index.js.map +1 -0
  30. package/dist/background/manager.d.ts +70 -0
  31. package/dist/background/manager.js +291 -0
  32. package/dist/background/manager.js.map +1 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +1275 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/logger.d.ts +12 -0
  37. package/dist/logger.js +39 -0
  38. package/dist/logger.js.map +1 -0
  39. package/dist/paths.d.ts +14 -0
  40. package/dist/paths.js +32 -0
  41. package/dist/paths.js.map +1 -0
  42. package/dist/security/auth.d.ts +91 -0
  43. package/dist/security/auth.js +316 -0
  44. package/dist/security/auth.js.map +1 -0
  45. package/dist/security/index.d.ts +1 -0
  46. package/dist/security/index.js +2 -0
  47. package/dist/security/index.js.map +1 -0
  48. package/dist/security/tool-policy.d.ts +66 -0
  49. package/dist/security/tool-policy.js +467 -0
  50. package/dist/security/tool-policy.js.map +1 -0
  51. package/dist/sessions/index.d.ts +1 -0
  52. package/dist/sessions/index.js +2 -0
  53. package/dist/sessions/index.js.map +1 -0
  54. package/dist/sessions/store.d.ts +64 -0
  55. package/dist/sessions/store.js +247 -0
  56. package/dist/sessions/store.js.map +1 -0
  57. package/package.json +57 -0
  58. package/src/adapters/base.ts +124 -0
  59. package/src/adapters/discord.ts +270 -0
  60. package/src/adapters/index.ts +13 -0
  61. package/src/adapters/slack.ts +313 -0
  62. package/src/adapters/telegram.ts +394 -0
  63. package/src/adapters/twitch.ts +316 -0
  64. package/src/adapters/websocket.ts +158 -0
  65. package/src/adapters/whatsapp.ts +296 -0
  66. package/src/background/index.ts +1 -0
  67. package/src/background/manager.ts +395 -0
  68. package/src/index.ts +1665 -0
  69. package/src/logger.ts +43 -0
  70. package/src/paths.ts +40 -0
  71. package/src/security/auth.ts +458 -0
  72. package/src/security/index.ts +1 -0
  73. package/src/security/tool-policy.ts +568 -0
  74. package/src/sessions/index.ts +1 -0
  75. package/src/sessions/store.ts +360 -0
package/dist/index.js ADDED
@@ -0,0 +1,1275 @@
1
+ /**
2
+ * pi-gateway - Hermes-style Messaging Gateway
3
+ *
4
+ * Architecture:
5
+ * - Single background process
6
+ * - Platform adapters (Discord, Telegram, etc.)
7
+ * - Per-chat session management
8
+ * - Background task support
9
+ * - Security (allowlists, pairing)
10
+ *
11
+ * Usage:
12
+ * /gateway start [port] - Start the gateway
13
+ * /gateway stop - Stop the gateway
14
+ * /gateway status - Show status
15
+ * /gateway pair <code> - Approve pairing code
16
+ */
17
+ import { join } from "node:path";
18
+ import { existsSync, readFileSync, copyFileSync, mkdirSync } from "node:fs";
19
+ import { createServer, } from "node:http";
20
+ import { WebSocketServer, WebSocket } from "ws";
21
+ import { randomBytes } from "node:crypto";
22
+ import { spawn } from "node:child_process";
23
+ import { Type } from "@sinclair/typebox";
24
+ import { initSessionStore, getOrCreateSession, listSessions, touchSession, } from "./sessions/store.js";
25
+ import { logger } from "./logger.js";
26
+ import { GATEWAY_CONFIG_DIR, GATEWAY_CONFIG_FILE, getPackageRoot, } from "./paths.js";
27
+ import { initSecurityStore, isUserAllowed, approvePairingCode, generatePairingCode, listPendingPairingCodes, addToAllowlist, listAllowlistedUsers, revokeUserAccess, addAdmin, removeAdmin, listAdmins, } from "./security/auth.js";
28
+ import { setToolPolicy, removeToolPolicy, listToolPolicies, resetToolPolicies, getEffectivePolicySummary, buildPolicyGuard, } from "./security/tool-policy.js";
29
+ import { initBackgroundTasks, startBackgroundTask, getPendingResultsForSession, markTaskDelivered, listTasks, } from "./background/manager.js";
30
+ import { DiscordAdapter } from "./adapters/discord.js";
31
+ import { TwitchAdapter } from "./adapters/twitch.js";
32
+ import { TelegramAdapter } from "./adapters/telegram.js";
33
+ import { SlackAdapter } from "./adapters/slack.js";
34
+ import { WhatsAppAdapter } from "./adapters/whatsapp.js";
35
+ const DEFAULT_CONFIG = {
36
+ port: 3847,
37
+ host: "localhost",
38
+ tokens: [],
39
+ corsOrigins: ["*"],
40
+ enableWebSocket: true,
41
+ enableHttp: true,
42
+ security: {
43
+ allowAll: true,
44
+ requirePairing: false,
45
+ allowedUids: {},
46
+ adminUids: {},
47
+ rateLimit: { maxRequests: 60, windowMs: 60000 },
48
+ },
49
+ sessions: {
50
+ resetPolicy: "idle",
51
+ dailyHour: 4,
52
+ idleMinutes: 1440,
53
+ },
54
+ platforms: {},
55
+ };
56
+ let config;
57
+ let state;
58
+ let server = null;
59
+ let wss = null;
60
+ let globalCtx = null;
61
+ let rpcProcess = null;
62
+ let cronInterval = null;
63
+ const pendingRequests = [];
64
+ const pendingCompletions = [];
65
+ // Load/save config
66
+ function loadConfig() {
67
+ try {
68
+ if (existsSync(GATEWAY_CONFIG_FILE)) {
69
+ return {
70
+ ...DEFAULT_CONFIG,
71
+ ...JSON.parse(readFileSync(GATEWAY_CONFIG_FILE, "utf-8")),
72
+ };
73
+ }
74
+ // No config file yet — seed from default template
75
+ const packageRoot = getPackageRoot(import.meta.url);
76
+ const defaultConfigPath = join(packageRoot, "config", "config.default.json");
77
+ if (existsSync(defaultConfigPath)) {
78
+ if (!existsSync(GATEWAY_CONFIG_DIR)) {
79
+ mkdirSync(GATEWAY_CONFIG_DIR, { recursive: true });
80
+ }
81
+ copyFileSync(defaultConfigPath, GATEWAY_CONFIG_FILE);
82
+ logger.info("[gateway] Seeded default config at", GATEWAY_CONFIG_FILE);
83
+ return {
84
+ ...DEFAULT_CONFIG,
85
+ ...JSON.parse(readFileSync(GATEWAY_CONFIG_FILE, "utf-8")),
86
+ };
87
+ }
88
+ }
89
+ catch (err) {
90
+ logger.error("[gateway] Failed to parse config file — using defaults. Error:", err);
91
+ }
92
+ return { ...DEFAULT_CONFIG };
93
+ }
94
+ // Token auth
95
+ function verifyToken(token) {
96
+ if (config.tokens.length === 0)
97
+ return true;
98
+ return config.tokens.includes(token);
99
+ }
100
+ function authenticate(req) {
101
+ const auth = req.headers.authorization;
102
+ if (!auth)
103
+ return verifyToken("");
104
+ if (auth.startsWith("Bearer "))
105
+ return verifyToken(auth.slice(7));
106
+ return false;
107
+ }
108
+ // WebSocket helpers
109
+ function sendWs(ws, msg) {
110
+ if (ws.readyState === WebSocket.OPEN) {
111
+ ws.send(JSON.stringify(msg));
112
+ }
113
+ }
114
+ function broadcastClients(event, data) {
115
+ for (const ws of state.clients.values()) {
116
+ sendWs(ws, { type: event, data });
117
+ }
118
+ }
119
+ // RPC to pi agent
120
+ function createRpcProcess() {
121
+ const proc = spawn("pi", ["--mode", "rpc"], {
122
+ stdio: ["pipe", "pipe", "pipe"],
123
+ env: {
124
+ ...process.env,
125
+ OLLAMA_HOST: process.env.OLLAMA_HOST || "localhost:11434",
126
+ },
127
+ });
128
+ proc.stdout?.on("data", (data) => {
129
+ const lines = data.toString().split("\n").filter(Boolean);
130
+ for (const line of lines) {
131
+ try {
132
+ const msg = JSON.parse(line);
133
+ if (msg.id) {
134
+ const idx = pendingRequests.findIndex((r) => r.id === msg.id);
135
+ if (idx !== -1) {
136
+ const req = pendingRequests.splice(idx, 1)[0];
137
+ req.resolve(msg);
138
+ }
139
+ }
140
+ // agent_end carries the full response — resolve pending completions
141
+ if (msg.type === "agent_end") {
142
+ const text = extractAgentEndText(msg);
143
+ logger.info(`[gateway] agent_end received, text length: ${text.length}`);
144
+ const completion = pendingCompletions.shift();
145
+ if (completion) {
146
+ clearTimeout(completion.timer);
147
+ completion.resolve(text);
148
+ }
149
+ }
150
+ // Broadcast events
151
+ if (msg.type === "response") {
152
+ broadcastClients("response", msg);
153
+ }
154
+ else {
155
+ broadcastClients("event", msg);
156
+ }
157
+ }
158
+ catch {
159
+ /* not JSON */
160
+ }
161
+ }
162
+ });
163
+ proc.stderr?.on("data", (data) => {
164
+ logger.info("[gateway] pi stderr:", data.toString().trim());
165
+ });
166
+ proc.on("exit", (code) => {
167
+ logger.info("[gateway] pi process exited");
168
+ // Reject any pending completions so they don't hang forever
169
+ while (pendingCompletions.length > 0) {
170
+ const completion = pendingCompletions.shift();
171
+ clearTimeout(completion.timer);
172
+ completion.reject(new Error(`pi process exited with code ${code}`));
173
+ }
174
+ rpcProcess = null;
175
+ broadcastClients("agent_disconnected", { code });
176
+ });
177
+ return proc;
178
+ }
179
+ async function sendRpc(command, data = {}) {
180
+ if (!rpcProcess)
181
+ throw new Error("pi agent not running");
182
+ const id = randomBytes(8).toString("hex");
183
+ const payload = { id, type: command, ...data };
184
+ return new Promise((resolve, reject) => {
185
+ pendingRequests.push({ id, resolve, reject });
186
+ try {
187
+ rpcProcess.stdin.write(JSON.stringify(payload) + "\n");
188
+ }
189
+ catch (err) {
190
+ const idx = pendingRequests.findIndex((r) => r.id === id);
191
+ if (idx !== -1)
192
+ pendingRequests.splice(idx, 1);
193
+ reject(err);
194
+ }
195
+ setTimeout(() => {
196
+ const idx = pendingRequests.findIndex((r) => r.id === id);
197
+ if (idx !== -1) {
198
+ pendingRequests.splice(idx, 1);
199
+ reject(new Error("Request timeout"));
200
+ }
201
+ }, 30000);
202
+ });
203
+ }
204
+ // Extract assistant response text from agent_end.messages
205
+ function extractAgentEndText(agentEndMsg) {
206
+ const messages = agentEndMsg.messages;
207
+ if (!messages)
208
+ return "";
209
+ const parts = [];
210
+ for (const msg of messages) {
211
+ if (msg.role === "assistant") {
212
+ const content = msg.content;
213
+ if (Array.isArray(content)) {
214
+ for (const block of content) {
215
+ if (block.type === "text" && typeof block.text === "string") {
216
+ parts.push(block.text);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ return parts.join("\n");
223
+ }
224
+ // Send a prompt to pi and wait for agent_end to get the full response text.
225
+ // Unlike sendRpc (which resolves with the ACK), this resolves with the
226
+ // actual assistant response text after the agent finishes processing.
227
+ async function sendPromptRpc(message) {
228
+ if (!rpcProcess)
229
+ throw new Error("pi agent not running");
230
+ // Send the prompt and wait for the ACK (so we know the prompt was accepted)
231
+ const ackResponse = await sendRpc("prompt", { message });
232
+ const ack = ackResponse;
233
+ if (!ack.success) {
234
+ throw new Error(`Prompt rejected: ${JSON.stringify(ackResponse)}`);
235
+ }
236
+ logger.info("[gateway] Prompt ACK received, waiting for agent_end...");
237
+ // Wait for agent_end to deliver the full response
238
+ return new Promise((resolve, reject) => {
239
+ const timer = setTimeout(() => {
240
+ const idx = pendingCompletions.findIndex((c) => c.timer === timer);
241
+ if (idx !== -1)
242
+ pendingCompletions.splice(idx, 1);
243
+ reject(new Error("Prompt completion timeout — no agent_end received within 5 minutes"));
244
+ }, 300000); // 5 minutes
245
+ pendingCompletions.push({ resolve, reject, timer });
246
+ });
247
+ }
248
+ // Extract response text from pi RPC response (kept for backward compat with old sendRpc)
249
+ // pi responds with JSON lines on stdout;
250
+ // the response shape varies but typically contains `text` or `content`
251
+ function extractRpcResponseText(response) {
252
+ if (typeof response === "string")
253
+ return response;
254
+ const r = response;
255
+ if (r.text && typeof r.text === "string")
256
+ return r.text;
257
+ if (r.content) {
258
+ // OpenAI-style content array: [{ type: "text", text: "..." }]
259
+ if (Array.isArray(r.content)) {
260
+ return r.content
261
+ .filter((c) => c.type === "text" && typeof c.text === "string")
262
+ .map((c) => c.text)
263
+ .join("\n");
264
+ }
265
+ if (typeof r.content === "string")
266
+ return r.content;
267
+ }
268
+ // Fallback — the response might be something we don't recognise
269
+ return "";
270
+ }
271
+ // Platform adapter callbacks
272
+ const adapterCallbacks = {
273
+ onMessage: async (message) => {
274
+ // Get or create session for this chat
275
+ const session = getOrCreateSession(message.platform, message.channelId, message.userId, {
276
+ resetPolicy: config.sessions.resetPolicy,
277
+ dailyHour: config.sessions.dailyHour,
278
+ idleMinutes: config.sessions.idleMinutes,
279
+ });
280
+ // Check allowlist
281
+ if (!isUserAllowed(message.platform, message.userId)) {
282
+ logger.info(`[gateway] User ${message.userId} not in allowlist`);
283
+ // Could send a DM here about pairing flow
284
+ return;
285
+ }
286
+ // Store session reference
287
+ state.sessions.set(`${message.platform}:${message.channelId}`, session);
288
+ // Send to pi agent with tool policy guard
289
+ if (rpcProcess) {
290
+ const guard = buildPolicyGuard(message.platform, message.userId);
291
+ try {
292
+ logger.info(`[gateway] Sending prompt from ${message.platform}/${message.userId} (session: ${session.id.slice(0, 12)}...)`);
293
+ // Use sendPromptRpc which waits for agent_end (the real response),
294
+ // NOT the ACK which has no content
295
+ const responseText = await sendPromptRpc(`${guard}\n\n${message.content}`);
296
+ logger.info(`[gateway] Response received, length: ${responseText.length}, sending back to ${message.platform}/${message.channelId}`);
297
+ if (responseText) {
298
+ const adapter = state.adapters.get(message.platform);
299
+ if (adapter) {
300
+ await adapter.sendMessage(message.channelId, responseText);
301
+ logger.info("[gateway] Response sent to platform successfully");
302
+ }
303
+ }
304
+ else {
305
+ logger.warn("[gateway] Response text was empty — nothing to send");
306
+ const adapter = state.adapters.get(message.platform);
307
+ if (adapter) {
308
+ await adapter.sendMessage(message.channelId, "I processed your message but had no text response. Please try again.");
309
+ }
310
+ }
311
+ }
312
+ catch (err) {
313
+ logger.error("[gateway] RPC error processing message:", err);
314
+ const adapter = state.adapters.get(message.platform);
315
+ if (adapter) {
316
+ try {
317
+ await adapter.sendMessage(message.channelId, "Sorry, I encountered an error processing your message. Please try again.");
318
+ }
319
+ catch (sendErr) {
320
+ logger.error("[gateway] Failed to send error message:", sendErr);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ else {
326
+ logger.warn("[gateway] pi agent not running — cannot process message");
327
+ }
328
+ },
329
+ onDisconnect: () => {
330
+ logger.info("[gateway] Platform adapter disconnected");
331
+ updateStatus();
332
+ },
333
+ };
334
+ // Initialize platform adapters
335
+ async function initializeAdapters() {
336
+ // Discord
337
+ if (config.platforms.discord?.enabled && config.platforms.discord.botToken) {
338
+ try {
339
+ const discord = new DiscordAdapter({
340
+ enabled: true,
341
+ platform: "discord",
342
+ botToken: config.platforms.discord.botToken,
343
+ guildId: config.platforms.discord.guildId,
344
+ });
345
+ await discord.initialize();
346
+ await discord.start(adapterCallbacks);
347
+ state.adapters.set("discord", discord);
348
+ logger.info("[gateway] Discord adapter started");
349
+ }
350
+ catch (err) {
351
+ logger.error("[gateway] Failed to start Discord adapter:", err);
352
+ }
353
+ }
354
+ // Twitch
355
+ if (config.platforms.twitch?.enabled &&
356
+ config.platforms.twitch.clientId &&
357
+ config.platforms.twitch.clientSecret) {
358
+ try {
359
+ const twitch = new TwitchAdapter({
360
+ enabled: true,
361
+ platform: "twitch",
362
+ clientId: config.platforms.twitch.clientId,
363
+ clientSecret: config.platforms.twitch.clientSecret,
364
+ channels: config.platforms.twitch.channels,
365
+ });
366
+ await twitch.initialize();
367
+ await twitch.start(adapterCallbacks);
368
+ state.adapters.set("twitch", twitch);
369
+ logger.info("[gateway] Twitch adapter started");
370
+ }
371
+ catch (err) {
372
+ logger.error("[gateway] Failed to start Twitch adapter:", err);
373
+ }
374
+ }
375
+ // Telegram
376
+ if (config.platforms.telegram?.enabled && config.platforms.telegram.token) {
377
+ try {
378
+ const telegram = new TelegramAdapter({
379
+ enabled: true,
380
+ platform: "telegram",
381
+ token: config.platforms.telegram.token,
382
+ webhookUrl: config.platforms.telegram.webhookUrl,
383
+ });
384
+ await telegram.initialize();
385
+ await telegram.start(adapterCallbacks);
386
+ state.adapters.set("telegram", telegram);
387
+ logger.info("[gateway] Telegram adapter started");
388
+ }
389
+ catch (err) {
390
+ logger.error("[gateway] Failed to start Telegram adapter:", err);
391
+ }
392
+ }
393
+ // Slack
394
+ if (config.platforms.slack?.enabled &&
395
+ (config.platforms.slack.webhookUrl || config.platforms.slack.botToken)) {
396
+ try {
397
+ const slack = new SlackAdapter({
398
+ enabled: true,
399
+ platform: "slack",
400
+ webhookUrl: config.platforms.slack.webhookUrl,
401
+ botToken: config.platforms.slack.botToken,
402
+ });
403
+ await slack.initialize();
404
+ await slack.start(adapterCallbacks);
405
+ state.adapters.set("slack", slack);
406
+ logger.info("[gateway] Slack adapter started");
407
+ }
408
+ catch (err) {
409
+ logger.error("[gateway] Failed to start Slack adapter:", err);
410
+ }
411
+ }
412
+ // WhatsApp
413
+ if (config.platforms.whatsapp?.enabled) {
414
+ try {
415
+ const whatsapp = new WhatsAppAdapter({
416
+ enabled: true,
417
+ platform: "whatsapp",
418
+ sessionPath: config.platforms.whatsapp.sessionPath,
419
+ printQr: config.platforms.whatsapp.printQr,
420
+ });
421
+ await whatsapp.initialize();
422
+ await whatsapp.start(adapterCallbacks);
423
+ state.adapters.set("whatsapp", whatsapp);
424
+ logger.info("[gateway] WhatsApp adapter started");
425
+ }
426
+ catch (err) {
427
+ logger.error("[gateway] Failed to start WhatsApp adapter:", err);
428
+ }
429
+ }
430
+ }
431
+ // Cron job for background tasks and session cleanup
432
+ function startCron() {
433
+ cronInterval = setInterval(async () => {
434
+ // Check for pending background results
435
+ for (const session of state.sessions.values()) {
436
+ const pending = getPendingResultsForSession(session.id);
437
+ for (const task of pending) {
438
+ // Deliver result to user via their platform
439
+ const adapter = state.adapters.get(session.platform);
440
+ if (adapter) {
441
+ const resultText = task.status === "completed"
442
+ ? `✅ Background task completed:\n\`\`\`\n${JSON.stringify(task.result, null, 2)}\n\`\`\``
443
+ : `❌ Background task failed:\n\`\`\`\n${task.error}\n\`\`\``;
444
+ await adapter.sendMessage(session.channelId, resultText);
445
+ markTaskDelivered(task.id);
446
+ }
447
+ }
448
+ }
449
+ // Touch active sessions
450
+ for (const session of state.sessions.values()) {
451
+ touchSession(session.id);
452
+ }
453
+ }, 60000); // Every 60 seconds (Hermes-style)
454
+ }
455
+ function stopCron() {
456
+ if (cronInterval) {
457
+ clearInterval(cronInterval);
458
+ cronInterval = null;
459
+ }
460
+ }
461
+ // HTTP handlers
462
+ async function handleHttpRequest(req, res) {
463
+ res.setHeader("Access-Control-Allow-Origin", config.corsOrigins.join(",") || "*");
464
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
465
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
466
+ if (req.method === "OPTIONS") {
467
+ res.writeHead(204);
468
+ res.end();
469
+ return;
470
+ }
471
+ // ── Telegram webhook (unauthenticated — called by Telegram) ──
472
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
473
+ if (url.pathname === "/webhook/telegram" && req.method === "POST") {
474
+ const chunks = [];
475
+ req.on("data", (chunk) => chunks.push(chunk));
476
+ req.on("end", async () => {
477
+ try {
478
+ const body = JSON.parse(Buffer.concat(chunks).toString());
479
+ const telegram = state.adapters.get("telegram");
480
+ if (telegram?.handleWebhookUpdate) {
481
+ await telegram.handleWebhookUpdate(body);
482
+ res.writeHead(200);
483
+ res.end("ok");
484
+ }
485
+ else {
486
+ res.writeHead(503);
487
+ res.end("Telegram adapter not running");
488
+ }
489
+ }
490
+ catch {
491
+ res.writeHead(400);
492
+ res.end("Invalid request");
493
+ }
494
+ });
495
+ return;
496
+ }
497
+ if (!authenticate(req)) {
498
+ res.writeHead(401);
499
+ res.end(JSON.stringify({ error: "Unauthorized" }));
500
+ return;
501
+ }
502
+ // API endpoints
503
+ if (url.pathname === "/api/status" && req.method === "GET") {
504
+ res.writeHead(200, { "Content-Type": "application/json" });
505
+ res.end(JSON.stringify({
506
+ running: state.running,
507
+ adapters: Array.from(state.adapters.keys()),
508
+ clients: state.clients.size,
509
+ sessions: state.sessions.size,
510
+ agent: rpcProcess !== null,
511
+ }));
512
+ return;
513
+ }
514
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
515
+ res.writeHead(200, { "Content-Type": "application/json" });
516
+ res.end(JSON.stringify(listSessions()));
517
+ return;
518
+ }
519
+ if (url.pathname === "/api/background" && req.method === "GET") {
520
+ res.writeHead(200, { "Content-Type": "application/json" });
521
+ res.end(JSON.stringify(listTasks()));
522
+ return;
523
+ }
524
+ if (url.pathname === "/api/allowlist" && req.method === "GET") {
525
+ res.writeHead(200, { "Content-Type": "application/json" });
526
+ res.end(JSON.stringify(listAllowlistedUsers()));
527
+ return;
528
+ }
529
+ if (url.pathname === "/api/pairing" && req.method === "GET") {
530
+ res.writeHead(200, { "Content-Type": "application/json" });
531
+ res.end(JSON.stringify(listPendingPairingCodes()));
532
+ return;
533
+ }
534
+ res.writeHead(404);
535
+ res.end(JSON.stringify({ error: "Not found" }));
536
+ }
537
+ // WebSocket handler
538
+ function handleWebSocket(ws, req) {
539
+ if (!authenticate(req)) {
540
+ ws.close(1008, "Unauthorized");
541
+ return;
542
+ }
543
+ const clientId = randomBytes(8).toString("hex");
544
+ state.clients.set(clientId, ws);
545
+ logger.info(`[gateway] WebSocket client connected: ${clientId}`);
546
+ sendWs(ws, { type: "connected", data: { clientId } });
547
+ ws.on("message", async (data) => {
548
+ try {
549
+ const msg = JSON.parse(data.toString());
550
+ switch (msg.type) {
551
+ case "prompt": {
552
+ const result = await sendRpc("prompt", {
553
+ message: msg.data?.message || "",
554
+ });
555
+ sendWs(ws, { type: "response", id: msg.id, data: result });
556
+ break;
557
+ }
558
+ case "background": {
559
+ const task = startBackgroundTask(msg.data?.sessionId || "default", msg.data?.command || "");
560
+ sendWs(ws, { type: "background_started", data: task });
561
+ break;
562
+ }
563
+ case "ping": {
564
+ sendWs(ws, { type: "pong", data: { time: Date.now() } });
565
+ break;
566
+ }
567
+ }
568
+ }
569
+ catch (err) {
570
+ sendWs(ws, { type: "error", data: { error: String(err) } });
571
+ }
572
+ });
573
+ ws.on("close", () => {
574
+ state.clients.delete(clientId);
575
+ logger.info(`[gateway] WebSocket client disconnected: ${clientId}`);
576
+ });
577
+ }
578
+ // Status update
579
+ function updateStatus() {
580
+ if (!globalCtx)
581
+ return;
582
+ const adapterCount = state.adapters.size;
583
+ const statusText = state.running
584
+ ? adapterCount > 0
585
+ ? `🟢 Gateway (${adapterCount} platform${adapterCount !== 1 ? "s" : ""})`
586
+ : `🟡 Gateway (waiting)`
587
+ : "🔴 Gateway";
588
+ globalCtx.ui.setStatus("gateway", statusText);
589
+ }
590
+ export default function (pi) {
591
+ config = loadConfig();
592
+ state = {
593
+ running: false,
594
+ adapters: new Map(),
595
+ clients: new Map(),
596
+ sessions: new Map(),
597
+ };
598
+ // Initialize stores
599
+ initSessionStore();
600
+ initSecurityStore();
601
+ initBackgroundTasks();
602
+ // Register commands
603
+ pi.registerCommand("gateway", {
604
+ description: "Manage Hermes-style messaging gateway",
605
+ getArgumentCompletions: (prefix) => {
606
+ const cmds = [
607
+ "start",
608
+ "stop",
609
+ "status",
610
+ "restart",
611
+ "pair",
612
+ "allow",
613
+ "revoke",
614
+ "admin",
615
+ "sessions",
616
+ "tasks",
617
+ "config",
618
+ "tool-policy",
619
+ ];
620
+ return cmds
621
+ .filter((c) => c.startsWith(prefix))
622
+ .map((c) => ({ value: c, label: c }));
623
+ },
624
+ handler: async (args, ctx) => {
625
+ const parts = args.split(/\s+/).filter(Boolean);
626
+ const subcmd = parts[0]?.toLowerCase();
627
+ switch (subcmd) {
628
+ case "start": {
629
+ if (state.running) {
630
+ ctx.ui.notify("Gateway already running", "info");
631
+ return;
632
+ }
633
+ // Reload config fresh on every start so users can edit
634
+ // ~/.pi/gateway/config.json without restarting pi
635
+ config = loadConfig();
636
+ const port = parseInt(parts[1]) || config.port;
637
+ // Start HTTP server
638
+ server = createServer(handleHttpRequest);
639
+ if (config.enableWebSocket) {
640
+ wss = new WebSocketServer({ server });
641
+ wss.on("connection", handleWebSocket);
642
+ }
643
+ server.listen(port, config.host, () => {
644
+ logger.info(`[gateway] HTTP server started on ${config.host}:${port}`);
645
+ });
646
+ // Start pi agent
647
+ rpcProcess = createRpcProcess();
648
+ // Initialize platform adapters
649
+ await initializeAdapters();
650
+ // Start cron
651
+ startCron();
652
+ state.running = true;
653
+ updateStatus();
654
+ ctx.ui.notify(`✅ Gateway started on http://${config.host}:${port}\n\n` +
655
+ `Platforms: ${state.adapters.size > 0 ? Array.from(state.adapters.keys()).join(", ") : "none"}\n` +
656
+ `Sessions: Idle reset every ${config.sessions.idleMinutes} min`, "info");
657
+ return;
658
+ }
659
+ case "stop": {
660
+ if (!state.running) {
661
+ ctx.ui.notify("Gateway not running", "info");
662
+ return;
663
+ }
664
+ // Stop adapters
665
+ for (const adapter of state.adapters.values()) {
666
+ await adapter.stop();
667
+ }
668
+ state.adapters.clear();
669
+ // Stop cron
670
+ stopCron();
671
+ // Close WebSocket clients
672
+ for (const ws of state.clients.values()) {
673
+ ws.close(1000, "Server shutting down");
674
+ }
675
+ state.clients.clear();
676
+ // Stop HTTP server
677
+ server?.close();
678
+ server = null;
679
+ wss = null;
680
+ // Kill pi process
681
+ if (rpcProcess) {
682
+ rpcProcess.kill();
683
+ rpcProcess = null;
684
+ }
685
+ state.running = false;
686
+ updateStatus();
687
+ ctx.ui.notify("Gateway stopped", "info");
688
+ return;
689
+ }
690
+ case "restart": {
691
+ if (state.running) {
692
+ // Stop
693
+ for (const adapter of state.adapters.values()) {
694
+ await adapter.stop();
695
+ }
696
+ state.adapters.clear();
697
+ stopCron();
698
+ for (const ws of state.clients.values()) {
699
+ ws.close(1000, "Server restart");
700
+ }
701
+ state.clients.clear();
702
+ server?.close();
703
+ server = null;
704
+ wss = null;
705
+ if (rpcProcess) {
706
+ rpcProcess.kill();
707
+ rpcProcess = null;
708
+ }
709
+ state.running = false;
710
+ }
711
+ // Reload config and start
712
+ config = loadConfig();
713
+ const port = parseInt(parts[1]) || config.port;
714
+ server = createServer(handleHttpRequest);
715
+ if (config.enableWebSocket) {
716
+ wss = new WebSocketServer({ server });
717
+ wss.on("connection", handleWebSocket);
718
+ }
719
+ server.listen(port, config.host, () => {
720
+ logger.info(`[gateway] HTTP server started on ${config.host}:${port}`);
721
+ });
722
+ rpcProcess = createRpcProcess();
723
+ await initializeAdapters();
724
+ startCron();
725
+ state.running = true;
726
+ updateStatus();
727
+ ctx.ui.notify(`✅ Gateway restarted on http://${config.host}:${port}\n\n` +
728
+ `Platforms: ${state.adapters.size > 0 ? Array.from(state.adapters.keys()).join(", ") : "none"}\n` +
729
+ `Sessions: Idle reset every ${config.sessions.idleMinutes} min`, "info");
730
+ return;
731
+ }
732
+ case "status": {
733
+ const lines = [];
734
+ lines.push(`Status: ${state.running ? "🟢 Running" : "🔴 Stopped"}`);
735
+ lines.push(`Port: ${config.port}`);
736
+ lines.push(`Adapters: ${state.adapters.size}`);
737
+ lines.push(`Clients: ${state.clients.size}`);
738
+ lines.push(`Sessions: ${state.sessions.size}`);
739
+ lines.push(`Agent: ${rpcProcess ? "✅ Connected" : "❌ Disconnected"}`);
740
+ lines.push("");
741
+ lines.push(`Session Reset: ${config.sessions.resetPolicy}`);
742
+ lines.push(` - Daily at ${config.sessions.dailyHour}:00`);
743
+ lines.push(` - Idle after ${config.sessions.idleMinutes} min`);
744
+ lines.push("");
745
+ const adminCount = listAdmins().length +
746
+ Object.values(config.security.adminUids ?? {}).reduce((sum, uids) => sum + uids.length, 0);
747
+ lines.push(`Security: ${config.security.allowAll ? "Allow all" : "Allowlist only"}${Object.values(config.security.allowedUids ?? {}).reduce((sum, uids) => sum + uids.length, 0) > 0 ? ` (+${Object.values(config.security.allowedUids ?? {}).reduce((sum, uids) => sum + uids.length, 0)} config UIDs)` : ""}`);
748
+ lines.push(`Admins: ${adminCount}`);
749
+ ctx.ui.setWidget("gateway-status", lines, {
750
+ placement: "belowEditor",
751
+ });
752
+ setTimeout(() => ctx.ui.setWidget("gateway-status", undefined), 15000);
753
+ return;
754
+ }
755
+ case "pair": {
756
+ const code = parts[1]?.toUpperCase();
757
+ const pending = code ? null : listPendingPairingCodes();
758
+ if (pending) {
759
+ ctx.ui.notify("Pending pairing codes:\n" +
760
+ (pending.length > 0
761
+ ? pending
762
+ .map((p) => `${p.code} - ${p.platform} (${Math.round(p.expiresIn / 60000)}min)`)
763
+ .join("\n")
764
+ : "None"), "info");
765
+ return;
766
+ }
767
+ if (approvePairingCode(code)) {
768
+ ctx.ui.notify("Pairing code approved", "info");
769
+ }
770
+ else {
771
+ ctx.ui.notify(`❌ Invalid or expired pairing code`, "error");
772
+ }
773
+ return;
774
+ }
775
+ case "allow": {
776
+ const platform = parts[1];
777
+ const userId = parts[2];
778
+ const list = listAllowlistedUsers();
779
+ const configUids = config.security.allowedUids ?? {};
780
+ const configLines = [];
781
+ for (const [plat, uids] of Object.entries(configUids)) {
782
+ for (const uid of uids) {
783
+ configLines.push(`${plat}:${uid} (config)`);
784
+ }
785
+ }
786
+ if (!platform || !userId) {
787
+ ctx.ui.notify("Allowlisted users:\n" +
788
+ (list.length > 0 || configLines.length > 0
789
+ ? [
790
+ ...list.map((u) => `${u.platform}:${u.userId}`),
791
+ ...configLines,
792
+ ].join("\n")
793
+ : "None"), "info");
794
+ return;
795
+ }
796
+ addToAllowlist(platform, userId);
797
+ ctx.ui.notify(`Added ${userId} to allowlist`, "info");
798
+ return;
799
+ }
800
+ case "revoke": {
801
+ const platform = parts[1];
802
+ const userId = parts[2];
803
+ if (!platform || !userId) {
804
+ ctx.ui.notify("Usage: /gateway revoke <platform> <userId>\n" +
805
+ "Removes a user from the DB allowlist.", "info");
806
+ return;
807
+ }
808
+ const removed = revokeUserAccess(platform, userId);
809
+ ctx.ui.notify(removed
810
+ ? `Removed ${userId} from allowlist`
811
+ : `${userId} was not in the allowlist`, removed ? "info" : "error");
812
+ return;
813
+ }
814
+ case "admin": {
815
+ const action = parts[1]?.toLowerCase();
816
+ switch (action) {
817
+ case "list": {
818
+ const dbAdmins = listAdmins();
819
+ const configAdmins = config.security.adminUids ?? {};
820
+ const configLines = [];
821
+ for (const [plat, uids] of Object.entries(configAdmins)) {
822
+ for (const uid of uids) {
823
+ configLines.push(`${plat}:${uid} (config)`);
824
+ }
825
+ }
826
+ const dbLines = dbAdmins.map((a) => `${a.platform}:${a.userId}`);
827
+ ctx.ui.notify("Admin users:\n" +
828
+ ([...dbLines, ...configLines].length > 0
829
+ ? [...dbLines, ...configLines].join("\n")
830
+ : "None"), "info");
831
+ return;
832
+ }
833
+ case "add": {
834
+ const plat = parts[2];
835
+ const uid = parts[3];
836
+ if (!plat || !uid) {
837
+ ctx.ui.notify("Usage: /gateway admin add <platform|*> <userId>\n" +
838
+ "Use * for platform to make admin on all platforms.\n" +
839
+ "Admins bypass all tool restrictions and have full access.", "info");
840
+ return;
841
+ }
842
+ addAdmin(plat, uid);
843
+ ctx.ui.notify(`✅ ${uid} is now admin on ${plat === "*" ? "all platforms" : plat}`, "info");
844
+ return;
845
+ }
846
+ case "remove": {
847
+ const plat = parts[2];
848
+ const uid = parts[3];
849
+ if (!plat || !uid) {
850
+ ctx.ui.notify("Usage: /gateway admin remove <platform|*> <userId>", "info");
851
+ return;
852
+ }
853
+ if (removeAdmin(plat, uid)) {
854
+ ctx.ui.notify(`Removed admin: ${plat}:${uid}`, "info");
855
+ }
856
+ else {
857
+ ctx.ui.notify(`${uid} was not an admin on ${plat}`, "error");
858
+ }
859
+ return;
860
+ }
861
+ default: {
862
+ ctx.ui.notify("/gateway admin commands:\n\n" +
863
+ " list - Show all admins (DB + config)\n" +
864
+ " add <platform|*> <uid> - Grant admin privileges\n" +
865
+ " remove <platform|*> <uid> - Revoke admin privileges\n\n" +
866
+ "Admins bypass all tool restrictions and have full access.\n" +
867
+ "Use * as platform to grant admin on all platforms.\n" +
868
+ "Config-file admins: set adminUids in gateway-security.json", "info");
869
+ }
870
+ }
871
+ return;
872
+ }
873
+ case "sessions": {
874
+ const sessions = listSessions();
875
+ ctx.ui.notify("Active sessions:\n" +
876
+ sessions
877
+ .slice(0, 10)
878
+ .map((s) => `${s.platform}:${s.channelId} (${s.id.slice(0, 8)}...)`)
879
+ .join("\n"), "info");
880
+ return;
881
+ }
882
+ case "tasks": {
883
+ const tasks = listTasks();
884
+ ctx.ui.notify("Background tasks:\n" +
885
+ tasks
886
+ .slice(0, 10)
887
+ .map((t) => `${t.id.slice(0, 12)}... - ${t.status} (${t.progress}%)`)
888
+ .join("\n"), "info");
889
+ return;
890
+ }
891
+ case "config": {
892
+ const configUidCount2 = Object.values(config.security.allowedUids ?? {}).reduce((sum, uids) => sum + uids.length, 0);
893
+ ctx.ui.notify(`Gateway Config:\n\n` +
894
+ `Port: ${config.port}\n` +
895
+ `Sessions: ${config.sessions.resetPolicy}\n` +
896
+ `Security: ${config.security.allowAll ? "Allow all" : "Allowlist"}` +
897
+ ` (${configUidCount2} config UIDs)\n` +
898
+ `Discord: ${config.platforms.discord?.enabled ? "Enabled" : "Disabled"}`, "info");
899
+ return;
900
+ }
901
+ case "tool-policy": {
902
+ const action = parts[1]?.toLowerCase();
903
+ switch (action) {
904
+ case "list": {
905
+ const platform = parts[2];
906
+ const userId = parts[3];
907
+ const policies = listToolPolicies(platform, userId);
908
+ if (policies.length === 0) {
909
+ ctx.ui.notify("No explicit tool policies — only defaults active.\n" +
910
+ "Use /gateway tool-policy defaults to see them.", "info");
911
+ return;
912
+ }
913
+ ctx.ui.notify("Tool policies:\n" +
914
+ policies
915
+ .map((p) => `#${p.id} ${p.platform ?? "*"}:${p.userId ?? "*"} → ${p.toolName} [${p.action}]`)
916
+ .join("\n"), "info");
917
+ return;
918
+ }
919
+ case "defaults": {
920
+ const summary = getEffectivePolicySummary("*", "*");
921
+ ctx.ui.notify("Default Tool Policy (all external users):\n\n" +
922
+ `✅ ALLOWED:\n ${summary.allowed.join("\n ")}\n\n` +
923
+ `🚫 DENIED:\n ${summary.denied.join("\n ")}\n\n` +
924
+ "Use /gateway tool-policy set to override.", "info");
925
+ return;
926
+ }
927
+ case "set": {
928
+ const plat = parts[2] || null;
929
+ const uid = parts[3] || null;
930
+ const tool = parts[4];
931
+ const act = parts[5]?.toLowerCase();
932
+ if (!tool || (act !== "allow" && act !== "deny")) {
933
+ ctx.ui.notify("Usage: /gateway tool-policy set [platform] [userId] <toolName> allow|deny\n\n" +
934
+ "Examples:\n" +
935
+ " /gateway tool-policy set discord * bash deny\n" +
936
+ " /gateway tool-policy set discord U123 bash allow\n" +
937
+ " /gateway tool-policy set * * write allow\n" +
938
+ " (Use * for platform/userId to mean all)", "info");
939
+ return;
940
+ }
941
+ setToolPolicy({
942
+ platform: plat === "*" ? null : plat,
943
+ userId: uid === "*" ? null : uid,
944
+ toolName: tool,
945
+ action: act,
946
+ priority: 50, // Explicit policies override default (priority 0)
947
+ });
948
+ ctx.ui.notify(`Policy set: ${plat ?? "*"}:${uid ?? "*"} → ${tool} [${act}]`, "info");
949
+ return;
950
+ }
951
+ case "remove": {
952
+ const id = parseInt(parts[2]);
953
+ if (isNaN(id)) {
954
+ ctx.ui.notify("Usage: /gateway tool-policy remove <id>\n" +
955
+ "Use /gateway tool-policy list to see IDs.", "info");
956
+ return;
957
+ }
958
+ if (removeToolPolicy(id)) {
959
+ ctx.ui.notify(`Removed tool policy #${id}`, "info");
960
+ }
961
+ else {
962
+ ctx.ui.notify(`Policy #${id} not found`, "error");
963
+ }
964
+ return;
965
+ }
966
+ case "reset": {
967
+ resetToolPolicies();
968
+ ctx.ui.notify("All tool policies reset to defaults.", "info");
969
+ return;
970
+ }
971
+ default: {
972
+ ctx.ui.notify("/gateway tool-policy commands:\n\n" +
973
+ " list [platform] [userId] - List explicit policies\n" +
974
+ " defaults - Show default policy\n" +
975
+ " set <p> <u> <tool> allow|deny - Add/update policy\n" +
976
+ " remove <id> - Delete a policy\n" +
977
+ " reset - Clear all, back to defaults\n\n" +
978
+ "Use * for platform/userId to match all.\n" +
979
+ "Tool names support globs: bash, gateway_*, wiki_*", "info");
980
+ }
981
+ }
982
+ return;
983
+ }
984
+ default: {
985
+ ctx.ui.notify("pi Gateway Commands:\n\n" +
986
+ " /gateway start [port] - Start gateway\n" +
987
+ " /gateway stop - Stop gateway\n" +
988
+ " /gateway restart - Restart gateway\n" +
989
+ " /gateway status - Show status\n" +
990
+ " /gateway pair <code> - Approve pairing\n" +
991
+ " /gateway allow <p> <u>- Add user to allowlist\n" +
992
+ " /gateway revoke <p> <u>- Remove user from allowlist\n" +
993
+ " /gateway admin list - List admin users\n" +
994
+ " /gateway admin add <p|*> <u> - Grant admin\n" +
995
+ " /gateway admin remove <p|*> <u> - Revoke admin\n" +
996
+ " /gateway sessions - List sessions\n" +
997
+ " /gateway tasks - List background tasks\n" +
998
+ " /gateway config - Show config\n" +
999
+ " /gateway tool-policy - Manage tool policies\n\n" +
1000
+ "Hermes-style features:\n" +
1001
+ " - Per-chat sessions with reset policies\n" +
1002
+ " - Platform adapters (Discord, etc.)\n" +
1003
+ " - Background task support\n" +
1004
+ " - Allowlist security (DB + config UIDs)\n" +
1005
+ " - Tool policy (per-user tool allow/deny)", "info");
1006
+ }
1007
+ }
1008
+ },
1009
+ });
1010
+ // Register tools
1011
+ pi.registerTool({
1012
+ name: "gateway_status",
1013
+ label: "Gateway Status",
1014
+ description: "Check Hermes-style gateway status",
1015
+ parameters: Type.Object({}),
1016
+ async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
1017
+ return {
1018
+ content: [
1019
+ {
1020
+ type: "text",
1021
+ text: `Gateway: ${state.running ? "Running" : "Stopped"}\n` +
1022
+ `Adapters: ${state.adapters.size}\n` +
1023
+ `Clients: ${state.clients.size}\n` +
1024
+ `Sessions: ${state.sessions.size}\n` +
1025
+ `Agent: ${rpcProcess ? "Connected" : "Disconnected"}`,
1026
+ },
1027
+ ],
1028
+ details: {
1029
+ running: state.running,
1030
+ adapters: state.adapters.size,
1031
+ clients: state.clients.size,
1032
+ sessions: state.sessions.size,
1033
+ },
1034
+ };
1035
+ },
1036
+ });
1037
+ pi.registerTool({
1038
+ name: "gateway_sessions",
1039
+ label: "Gateway Sessions",
1040
+ description: "List active gateway sessions",
1041
+ parameters: Type.Object({}),
1042
+ async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
1043
+ const sessions = listSessions();
1044
+ return {
1045
+ content: [
1046
+ {
1047
+ type: "text",
1048
+ text: `Active sessions: ${sessions.length}\n` +
1049
+ JSON.stringify(sessions.map((s) => ({
1050
+ id: s.id.slice(0, 12),
1051
+ platform: s.platform,
1052
+ channel: s.channelId,
1053
+ lastActivity: new Date(s.lastActivity).toISOString(),
1054
+ })), null, 2),
1055
+ },
1056
+ ],
1057
+ details: { count: sessions.length },
1058
+ };
1059
+ },
1060
+ });
1061
+ pi.registerTool({
1062
+ name: "gateway_background_tasks",
1063
+ label: "Background Tasks",
1064
+ description: "List and manage background tasks",
1065
+ parameters: Type.Object({
1066
+ status: Type.Optional(Type.String()),
1067
+ }),
1068
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1069
+ const tasks = listTasks(params.status);
1070
+ return {
1071
+ content: [
1072
+ {
1073
+ type: "text",
1074
+ text: `Background tasks: ${tasks.length}\n` +
1075
+ JSON.stringify(tasks.map((t) => ({
1076
+ id: t.id.slice(0, 12),
1077
+ status: t.status,
1078
+ progress: t.progress,
1079
+ command: t.command.slice(0, 50),
1080
+ })), null, 2),
1081
+ },
1082
+ ],
1083
+ details: { count: tasks.length },
1084
+ };
1085
+ },
1086
+ });
1087
+ pi.registerTool({
1088
+ name: "gateway_pairing",
1089
+ label: "Gateway Pairing",
1090
+ description: "Generate or approve pairing codes",
1091
+ parameters: Type.Object({
1092
+ action: Type.Union([
1093
+ Type.Literal("generate"),
1094
+ Type.Literal("list"),
1095
+ Type.Literal("approve"),
1096
+ ]),
1097
+ platform: Type.Optional(Type.String()),
1098
+ userId: Type.Optional(Type.String()),
1099
+ code: Type.Optional(Type.String()),
1100
+ }),
1101
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1102
+ const { action, platform, userId, code } = params;
1103
+ switch (action) {
1104
+ case "generate": {
1105
+ if (!platform || !userId) {
1106
+ return {
1107
+ content: [{ type: "text", text: "platform and userId required" }],
1108
+ details: { error: true },
1109
+ };
1110
+ }
1111
+ const pairingCode = generatePairingCode(platform, userId);
1112
+ return {
1113
+ content: [
1114
+ {
1115
+ type: "text",
1116
+ text: `Pairing code: ${pairingCode}\n\nShare this code with the user to approve access.`,
1117
+ },
1118
+ ],
1119
+ details: { code: pairingCode },
1120
+ };
1121
+ }
1122
+ case "approve": {
1123
+ if (!code) {
1124
+ return {
1125
+ content: [{ type: "text", text: "code required" }],
1126
+ details: { error: true },
1127
+ };
1128
+ }
1129
+ const success = approvePairingCode(code);
1130
+ return {
1131
+ content: [
1132
+ {
1133
+ type: "text",
1134
+ text: success ? "✅ Code approved" : "❌ Invalid/expired",
1135
+ },
1136
+ ],
1137
+ details: { success },
1138
+ };
1139
+ }
1140
+ case "list": {
1141
+ const pending = listPendingPairingCodes();
1142
+ return {
1143
+ content: [
1144
+ {
1145
+ type: "text",
1146
+ text: `Pending codes: ${pending.length}\n` +
1147
+ JSON.stringify(pending, null, 2),
1148
+ },
1149
+ ],
1150
+ details: { count: pending.length },
1151
+ };
1152
+ }
1153
+ }
1154
+ },
1155
+ });
1156
+ pi.registerTool({
1157
+ name: "gateway_tool_policy",
1158
+ label: "Gateway Tool Policy",
1159
+ description: "Manage tool access policies for external gateway users",
1160
+ parameters: Type.Object({
1161
+ action: Type.Union([
1162
+ Type.Literal("list"),
1163
+ Type.Literal("defaults"),
1164
+ Type.Literal("set"),
1165
+ Type.Literal("remove"),
1166
+ Type.Literal("reset"),
1167
+ ]),
1168
+ platform: Type.Optional(Type.String()),
1169
+ userId: Type.Optional(Type.String()),
1170
+ toolName: Type.Optional(Type.String()),
1171
+ policyAction: Type.Optional(Type.Union([Type.Literal("allow"), Type.Literal("deny")])),
1172
+ policyId: Type.Optional(Type.Number()),
1173
+ }),
1174
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1175
+ const { action, platform, userId, toolName, policyAction, policyId } = params;
1176
+ switch (action) {
1177
+ case "list": {
1178
+ const policies = listToolPolicies(platform, userId);
1179
+ return {
1180
+ content: [
1181
+ {
1182
+ type: "text",
1183
+ text: policies.length > 0
1184
+ ? JSON.stringify(policies, null, 2)
1185
+ : "No explicit policies — only defaults active.",
1186
+ },
1187
+ ],
1188
+ details: { count: policies.length, policies },
1189
+ };
1190
+ }
1191
+ case "defaults": {
1192
+ const summary = getEffectivePolicySummary(platform ?? "*", userId ?? "*");
1193
+ return {
1194
+ content: [
1195
+ {
1196
+ type: "text",
1197
+ text: `Default tool policy:\n\n` +
1198
+ `ALLOWED: ${summary.allowed.join(", ")}\n` +
1199
+ `DENIED: ${summary.denied.join(", ")}`,
1200
+ },
1201
+ ],
1202
+ details: summary,
1203
+ };
1204
+ }
1205
+ case "set": {
1206
+ if (!toolName || !policyAction) {
1207
+ return {
1208
+ content: [
1209
+ {
1210
+ type: "text",
1211
+ text: "toolName and policyAction (allow|deny) are required",
1212
+ },
1213
+ ],
1214
+ details: { error: true },
1215
+ };
1216
+ }
1217
+ setToolPolicy({
1218
+ platform: platform ?? null,
1219
+ userId: userId ?? null,
1220
+ toolName,
1221
+ action: policyAction,
1222
+ priority: 50,
1223
+ });
1224
+ return {
1225
+ content: [
1226
+ {
1227
+ type: "text",
1228
+ text: `Policy set: ${platform ?? "*"}:${userId ?? "*"} → ${toolName} [${policyAction}]`,
1229
+ },
1230
+ ],
1231
+ details: { success: true },
1232
+ };
1233
+ }
1234
+ case "remove": {
1235
+ if (policyId == null) {
1236
+ return {
1237
+ content: [
1238
+ { type: "text", text: "policyId (number) is required" },
1239
+ ],
1240
+ details: { error: true },
1241
+ };
1242
+ }
1243
+ const removed = removeToolPolicy(policyId);
1244
+ return {
1245
+ content: [
1246
+ {
1247
+ type: "text",
1248
+ text: removed
1249
+ ? `Removed policy #${policyId}`
1250
+ : `Policy #${policyId} not found`,
1251
+ },
1252
+ ],
1253
+ details: { success: removed },
1254
+ };
1255
+ }
1256
+ case "reset": {
1257
+ resetToolPolicies();
1258
+ return {
1259
+ content: [
1260
+ { type: "text", text: "All tool policies reset to defaults." },
1261
+ ],
1262
+ details: { success: true },
1263
+ };
1264
+ }
1265
+ }
1266
+ },
1267
+ });
1268
+ // Notify on session start
1269
+ pi.on("session_start", async (_event, ctx) => {
1270
+ globalCtx = ctx;
1271
+ updateStatus();
1272
+ });
1273
+ logger.info("[pi-gateway] Hermes-style gateway extension loaded");
1274
+ }
1275
+ //# sourceMappingURL=index.js.map