/** * pi-gateway - Hermes-style Messaging Gateway * * Architecture: * - Single background process * - Platform adapters (Discord, Telegram, etc.) * - Per-chat session management * - Background task support * - Security (allowlists, pairing) * * Usage in pi session: * /gateway start [port] - Start the gateway * /gateway stop - Stop the gateway * /gateway status - Show status * /gateway pair - Approve pairing code * * Programmatic API: * import { startGateway, stopGateway, isGatewayRunning } from '@0xkobold/pi-gateway/api'; */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { startGateway, stopGateway, getStatus, isRunning, getConfig, getAdapters, sendMessage, broadcast, type GatewayStatus, type StartGatewayOptions, } from "./api.js"; import { getOrCreateSession, listSessions, type SessionConfig, } from "./sessions/store.js"; import { isUserAllowed, approvePairingCode, generatePairingCode, listPendingPairingCodes, addToAllowlist, listAllowlistedUsers, type Platform, } from "./security/auth.js"; import { startBackgroundTask, listTasks, } from "./background/manager.js"; let globalCtx: ExtensionContext | null = null; function updateStatus(): void { if (!globalCtx) return; const status = getStatus(); const adapterCount = status.adapters.length; const statusText = status.running ? adapterCount > 0 ? `🟢 Gateway (${adapterCount} platform${adapterCount !== 1 ? "s" : ""})` : `🟡 Gateway (waiting)` : "🔴 Gateway"; globalCtx.ui.setStatus("gateway", statusText); } export default async function (pi: ExtensionAPI): Promise { pi.registerCommand("gateway", { description: "Manage Hermes-style messaging gateway", getArgumentCompletions: (prefix: string) => { const cmds = ["start", "stop", "status", "restart", "pair", "allow", "sessions", "tasks", "config"]; return cmds.filter(c => c.startsWith(prefix)).map(c => ({ value: c, label: c })); }, handler: async (args, ctx) => { const parts = args.split(/\s+/).filter(Boolean); const subcmd = parts[0]?.toLowerCase(); switch (subcmd) { case "start": { if (isRunning()) { ctx.ui.notify("Gateway already running", "info"); return; } const port = parseInt(parts[1]) || undefined; const status = await startGateway({ port, noAgent: false }); ctx.ui.notify( `✅ Gateway started on http://${status.host}:${status.port}\n\n` + `Platforms: ${status.adapters.length > 0 ? status.adapters.join(", ") : "none"}\n` + `Sessions: Idle reset every ${getConfig().sessions.idleMinutes} min`, "info" ); return; } case "stop": { if (!isRunning()) { ctx.ui.notify("Gateway not running", "info"); return; } await stopGateway(); updateStatus(); ctx.ui.notify("Gateway stopped", "info"); return; } case "restart": { await stopGateway(); const status = await startGateway({ noAgent: false }); updateStatus(); ctx.ui.notify(`✅ Gateway restarted on port ${status.port}`, "info"); return; } case "status": { const status = getStatus(); const cfg = getConfig(); const lines: string[] = [ `Status: ${status.running ? "🟢 Running" : "🔴 Stopped"}`, `Port: ${status.port}`, `Adapters: ${status.adapters.length}`, `Clients: ${status.clientCount}`, `Sessions: ${status.sessionCount}`, `Agent: ${status.agentConnected ? "✅ Connected" : "❌ Disconnected"}`, "", `Session Reset: ${cfg.sessions.resetPolicy}`, ` - Daily at ${cfg.sessions.dailyHour}:00`, ` - Idle after ${cfg.sessions.idleMinutes} min`, "", `Security: ${cfg.security.allowAll ? "Allow all" : "Allowlist only"}`, ]; ctx.ui.setWidget("gateway-status", lines, { placement: "belowEditor" }); setTimeout(() => ctx.ui.setWidget("gateway-status", undefined), 15000); return; } case "pair": { const code = parts[1]?.toUpperCase(); if (!code) { const pending = await listPendingPairingCodes(); ctx.ui.notify( "Pending pairing codes:\n" + (pending.length > 0 ? pending.map(p => `${p.code} - ${p.platform} (${Math.round(p.expiresIn / 60000)}min)`).join("\n") : "None"), "info" ); return; } if (await approvePairingCode(code)) { ctx.ui.notify("Pairing code approved", "info"); } else { ctx.ui.notify("❌ Invalid or expired pairing code", "error"); } return; } case "allow": { const platform = parts[1] as Platform; const userId = parts[2]; if (!platform || !userId) { const list = await listAllowlistedUsers(); ctx.ui.notify( "Allowlisted users:\n" + (list.length > 0 ? list.map(u => `${u.platform}:${u.userId}`).join("\n") : "None"), "info" ); return; } await addToAllowlist(platform, userId); ctx.ui.notify(`Added ${userId} to allowlist`, "info"); return; } case "sessions": { const sessions = await listSessions(); ctx.ui.notify( "Active sessions:\n" + sessions.slice(0, 10).map(s => `${s.platform}:${s.channelId} (${s.id.slice(0, 8)}...)` ).join("\n"), "info" ); return; } case "tasks": { const tasks = await listTasks(); ctx.ui.notify( "Background tasks:\n" + tasks.slice(0, 10).map(t => `${t.id.slice(0, 12)}... - ${t.status} (${t.progress}%)` ).join("\n"), "info" ); return; } case "config": { const cfg = getConfig(); ctx.ui.notify( `Gateway Config:\n\n` + `Port: ${cfg.port}\n` + `Sessions: ${cfg.sessions.resetPolicy}\n` + `Security: ${cfg.security.allowAll ? "Allow all" : "Allowlist"}\n` + `Discord: ${cfg.platforms.discord?.enabled ? "Enabled" : "Disabled"}`, "info" ); return; } default: { ctx.ui.notify( "pi Gateway Commands:\n\n" + " /gateway start [port] - Start gateway\n" + " /gateway stop - Stop gateway\n" + " /gateway restart - Restart gateway\n" + " /gateway status - Show status\n" + " /gateway pair - Approve pairing\n" + " /gateway allow

- Add user to allowlist\n" + " /gateway sessions - List sessions\n" + " /gateway tasks - List background tasks\n" + " /gateway config - Show config\n\n" + "Hermes-style features:\n" + " - Per-chat sessions with reset policies\n" + " - Platform adapters (Discord, etc.)\n" + " - Background task support\n" + " - Allowlist security", "info" ); } } }, }); // Register tools pi.registerTool({ name: "gateway_status", label: "Gateway Status", description: "Check Hermes-style gateway status", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { const status = getStatus(); return { content: [{ type: "text", text: `Gateway: ${status.running ? "Running" : "Stopped"}\n` + `Adapters: ${status.adapters.length}\n` + `Clients: ${status.clientCount}\n` + `Sessions: ${status.sessionCount}\n` + `Agent: ${status.agentConnected ? "Connected" : "Disconnected"}`, }], details: { running: status.running, adapters: status.adapters.length, clients: status.clientCount, sessions: status.sessionCount, }, }; }, }); pi.registerTool({ name: "gateway_sessions", label: "Gateway Sessions", description: "List active gateway sessions", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { const sessions = await listSessions(); return { content: [{ type: "text", text: `Active sessions: ${sessions.length}\n` + JSON.stringify(sessions.map(s => ({ id: s.id.slice(0, 12), platform: s.platform, channel: s.channelId, lastActivity: new Date(s.lastActivity).toISOString(), })), null, 2), }], details: { count: sessions.length }, }; }, }); pi.registerTool({ name: "gateway_background_tasks", label: "Background Tasks", description: "List and manage background tasks", parameters: Type.Object({ status: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const tasks = await listTasks(params.status as any); return { content: [{ type: "text", text: `Background tasks: ${tasks.length}\n` + JSON.stringify(tasks.map(t => ({ id: t.id.slice(0, 12), status: t.status, progress: t.progress, command: t.command.slice(0, 50), })), null, 2), }], details: { count: tasks.length }, }; }, }); pi.registerTool({ name: "gateway_pairing", label: "Gateway Pairing", description: "Generate or approve pairing codes", parameters: Type.Object({ action: Type.Union([Type.Literal("generate"), Type.Literal("list"), Type.Literal("approve")]), platform: Type.Optional(Type.String()), userId: Type.Optional(Type.String()), code: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const { action, platform, userId, code } = params; switch (action) { case "generate": { if (!platform || !userId) { return { content: [{ type: "text", text: "platform and userId required" }], details: { error: true } }; } const pairingCode = await generatePairingCode(platform as Platform, userId); return { content: [{ type: "text", text: `Pairing code: ${pairingCode}\n\nShare this code with the user to approve access.`, }], details: { code: pairingCode }, }; } case "approve": { if (!code) { return { content: [{ type: "text", text: "code required" }], details: { error: true } }; } const success = await approvePairingCode(code); return { content: [{ type: "text", text: success ? "✅ Code approved" : "❌ Invalid/expired" }], details: { success }, }; } case "list": { const pending = await listPendingPairingCodes(); return { content: [{ type: "text", text: `Pending codes: ${pending.length}\n` + JSON.stringify(pending, null, 2), }], details: { count: pending.length }, }; } } }, }); // Notify on session start pi.on("session_start", async (_event, ctx) => { globalCtx = ctx; updateStatus(); }); // Re-export programmatic API so extension consumers can import from here too console.log("[pi-gateway] Hermes-style gateway extension loaded"); } // Re-export programmatic API for direct import export { startGateway, stopGateway, isGatewayRunning, getStatus, getConfig, isRunning, getAdapter, getAdapters, sendMessage, broadcast } from "./api.js"; export type { GatewayConfig, GatewayStatus, StartGatewayOptions } from "./api.js";