import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Container, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { BACKGROUND_ENTRY_TYPE, BACKGROUND_SETTLED_EVENT, BackgroundWatchManager, } from "./background.ts"; import { watchGithubActions } from "./github.ts"; import { emptyResultComponent, getWatchCard, isRunWatchDetails, WatchCardComponent, type WatchRendererState, } from "./renderer.ts"; import type { BackgroundWatchFinalEntry, BackgroundWatchSettledEvent, BackgroundWatchStartDetails, GithubActionsWatchInput, RunWatchDetails, } from "./types.ts"; const WatchParameters = Type.Object( { repo: Type.Optional(Type.String({ description: "GitHub repository in owner/repo form" })), branch: Type.Optional(Type.String({ description: "Branch whose head commit should be watched" })), run: Type.Optional( Type.String({ description: "Workflow run ID or full https://github.com/owner/repo/actions/runs/ID URL" }), ), tail: Type.Optional( Type.Integer({ description: "Failed-job log lines to retain inline (default 15, maximum 200)", minimum: 1, maximum: 200, }), ), background: Type.Optional( Type.Boolean({ description: "Run the watch in the background (default true): returns a watch ID immediately, shows live progress in a widget, and reports the final status back to the conversation when runs settle. Set false to block the turn with live inline updates.", }), ), }, { additionalProperties: false }, ); const StatusParameters = Type.Object( { watch_id: Type.Optional(Type.String({ description: "Background watch ID; omit to list all active watches" })), }, { additionalProperties: false }, ); const StopParameters = Type.Object( { watch_id: Type.Optional(Type.String({ description: "Background watch ID to stop" })), all: Type.Optional(Type.Boolean({ description: "Stop every active background watch" })), }, { additionalProperties: false }, ); function extractText(content: Array<{ type: string; text?: string }>): string { return content .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text) .join("\n") .trim(); } async function persistFailedLogs(content: string): Promise { const directory = await mkdtemp(join(tmpdir(), "pi-github-actions-watch-")); const path = join(directory, "failed-jobs.log"); await writeFile(path, content, { encoding: "utf8", mode: 0o600 }); return path; } function wantsBackground(input: { background?: boolean }): boolean { return input.background !== false; } function settledMessageContent(entry: BackgroundWatchFinalEntry): string { const lines = [`Background GitHub Actions watch ${entry.watchId} settled: ${entry.summary}`]; if (entry.error) lines.push(`Error: ${entry.error}`); lines.push("Report this workflow status to the user, and investigate the saved failed-job logs if anything failed."); return lines.join("\n"); } function statusLine(details: RunWatchDetails | undefined): string { if (!details) return "waiting for workflow data"; const jobs = details.runs.flatMap((run) => run.jobs); const completed = jobs.filter((job) => job.status === "completed").length; const target = details.mode === "run" && details.runs[0] ? `${details.repo} run #${details.runs[0].id}` : `${details.repo}@${details.headSha?.slice(0, 7) ?? "HEAD"}`; return `${target}: ${details.outcome} (${completed}/${jobs.length} jobs complete, poll ${details.pollCount})`; } export default function githubActionsWatchExtension(pi: ExtensionAPI): void { const host = { exec: (command: string, args: string[], options: { cwd: string; signal?: AbortSignal; timeout?: number }) => pi.exec(command, args, options), appendEntry(customType: string, data?: T): void { pi.appendEntry(customType, data); }, emitSettled(event: BackgroundWatchSettledEvent): void { pi.events.emit(BACKGROUND_SETTLED_EVENT, event); }, sendSettledMessage(entry: BackgroundWatchFinalEntry): void { pi.sendMessage( { customType: "github-actions-watch-settled", content: settledMessageContent(entry), display: false, details: entry, }, { deliverAs: "followUp", triggerTurn: true }, ); }, }; const background = new BackgroundWatchManager(host, { persistLogs: persistFailedLogs }); pi.registerEntryRenderer(BACKGROUND_ENTRY_TYPE, (entry, { expanded }, theme) => { const data = entry.data; const container = new Container(); if (!data) return container; container.addChild(new Text(theme.fg("dim", `Background Actions watch ${data.watchId}`), 0, 0)); const card = new WatchCardComponent(data.input, theme); card.update({ details: data.details, error: data.error, expanded, theme }); container.addChild(card); return container; }); pi.registerTool({ name: "github_actions_watch", label: "GitHub Run Watch", description: "Watch GitHub Actions using the authenticated gh CLI. Pass run for one run; otherwise watch every workflow associated with a branch head or the current commit. By default the watch runs in the background: it returns a watch ID immediately, streams progress to a widget, and reports the final status back to the conversation when runs settle. A watch settles only once every job in the watched runs has finished, so an early job failure never cuts off jobs that are still running. Set background=false to block the turn with live inline updates. Streams jobs and bounded failed-log tails, and saves complete failed-job logs to a temporary artifact.", promptSnippet: "Watch GitHub Actions runs and jobs in the background with live structured progress", promptGuidelines: [ "Use github_actions_watch when the user asks to watch, follow, or wait for GitHub Actions; use run for a specific run and omit run to watch all workflows for a commit. Watches run in the background by default and automatically report their final status to the conversation, so do not poll github_actions_watch_status in a loop; set background=false only when the user explicitly wants to block the turn while streaming the run.", ], parameters: WatchParameters, renderShell: "self", async execute(_toolCallId, params, signal, onUpdate, ctx) { const input: GithubActionsWatchInput = params; if (wantsBackground(params)) { const started = await background.start(input, ctx, signal); const action = started.deduplicated ? "Already watching" : "Started background watch"; return { content: [{ type: "text", text: `${action} ${started.watchId} (${started.targetKey}).` }], details: { kind: "background-start", watchId: started.watchId, targetKey: started.targetKey, deduplicated: started.deduplicated, }, }; } const result = await watchGithubActions( input, { cwd: ctx.cwd, signal, exec: (command, args, options) => pi.exec(command, args, options), persistLogs: persistFailedLogs, }, (details) => { const subject = details.mode === "run" && details.runs[0] ? `run #${details.runs[0].id}` : `${details.repo}@${details.headSha?.slice(0, 7) ?? "HEAD"}`; onUpdate?.({ content: [{ type: "text", text: `Watching GitHub Actions ${subject} (poll ${details.pollCount}).` }], details, }); }, ); return { content: [{ type: "text", text: result.summary }], details: result.details, }; }, renderCall(args, theme, context) { if (wantsBackground(args)) { return new Text( theme.fg("accent", theme.bold("GitHub Actions background watch")) + ` ${theme.fg("muted", args.run ?? args.branch ?? args.repo ?? "current HEAD")}`, 0, 0, ); } return getWatchCard(context.state, context.lastComponent, args, theme); }, renderResult(result, { expanded }, theme, context) { if (wantsBackground(context.args)) { const details = result.details as BackgroundWatchStartDetails | undefined; const text = context.isError ? extractText(result.content) || "Could not start GitHub Actions background watch" : details ? `${details.deduplicated ? "↪" : "✓"} ${details.watchId}` : extractText(result.content); return new Text(theme.fg(context.isError ? "error" : "success", text), 0, 0); } const hadCallCard = context.state.card !== undefined; const card = getWatchCard(context.state, undefined, context.args, theme); const details = isRunWatchDetails(result.details) ? result.details : undefined; const error = context.isError ? extractText(result.content) || "GitHub Actions watch failed" : undefined; const changed = card.update({ details, error, expanded, theme }); card.invalidate(); if (changed) context.invalidate(); if (!hadCallCard) return card; return emptyResultComponent(context.lastComponent); }, }); pi.registerTool({ name: "github_actions_watch_status", label: "GitHub Actions Watch Status", description: "Return concise state for one or all active background GitHub Actions watches.", parameters: StatusParameters, async execute(_toolCallId, params) { const snapshots = background.getSnapshots(params.watch_id); const text = snapshots.length === 0 ? params.watch_id ? `No active GitHub Actions background watch found for ${params.watch_id}.` : "No active GitHub Actions background watches." : snapshots.map((snapshot) => `${snapshot.watchId}: ${statusLine(snapshot.latestDetails)} [${snapshot.state}]`).join("\n"); return { content: [{ type: "text", text }], details: { watches: snapshots } }; }, }); pi.registerTool({ name: "github_actions_watch_stop", label: "Stop GitHub Actions Watch", description: "Stop one background GitHub Actions watch by ID, or explicitly stop all active watches.", parameters: StopParameters, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { if (params.all && params.watch_id) throw new Error("Pass watch_id or all=true, not both"); if (!params.all && !params.watch_id) throw new Error("Pass watch_id or set all=true"); const stopped = await background.stop({ watchId: params.watch_id, all: params.all }, ctx); const text = stopped.found ? `Cancelled GitHub Actions background watch${stopped.watchIds.length === 1 ? "" : "es"}: ${stopped.watchIds.join(", ")}.` : params.watch_id ? `No active GitHub Actions background watch found for ${params.watch_id}; no watches were cancelled.` : "No active GitHub Actions background watches to cancel."; return { content: [{ type: "text", text }], details: stopped }; }, }); pi.on("session_shutdown", async (_event, ctx) => { await background.shutdown(ctx); }); }