|
| 1 | +/** |
| 2 | + * pi-dispatch admin extension. |
| 3 | + * |
| 4 | + * A pi extension that adds a `/dispatch` command for operating a pi-dispatch |
| 5 | + * deployment (status, pause, resume, runs, logs, budget, triggers, settings). |
| 6 | + * |
| 7 | + * Loading: the operator's own pi supplies `ExtensionAPI` at runtime. Three ways |
| 8 | + * to load it, all on the operator's host: |
| 9 | + * - `pi -e admin/src/index.ts` (explicit, one session) |
| 10 | + * - an entry in the `extensions` array of `~/.pi/agent/settings.json` |
| 11 | + * - the in-repo `.pi/extensions/dispatch.ts` shim, which pi loads only after |
| 12 | + * the operator trusts this checkout (trust gating) |
| 13 | + * |
| 14 | + * A job container can never load this: the job loader sets `noExtensions: true` |
| 15 | + * and mounts only the serviced repo's /job/pi. |
| 16 | + * |
| 17 | + * The extension is a thin channel over the read-model and the renderers: it |
| 18 | + * parses the subcommand, calls `read-model.mjs` for data and `render.mjs` for |
| 19 | + * text, and picks the output channel. PII-free records go to `sendMessage` |
| 20 | + * (they may enter later model context, which is accepted per REQ); raw `.log` |
| 21 | + * bytes go ONLY to the overlay viewer, never to a message. |
| 22 | + * |
| 23 | + * Supported pi version: 0.80.7. The factory registers nothing unless every API |
| 24 | + * member it consumes is present; on a miss it names the member and the |
| 25 | + * supported version on stderr and returns. |
| 26 | + */ |
| 27 | +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; |
| 28 | +import { createRequire } from "node:module"; |
| 29 | +import { |
| 30 | + resolvePaths, |
| 31 | + readQueueState, |
| 32 | + readSchedulers, |
| 33 | + readBudget, |
| 34 | + listRuns, |
| 35 | + readLogTail, |
| 36 | + readSettingsView, |
| 37 | + readFlows, |
| 38 | + listRunIds, |
| 39 | +} from "./read-model.mjs"; |
| 40 | +import { renderStatus, renderRuns, renderBudget, renderTriggers, renderSettingsView } from "./render.mjs"; |
| 41 | + |
| 42 | +// The single source of truth for the ExtensionAPI surface this extension |
| 43 | +// consumes. It grows only when a task actually uses a new member. |
| 44 | +export const USED_API = ["registerCommand", "sendMessage"] as const; |
| 45 | + |
| 46 | +export const SUPPORTED_PI_VERSION = "0.80.7"; |
| 47 | + |
| 48 | +const CHANNEL = "pi-dispatch-admin"; |
| 49 | + |
| 50 | +const USAGE = |
| 51 | + "usage: /dispatch <status|pause|resume|runs|logs|budget|triggers|settings|set|unset>"; |
| 52 | + |
| 53 | +const KNOWN_SUBCOMMANDS = [ |
| 54 | + "status", |
| 55 | + "pause", |
| 56 | + "resume", |
| 57 | + "runs", |
| 58 | + "logs", |
| 59 | + "budget", |
| 60 | + "triggers", |
| 61 | + "settings", |
| 62 | + "set", |
| 63 | + "unset", |
| 64 | +] as const; |
| 65 | + |
| 66 | +// Commands parked for a later slice (4.1): mutation of queue state and settings. |
| 67 | +const NOT_YET_IMPLEMENTED = new Set(["pause", "resume", "set", "unset"]); |
| 68 | + |
| 69 | +export default function admin(pi: ExtensionAPI): void { |
| 70 | + for (const member of USED_API) { |
| 71 | + if (typeof (pi as Record<string, unknown>)[member] !== "function") { |
| 72 | + console.error( |
| 73 | + `[pi-dispatch/admin] refusing to load: pi is missing '${member}'. ` + |
| 74 | + `This extension supports pi ${SUPPORTED_PI_VERSION}.`, |
| 75 | + ); |
| 76 | + return; |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + pi.registerCommand("dispatch", { |
| 81 | + description: |
| 82 | + "pi-dispatch admin: status|pause|resume|runs|logs|budget|triggers|settings|set|unset", |
| 83 | + getArgumentCompletions: (prefix) => completeArguments(prefix), |
| 84 | + handler: async (args, ctx) => dispatch(pi, args, ctx), |
| 85 | + }); |
| 86 | +} |
| 87 | + |
| 88 | +async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void> { |
| 89 | + const notify = ctx?.ui?.notify?.bind(ctx.ui); |
| 90 | + const tokens = args.trim().split(/\s+/).filter(Boolean); |
| 91 | + const sub = tokens[0] ?? ""; |
| 92 | + const paths = resolvePaths(process.env); |
| 93 | + |
| 94 | + if (sub === "") { |
| 95 | + notify?.(USAGE, "info"); |
| 96 | + return; |
| 97 | + } |
| 98 | + |
| 99 | + switch (sub) { |
| 100 | + case "status": { |
| 101 | + const [queue, budget] = await Promise.all([ |
| 102 | + readQueueState({ url: paths.valkeyUrl }), |
| 103 | + readBudget({ url: paths.valkeyUrl }), |
| 104 | + ]); |
| 105 | + const settings = readSettingsView({ settingsFile: paths.settingsFile }); |
| 106 | + send(pi, `${renderStatus(queue)}\n${renderBudget({ budget, settings })}`); |
| 107 | + return; |
| 108 | + } |
| 109 | + case "runs": { |
| 110 | + const limit = tokens[1] ? Number(tokens[1]) : undefined; |
| 111 | + send(pi, renderRuns(listRuns({ logsDir: paths.logsDir, limit }))); |
| 112 | + return; |
| 113 | + } |
| 114 | + case "budget": { |
| 115 | + const budget = await readBudget({ url: paths.valkeyUrl }); |
| 116 | + const settings = readSettingsView({ settingsFile: paths.settingsFile }); |
| 117 | + send(pi, renderBudget({ budget, settings })); |
| 118 | + return; |
| 119 | + } |
| 120 | + case "triggers": { |
| 121 | + const schedulers = await readSchedulers({ url: paths.valkeyUrl }); |
| 122 | + const flows = readFlows({ flowsPath: paths.flowsPath }); |
| 123 | + send(pi, renderTriggers({ schedulers, flows })); |
| 124 | + return; |
| 125 | + } |
| 126 | + case "settings": { |
| 127 | + send(pi, renderSettingsView(readSettingsView({ settingsFile: paths.settingsFile }))); |
| 128 | + return; |
| 129 | + } |
| 130 | + case "logs": |
| 131 | + await showLogs(paths.logsDir, tokens, ctx); |
| 132 | + return; |
| 133 | + default: |
| 134 | + if (NOT_YET_IMPLEMENTED.has(sub)) { |
| 135 | + notify?.(`dispatch ${sub}: not yet implemented`, "info"); |
| 136 | + return; |
| 137 | + } |
| 138 | + notify?.(`dispatch: unknown subcommand '${sub}'. ${USAGE}`, "warning"); |
| 139 | + return; |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +/** |
| 144 | + * The model-visible channel for the PII-free structured views. `display: true` shows the text; the empty |
| 145 | + * options object is deliberate -- NEVER `triggerTurn`, which would spend a paid turn just to observe state. |
| 146 | + */ |
| 147 | +function send(pi: ExtensionAPI, content: string): void { |
| 148 | + pi.sendMessage({ customType: CHANNEL, content, display: true }, {}); |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Show a job's raw `.log` in the overlay viewer, and ONLY there. Raw container output is untrusted and |
| 153 | + * PII-bearing, so it must never enter model context: if the pi build has no `ctx.ui.custom`, this fails |
| 154 | + * LOUD (an error notification, or console.error) and returns -- it never falls back to `sendMessage`, |
| 155 | + * which would leak the bytes into context, and never silently no-ops, which would fake "no logs". |
| 156 | + */ |
| 157 | +async function showLogs(logsDir: string, tokens: string[], ctx: any): Promise<void> { |
| 158 | + const notify = ctx?.ui?.notify?.bind(ctx.ui); |
| 159 | + const jobId = tokens[1]; |
| 160 | + if (!jobId) { |
| 161 | + notify?.("usage: /dispatch logs <jobId> [lines]", "warning"); |
| 162 | + return; |
| 163 | + } |
| 164 | + const lines = tokens[2] ? Number(tokens[2]) : undefined; |
| 165 | + const tail = readLogTail({ logsDir, jobId, lines }); |
| 166 | + |
| 167 | + const custom = ctx?.ui?.custom; |
| 168 | + if (typeof custom !== "function") { |
| 169 | + const message = "logs viewer unavailable in this pi version -- raw logs are never sent to the model"; |
| 170 | + if (notify) notify(message, "error"); |
| 171 | + else console.error(`[pi-dispatch/admin] ${message}`); |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + await custom.call(ctx.ui, makeLogViewer(jobId, tail), { overlay: true }); |
| 176 | +} |
| 177 | + |
| 178 | +const VIEWPORT_LINES = 20; |
| 179 | + |
| 180 | +/** |
| 181 | + * Build the scrollable log-viewer factory for `ctx.ui.custom`. The component renders a bounded window of |
| 182 | + * the tail with a title, and scrolls on up/down/pageUp/pageDown; escape closes via `done`. A missing log |
| 183 | + * renders a capture-off note rather than an empty view. The lines live only in this closure -- there is no |
| 184 | + * path from here to `sendMessage`. |
| 185 | + */ |
| 186 | +function makeLogViewer(jobId: string, tail: { lines?: string[]; missing?: boolean }) { |
| 187 | + const missing = tail.missing === true; |
| 188 | + const lines = missing ? [] : tail.lines ?? []; |
| 189 | + const maxTop = () => Math.max(0, lines.length - VIEWPORT_LINES); |
| 190 | + let top = 0; |
| 191 | + |
| 192 | + return (_tui: any, _theme: any, _keybindings: any, done: (value: void) => void) => { |
| 193 | + const component = { |
| 194 | + render(_width: number): string[] { |
| 195 | + if (missing) { |
| 196 | + return [`logs ${jobId} -- no captured log (PI_CAPTURE_JOB_LOGS off or not found). Esc to close.`, ""]; |
| 197 | + } |
| 198 | + const out = [`logs ${jobId} -- ${lines.length} line(s). Up/Down scroll, PgUp/PgDn page, Esc close.`, ""]; |
| 199 | + for (const line of lines.slice(top, top + VIEWPORT_LINES)) out.push(line); |
| 200 | + out.push("", `[${Math.min(top + VIEWPORT_LINES, lines.length)}/${lines.length}]`); |
| 201 | + return out; |
| 202 | + }, |
| 203 | + invalidate(): void { |
| 204 | + // No cached render state to clear; the TUI redraws from render(). |
| 205 | + }, |
| 206 | + handleInput(data: string): void { |
| 207 | + if (matchesKey(data, "escape")) { |
| 208 | + done(undefined); |
| 209 | + return; |
| 210 | + } |
| 211 | + if (missing) return; |
| 212 | + if (matchesKey(data, "up")) top = Math.max(0, top - 1); |
| 213 | + else if (matchesKey(data, "down")) top = Math.min(maxTop(), top + 1); |
| 214 | + else if (matchesKey(data, "pageUp")) top = Math.max(0, top - VIEWPORT_LINES); |
| 215 | + else if (matchesKey(data, "pageDown")) top = Math.min(maxTop(), top + VIEWPORT_LINES); |
| 216 | + component.invalidate(); |
| 217 | + }, |
| 218 | + }; |
| 219 | + return component; |
| 220 | + }; |
| 221 | +} |
| 222 | + |
| 223 | +/** |
| 224 | + * Argument completion: the first token completes against the subcommand names; `logs <partial>` completes |
| 225 | + * against the run ids present on disk. Returns null (not []) when there is nothing to offer. |
| 226 | + */ |
| 227 | +function completeArguments(prefix: string) { |
| 228 | + const parts = prefix.trimStart().split(/\s+/); |
| 229 | + if (parts.length <= 1) { |
| 230 | + const token = parts[0] ?? ""; |
| 231 | + const items = KNOWN_SUBCOMMANDS.filter((s) => s.startsWith(token)).map((s) => ({ value: s, label: s })); |
| 232 | + return items.length > 0 ? items : null; |
| 233 | + } |
| 234 | + if (parts[0] === "logs" && parts.length === 2) { |
| 235 | + const partial = parts[1]; |
| 236 | + const ids = listRunIds({ logsDir: resolvePaths(process.env).logsDir }); |
| 237 | + const items = ids |
| 238 | + .filter((id) => id.startsWith(partial)) |
| 239 | + .map((id) => ({ value: `logs ${id}`, label: id })); |
| 240 | + return items.length > 0 ? items : null; |
| 241 | + } |
| 242 | + return null; |
| 243 | +} |
| 244 | + |
| 245 | +/** |
| 246 | + * Match a raw terminal input string against a named key. Prefers pi's own `matchesKey` (resolved from |
| 247 | + * pi-tui via the pinned pi package, since pi-tui is nested there, not hoisted) to stay faithful to pi's |
| 248 | + * key decoding; falls back to a small legacy-sequence matcher for the keys the viewer uses if resolution |
| 249 | + * is unavailable. Memoized so resolution runs at most once. |
| 250 | + */ |
| 251 | +let cachedMatchesKey: ((data: string, keyId: string) => boolean) | null = null; |
| 252 | + |
| 253 | +function matchesKey(data: string, keyId: string): boolean { |
| 254 | + if (cachedMatchesKey === null) cachedMatchesKey = resolveMatchesKey(); |
| 255 | + return cachedMatchesKey(data, keyId); |
| 256 | +} |
| 257 | + |
| 258 | +function resolveMatchesKey(): (data: string, keyId: string) => boolean { |
| 259 | + try { |
| 260 | + const piRequire = createRequire(import.meta.resolve("@earendil-works/pi-coding-agent")); |
| 261 | + const tui = piRequire("@earendil-works/pi-tui"); |
| 262 | + if (typeof tui.matchesKey === "function") return tui.matchesKey; |
| 263 | + } catch { |
| 264 | + // fall through to the local matcher |
| 265 | + } |
| 266 | + return localMatchesKey; |
| 267 | +} |
| 268 | + |
| 269 | +function localMatchesKey(data: string, keyId: string): boolean { |
| 270 | + switch (keyId) { |
| 271 | + case "escape": |
| 272 | + return data === "\x1b"; |
| 273 | + case "up": |
| 274 | + return data === "\x1b[A" || data === "\x1bOA"; |
| 275 | + case "down": |
| 276 | + return data === "\x1b[B" || data === "\x1bOB"; |
| 277 | + case "pageUp": |
| 278 | + return data === "\x1b[5~"; |
| 279 | + case "pageDown": |
| 280 | + return data === "\x1b[6~"; |
| 281 | + default: |
| 282 | + return false; |
| 283 | + } |
| 284 | +} |
0 commit comments