/** * pi-pipes: exposes the Pipes daemon (GitHub Actions, GitLab CI, Jenkins, * Prow) two ways: real per-operation Pi tools (ci_status, ci_trigger, * ci_wait, ...) projected from the daemon's own VehicleRegistry (see * vehicle-client.ts and @danypops/pipes' src/vehicle/pipes-vehicle.ts), and * a `/pipes` interactive TUI for the human (see pipes-tui.ts). Thin * authenticated client to @danypops/pipes — no network access or * credentials of its own. */ import { createPipesClient, resolveVehicleClientTarget } from "@danypops/pipes"; import { connectPushChannel, type PushChannelClient } from "@danypops/vehicle-client/daemon-client"; import { registerSharedSecretsCommand } from "@danypops/vehicle-client-pi/secrets-tui"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { parseJobCompletionTransition } from "./jobs-client.ts"; import { JobsOverlay } from "./jobs-overlay.ts"; import { runPipesCommand } from "./pipes-tui.ts"; import { buildPipesSecretsBackends } from "./secrets.ts"; import { registerPipesVehicle, resolvePipesProgressBarStyle } from "./vehicle-client.ts"; export interface PiPipesDeps { /** Overridden in tests instead of exercising the real (daemon-talking) registerPipesVehicle. */ registerVehicle?: typeof registerPipesVehicle; connectCompletionChannel?: typeof connectPushChannel; resolveVehicleTarget?: typeof resolveVehicleClientTarget; } export default async function pipesExtension(pi: ExtensionAPI, deps: PiPipesDeps = {}) { const registerVehicle = deps.registerVehicle ?? registerPipesVehicle; const connectCompletionChannel = deps.connectCompletionChannel ?? connectPushChannel; const resolveVehicleTarget = deps.resolveVehicleTarget ?? resolveVehicleClientTarget; pi.registerCommand("pipes", { description: "Cross-platform CI: GitHub Actions, GitLab CI, Jenkins, Prow — trigger, cancel, view logs, manage presets", handler: async (_args, ctx) => runPipesCommand(ctx, createPipesClient), }); // Contributes to the shared /secrets namespace (vehicle-client-pi's // registerSharedSecretsCommand) instead of a menu entry buried inside // /pipes -- pi-enigma and pi-tickets contribute the same way, so // whichever of the three loads first in a given Pi session ends up // claiming the real command registration, and all three still show up // in it regardless of load order. registerSharedSecretsCommand(pi, { source: "pipes", resolve: () => ({ backends: buildPipesSecretsBackends() }) }); // The session-scoped Jobs overlay consumes authenticated CI push transitions and uses bounded // polling as a fallback. Passive startup reads the daemon handle only; it never starts a daemon. let jobsOverlay: JobsOverlay | undefined; let completionChannel: PushChannelClient | undefined; pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; // This session's own real id, so ci.subscribed only ever returns (and the ticker only ever // reacts to) jobs *this* session itself subscribed to -- see jobs-overlay.ts's own doc comment // on the cross-session leak this fixes. // ctx.isIdle is captured once here and reused by every later poll tick (startPolling()'s own // BoundedPoll has no ExtensionContext of its own to ask) -- see jobs-overlay.ts's own doc // comment for the blocking-turn bug this closes. const subscriberId = ctx.sessionManager.getSessionId(); jobsOverlay ??= new JobsOverlay( resolvePipesProgressBarStyle(), { sendUserMessage: (content, options) => void pi.sendMessage( { customType: "pi-pipes:ci-completion", content, display: false }, { deliverAs: options.deliverAs, triggerTurn: options.triggerTurn }, ), }, undefined, subscriberId, () => ctx.isIdle(), ); jobsOverlay.setUI(ctx.ui); await jobsOverlay.refresh(); jobsOverlay.startPolling(); const target = resolveVehicleTarget(); if (!completionChannel && target) { completionChannel = connectCompletionChannel({ url: () => { const current = resolveVehicleTarget(); if (!current) throw new Error("Pipes daemon is not running"); const url = new URL(current.baseUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; url.pathname = "/push"; return url.toString(); }, token: target.token, topics: ["ci"], onMessage: (topic, payload) => { if (topic !== "ci") return; const completion = parseJobCompletionTransition(payload, subscriberId); if (completion) jobsOverlay?.notifyCompletion(completion); }, }); } }); pi.on("session_shutdown", async () => { completionChannel?.close(); completionChannel = undefined; jobsOverlay?.dispose(); }); // Pi awaits async extension factories before replaying the transcript, so // Vehicle renderers must be registered here rather than in session_start. await registerVehicle(pi); }