Skip to content

Commit 1145273

Browse files
edgeheroclaude
andcommitted
chore(plan-exec): 2026-07-21-admin-pi-extension — phase "Phase 3: admin workspace scaffold + reads" passed gates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3npQvQXLWsW6qsrRMAmNL
1 parent 864d81b commit 1145273

13 files changed

Lines changed: 1410 additions & 3 deletions

.pi/extensions/dispatch.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// pi-dispatch admin extension shim.
2+
//
3+
// pi loads this only after the operator trusts this checkout (docs/extensions.md
4+
// trust gating). A job container can never load it: the job loader sets
5+
// noExtensions:true and mounts only the serviced repo's /job/pi
6+
// (INT-SDK-SESSION-OPTIONS, REQ-ADMIN-VIA-PI-EXTENSION Scope).
7+
export { USED_API, SUPPORTED_PI_VERSION } from "../../admin/src/index.ts";
8+
export { default } from "../../admin/src/index.ts";

admin/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "@pi-dispatch/admin",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"test": "node --test \"test/*.test.mjs\""
8+
},
9+
"dependencies": {
10+
"@pi-dispatch/worker": "*"
11+
},
12+
"devDependencies": {
13+
"@earendil-works/pi-coding-agent": "0.80.7"
14+
}
15+
}

admin/src/index.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)