/** * pi-zulip-extension — Zulip agent communication plugin for pi agents * * Deploy to: ~/.pi/agent/extensions/zulip/ * Config: config.yaml (alongside extension, or env vars) * * DM-first architecture per ADR-001/ADR-002. * Background Zulip event queue poller that injects messages directly into * the current pi session via pi.sendUserMessage(), then captures the LLM * response from agent_end and sends it back to Zulip. No subprocess overhead. * * @see ADR-007: Platform-native plugin contracts * @see docs/ARCHITECTURE.md */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import zulip from "zulip-js"; import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import url from "node:url"; import { parse } from "yaml"; // zulip-js types are declared in types.d.ts in this directory // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- interface ZulipConfig { zulip: { email: string; api_key: string; site: string; stream?: string; all_bots_user_id?: number; }; agent: { name: string; owner_email: string; display_name?: string; zulip_bot_name?: string; private_topic?: string; }; health_port?: number; poll_interval_ms?: number; max_retries?: number; retry_delay_ms?: number; pi_command?: string; } function loadConfig(): ZulipConfig { // Check env vars first const email = process.env.ZULIP_EMAIL; const apiKey = process.env.ZULIP_API_KEY; const site = process.env.ZULIP_SITE; if (email && apiKey && site) { return { zulip: { email, api_key: apiKey, site }, agent: { name: process.env.AGENT_NAME ?? "abiba", owner_email: process.env.AGENT_OWNER_EMAIL ?? "jerome@sysloggh.com", }, health_port: parseInt(process.env.HEALTH_PORT ?? "9200", 10), poll_interval_ms: 3000, max_retries: 3, retry_delay_ms: 5000, pi_command: "pi", }; } // Fall back to config.yaml alongside the extension const configDir = path.dirname(url.fileURLToPath(import.meta.url)); const configPath = path.join(configDir, "config.yaml"); if (!fs.existsSync(configPath)) { throw new Error( `No config.yaml found at ${configPath}. Set ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE env vars or provide config.yaml.`, ); } const raw = fs.readFileSync(configPath, "utf-8"); const cfg = parse(raw) as Record; // Support both flat (env-style) and nested config formats function getField(key: string): unknown { if (cfg[key] !== undefined) return cfg[key]; // Try nested lookup: "zulip.email" -> cfg.zulip?.email const parts = key.split("."); let cur: Record = cfg; for (const p of parts) { if (cur && typeof cur === "object" && p in cur) { cur = cur[p] as Record; } else { return undefined; } } return cur; } const parsed: ZulipConfig = { zulip: { email: String(getField("zulip.email") ?? getField("zulip.email") ?? ""), api_key: String(getField("zulip.api_key") ?? getField("zulip.api_key") ?? ""), site: String(getField("zulip.site") ?? getField("zulip.server_url") ?? ""), stream: String(getField("zulip.stream") ?? ""), all_bots_user_id: Number(getField("zulip.all_bots_user_id") ?? 1), }, agent: { name: String(getField("agent.name") ?? "abiba"), owner_email: String(getField("agent.owner_email") ?? "jerome@sysloggh.com"), display_name: String(getField("agent.display_name") ?? ""), zulip_bot_name: String(getField("agent.zulip_bot_name") ?? ""), private_topic: String(getField("agent.private_topic") ?? ""), }, health_port: parseInt(String(getField("health_port") ?? getField("monitoring.health_port") ?? "9200"), 10), poll_interval_ms: 3000, max_retries: 3, retry_delay_ms: 5000, pi_command: "pi", }; // Override api_key from env if set parsed.zulip.api_key = process.env.ZULIP_API_KEY ?? parsed.zulip.api_key; return parsed; } // --------------------------------------------------------------------------- // Zulip API helpers // --------------------------------------------------------------------------- interface ZulipMessage { id: number; type: "stream" | "private"; display_recipient?: string | Array<{ id: number; email: string; full_name: string }>; sender_id: number; sender_email: string; sender_full_name: string; content: string; subject: string; stream_id?: number; } interface ZulipEvent { id: number; type: "message"; message: ZulipMessage; } /** * Register a Zulip event queue and poll for events. */ async function createZulipQueue(config: ZulipConfig) { const client = await zulip({ username: config.zulip.email, apiKey: config.zulip.api_key, realm: config.zulip.site, }); // Register queue const queueRes = await client.queues.register({ event_types: ["message"], }); const queueId = queueRes.queue_id; let lastEventId: number = queueRes.last_event_id ?? -1; return { client, queueId, lastEventId, /** * Poll the event queue and return any new events. */ async poll(): Promise { const res = await fetch( `${config.zulip.site}/api/v1/events?queue_id=${encodeURIComponent(queueId)}&last_event_id=${lastEventId}`, { headers: { Authorization: "Basic " + Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"), }, }, ); if (!res.ok) { throw new Error(`Events API returned ${res.status}: ${await res.text()}`); } const data = (await res.json()) as { events?: ZulipEvent[] }; if (data.events && Array.isArray(data.events)) { for (const event of data.events) { if (event.id > lastEventId) { lastEventId = event.id; } } return data.events.filter((e) => e.type === "message"); } return []; }, /** * Send a message to Zulip via the API. */ async sendMessage(params: { type: "private" | "stream"; to: number | string; subject?: string; content: string; }): Promise { const formBody = new URLSearchParams(); formBody.append("type", params.type); formBody.append("to", params.type === "private" ? JSON.stringify([params.to]) : String(params.to)); if (params.subject) formBody.append("subject", params.subject); formBody.append("content", params.content); const res = await fetch(`${config.zulip.site}/api/v1/messages`, { method: "POST", headers: { Authorization: "Basic " + Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"), "Content-Type": "application/x-www-form-urlencoded", }, body: formBody.toString(), }); if (!res.ok) { throw new Error(`Send message API returned ${res.status}: ${await res.text()}`); } const body = (await res.json()) as { id: number }; return body.id; }, /** * Edit a previously sent Zulip message (streaming update). */ async editMessage(messageId: number, content: string): Promise { const formBody = new URLSearchParams(); formBody.append("content", content); const res = await fetch(`${config.zulip.site}/api/v1/messages/${messageId}`, { method: "PATCH", headers: { Authorization: "Basic " + Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"), "Content-Type": "application/x-www-form-urlencoded", }, body: formBody.toString(), }); if (!res.ok && res.status !== 400) { console.warn(`[zulip-ext] Edit message ${messageId} returned ${res.status}`); } }, /** * Send typing indicator. */ async sendTypingNotification(userIds: number[], operation: "start" | "stop"): Promise { const formBody = new URLSearchParams(); formBody.append("to", JSON.stringify(userIds)); formBody.append("op", operation); await fetch(`${config.zulip.site}/api/v1/typing`, { method: "POST", headers: { Authorization: "Basic " + Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"), "Content-Type": "application/x-www-form-urlencoded", }, body: formBody.toString(), }); }, }; } // --------------------------------------------------------------------------- // Health endpoint // --------------------------------------------------------------------------- function startHealthServer(port: number, getState: () => Record) { const server = http.createServer((req, res) => { if (req.url === "/health" || req.url === "/") { const state = getState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", ...state })); } else { res.writeHead(404); res.end("Not found"); } }); server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { console.warn(`[zulip-ext] Port :${port} already in use — skipping health endpoint`); } else { console.error(`[zulip-ext] Health server error:`, err.message); } }); server.listen(port, "127.0.0.1", () => { console.log(`[zulip-ext] Health endpoint on :${port}`); }); return server; } // --------------------------------------------------------------------------- // Detect @all-bots content // --------------------------------------------------------------------------- const ALL_BOTS_RE = /@\*\*(?:all-bots|All Bots)\*\*/i; function isAllBotsMention(content: string): boolean { return ALL_BOTS_RE.test(content); } // --------------------------------------------------------------------------- // Main extension // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { const config = loadConfig(); let queue: Awaited> | null = null; let pollTimer: ReturnType | null = null; let retryCount = 0; let connected = false; let lastError: string | null = null; let processedCount = 0; let healthServer: ReturnType | null = null; // Track state for health endpoint function getState() { return { connected, email: config.zulip.email, site: config.zulip.site, agent: config.agent.name, is_owner: config.agent.owner_email, queue_id: queue?.queueId ?? null, last_event_id: queue?.lastEventId ?? null, messages_processed: processedCount, last_error: lastError, retry_count: retryCount, }; } // Pending Zulip replies awaiting the LLM response from agent_end. // zulipMessageId is set after the placeholder is sent. const pendingZulipReplies: Array<{ id: number; type: "private" | "stream"; senderId?: number; senderName?: string; streamName?: string; topic?: string; streamId?: number; zulipMessageId?: number; }> = []; let zulipReplyIdCounter = 0; let isAgentBusy = false; let streamingTimer: ReturnType | null = null; let streamingAccumulator = ""; let streamingReplyId: number | null = null; /** * Queue a Zulip message into the current pi session via sendUserMessage * and schedule the reply to be sent back on the next agent_end event. * Sends a "Thinking..." placeholder to Zulip immediately for streaming UX. */ function injectIntoSession( msg: ZulipMessage, senderName: string, senderId: number, replyInfo: { type: "private" | "stream"; senderId?: number; senderName?: string; streamName?: string; topic?: string; streamId?: number; }, ): void { // Send typing indicator (fire-and-forget) queue!.sendTypingNotification([senderId], "start").catch(() => {}); const replyId = ++zulipReplyIdCounter; const label = replyInfo.type === "stream" ? `@all-bots in #${replyInfo.streamName!}` : `DM from @${senderName}`; // Send a placeholder "Thinking..." message to Zulip immediately const placeholders = [ ":robot: _Processing your message..._", ":hourglass_flowing_sand: _Thinking..._", ":brain: _Generating response..._", ]; const placeholder = placeholders[replyId % placeholders.length]; const placeholderTarget = replyInfo.type === "private" ? { type: "private" as const, to: senderId } : { type: "stream" as const, to: replyInfo.streamId ?? replyInfo.streamName!, subject: replyInfo.topic! }; queue!.sendMessage({ ...placeholderTarget, content: placeholder }) .then((msgId) => { pendingZulipReplies.push({ id: replyId, ...replyInfo, zulipMessageId: msgId }); console.log(`[zulip-ext] Placed [${replyId}] (msg ${msgId}) for ${senderName}`); }) .catch(() => { pendingZulipReplies.push({ id: replyId, ...replyInfo }); console.log(`[zulip-ext] Queued [${replyId}] for ${senderName} (no placeholder)`); }); // Inject into the pi session — use steer mode if agent is busy if (isAgentBusy) { pi.sendUserMessage(`[Zulip ${label}]: ${msg.content}`, { deliverAs: "steer" }); console.log(`[zulip-ext] Steer [${replyId}]: queued while agent busy`); } else { pi.sendUserMessage(`[Zulip ${label}]: ${msg.content}`); console.log(`[zulip-ext] Injected [${replyId}]: from ${senderName}`); } } /** * Process a single Zulip event — injects DMs and @all-bots into the * current pi session instead of spawning a subprocess. */ async function processEvent(event: ZulipEvent): Promise { const msg = event.message; // Ignore non-message events if (event.type !== "message") return; // Ignore own messages (sent by this bot) if (msg.sender_email === config.zulip.email) return; // DM-first architecture (ADR-001, ADR-002): process private messages if (msg.type === "private") { processedCount++; const senderName = msg.sender_full_name || msg.sender_email; const senderId = msg.sender_id; console.log(`[zulip-ext] DM from ${senderName} (id=${senderId}): ${msg.content.slice(0, 80)}...`); injectIntoSession(msg, senderName, senderId, { type: "private", senderId, senderName }); return; } // Stream messages: only respond to @all-bots (ADR-006) if (msg.type === "stream" && isAllBotsMention(msg.content)) { processedCount++; const streamName = typeof msg.display_recipient === "string" ? msg.display_recipient : "unknown"; const topic = msg.subject || "general"; console.log(`[zulip-ext] @all-bots in #${streamName} > ${topic}`); injectIntoSession(msg, msg.sender_full_name || msg.sender_email, msg.sender_id, { type: "stream", streamName, topic, streamId: msg.stream_id, }); } } /** * Polling loop. */ async function startPolling(): Promise { console.log(`[zulip-ext] Connecting to ${config.zulip.site} as ${config.zulip.email}...`); try { queue = await createZulipQueue(config); connected = true; retryCount = 0; lastError = null; console.log(`[zulip-ext] Connected, queue: ${queue.queueId} (last_event_id=${queue.lastEventId})`); // Start polling pollTimer = setInterval(async () => { if (!queue) return; try { const events = await queue.poll(); for (const event of events) { await processEvent(event); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); // Check if queue was deregistered (need to reconnect) if (errMsg.includes("BAD_EVENT_QUEUE_ID") || errMsg.includes("queue_id")) { console.log(`[zulip-ext] Queue expired, reconnecting...`); connected = false; if (pollTimer) clearInterval(pollTimer); pollTimer = null; setTimeout(() => startPolling(), config.retry_delay_ms ?? 5000); return; } // Network errors — retry on next poll interval lastError = errMsg; console.error(`[zulip-ext] Poll error: ${errMsg}`); } }, config.poll_interval_ms ?? 3000); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); lastError = errMsg; console.error(`[zulip-ext] Connection failed: ${errMsg}`); if (retryCount < (config.max_retries ?? 3)) { retryCount++; const delay = (config.retry_delay_ms ?? 5000) * retryCount; console.log(`[zulip-ext] Retrying in ${delay / 1000}s (attempt ${retryCount})...`); setTimeout(() => startPolling(), delay); } else { console.error(`[zulip-ext] Max retries (${config.max_retries}) reached. Giving up.`); lastError = `Connection failed after ${config.max_retries} retries: ${errMsg}`; } } } // ------------------------------------------------------------------------- // Extension lifecycle // ------------------------------------------------------------------------- pi.on("session_start", async (_event) => { console.log(`[zulip-ext] Starting Zulip extension for ${config.agent.name} (${config.zulip.email})`); if (!healthServer) { healthServer = startHealthServer(config.health_port ?? 9200, getState); } startPolling(); }); // ----------------------------------------------------------------------- // Streaming + steering lifecycle // ----------------------------------------------------------------------- /** * turn_start fires when a new LLM turn begins. Mark the agent as busy * so subsequent Zulip DMs are steered instead of starting new turns. */ pi.on("turn_start", async () => { isAgentBusy = true; streamingAccumulator = ""; streamingReplyId = pendingZulipReplies.length > 0 ? pendingZulipReplies[0].id : null; }); /** * message_update fires on each streaming token. Accumulate partial text * and periodically edit the Zulip placeholder message so the user sees * the response come in live. */ pi.on("message_update", async (event) => { const msg = event.message as any; const content = msg.content; let partialText = ""; if (typeof content === "string") { partialText = content; } else if (Array.isArray(content)) { partialText = (content as Array<{ type: string; text: string }>) .filter((c) => c.type === "text") .map((c) => c.text) .join("\n"); } streamingAccumulator = partialText; if (!streamingTimer && streamingAccumulator.length > 10) { streamingTimer = setTimeout(async () => { streamingTimer = null; if (!streamingAccumulator || streamingReplyId === null) return; const pending = pendingZulipReplies.find((r) => r.id === streamingReplyId); if (!pending || !pending.zulipMessageId) return; try { const preview = streamingAccumulator.length > 1800 ? streamingAccumulator.slice(0, 1800) + "\n\n_… still generating…_" : streamingAccumulator; await queue!.editMessage(pending.zulipMessageId, preview); } catch { // Non-critical during streaming } }, 2000); } }); /** * agent_end captures the final LLM response and relays it back to Zulip. * If we sent a placeholder message earlier, edit it with the final content * (instead of sending a new message) for a seamless streaming experience. */ pi.on("agent_end", async (event) => { isAgentBusy = false; if (streamingTimer) { clearTimeout(streamingTimer); streamingTimer = null; } if (pendingZulipReplies.length === 0) return; const reply = pendingZulipReplies.shift()!; // Extract final assistant response text const allMsgs = event.messages as unknown as Array>; const assistantMsgs = allMsgs.filter((m) => m.role === "assistant"); const lastAssistant = assistantMsgs[assistantMsgs.length - 1]; let responseText = ""; if (lastAssistant) { const content = lastAssistant.content; if (typeof content === "string") { responseText = content; } else if (Array.isArray(content)) { responseText = (content as Array<{ type: string; text: string }>) .filter((c) => c.type === "text") .map((c) => c.text) .join("\n"); } } // Stop typing indicator if (reply.senderId) { queue!.sendTypingNotification([reply.senderId], "stop").catch(() => {}); } if (!responseText.trim()) { console.log(`[zulip-ext] No assistant response for [${reply.id}] from ${reply.senderName}`); return; } const truncated = responseText.length > 1500 ? responseText.slice(0, 1500) + "\n\n[...truncated, see session for full output]" : responseText; try { if (reply.zulipMessageId) { // Edit the placeholder with the final response (streaming UX) await queue!.editMessage(reply.zulipMessageId, truncated); console.log(`[zulip-ext] Finalized [${reply.id}] for ${reply.senderName} (${truncated.length} chars)`); } else { // No placeholder — send as new message const target = reply.type === "private" ? { type: "private" as const, to: reply.senderId! } : { type: "stream" as const, to: reply.streamId ?? reply.streamName!, subject: reply.topic! }; await queue!.sendMessage({ ...target, content: truncated }); console.log(`[zulip-ext] Replied to [${reply.id}] ${reply.senderName} (${truncated.length} chars)`); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); console.error(`[zulip-ext] Failed to finalize reply [${reply.id}]: ${errMsg}`); lastError = errMsg; } }); pi.on("session_shutdown", async () => { console.log(`[zulip-ext] Shutting down...`); if (streamingTimer) { clearTimeout(streamingTimer); streamingTimer = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (healthServer) { healthServer.close(); healthServer = null; } connected = false; queue = null; }); // Register /zulip-status command for diagnostics pi.registerCommand("zulip-status", { description: "Show Zulip extension status and stats", handler: async (_args: unknown, ctx) => { const state = getState(); const lines: string[] = [ "=== Zulip Extension Status ===", "", `Agent: ${state.agent}`, `Email: ${state.email}`, `Server: ${state.site}`, `Owner: ${state.is_owner}`, `Connected: ${state.connected ? "✅ Yes" : "❌ No"}`, `Queue ID: ${state.queue_id ?? "N/A"}`, `Last Event ID: ${state.last_event_id ?? "N/A"}`, `Messages: ${state.messages_processed} processed`, `Retries: ${state.retry_count}`, `Health Port: :${config.health_port ?? 9200}`, ]; if (state.last_error) { lines.push("", `Last Error: ${state.last_error}`); } lines.push("", "ADRs followed: DM-first (ADR-001), DM-routing (ADR-002), @all-bots content (ADR-006)"); ctx.ui.notify(lines.join("\n"), "info"); }, }); // Register zulip-send-test command pi.registerCommand("zulip-send-test", { description: "Send a test message to Zulip user: /zulip-send-test ", handler: async (args: string, ctx) => { const parts = args.trim().match(/^([^\s]+)\s+(.+)$/); const targetEmail = parts?.[1] ?? config.agent.owner_email; const message = parts?.[2] ?? "Test message from pi Zulip extension"; if (!queue) { ctx.ui.notify("Zulip queue not connected. Use /zulip-status to check.", "error"); return; } try { await queue.sendMessage({ type: "private", to: targetEmail, content: message, }); ctx.ui.notify(`Test message sent to ${targetEmail}`, "info"); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); ctx.ui.notify(`Failed to send: ${errMsg}`, "error"); } }, }); }