From b3bfeb34b9365d76ab45ec0d4fe55fab917b5fd5 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Thu, 6 Aug 2026 00:13:04 -0700 Subject: [PATCH 1/2] Publish contribution API and invalidation --- docs/pi-web-extensions.md | 28 ++ examples/pi-web-extensions/notepad.ts | 647 ++++++++++++++++++++++++++ server.ts | 9 + server/extensions/webUi.ts | 157 ++++--- src/extensions.ts | 36 ++ src/extensions/webPanels.ts | 4 + src/git/panel.ts | 4 + src/main.ts | 4 + src/realtime/realtime.ts | 9 +- tests/e2e/pi-web.spec.ts | 4 +- tests/e2e/web-panel.spec.ts | 10 +- tests/extensions.test.ts | 43 ++ 12 files changed, 897 insertions(+), 58 deletions(-) create mode 100644 examples/pi-web-extensions/notepad.ts diff --git a/docs/pi-web-extensions.md b/docs/pi-web-extensions.md index ba48a80..c62013d 100644 --- a/docs/pi-web-extensions.md +++ b/docs/pi-web-extensions.md @@ -63,6 +63,34 @@ pi-web-only extensions are loaded from: These are separate from regular pi extension locations on purpose. A pi-web extension can use HTML and browser-specific APIs without promising that the same UI works in the terminal TUI. +## Contribution API + +`ctx.ui.web.contribute(key, spec)` is the canonical API for browser surfaces. Specs use an explicit `slot` and `kind`; pi-web normalizes them immediately into versioned descriptors. Passing `undefined` clears every contribution registered under that key. + +```ts +ctx.ui.web.contribute("worker-status", { + slot: "panel", + kind: "rendered", + title: "Worker status", + render: async (event) => ({ + html: ``, + }), +}); +``` + +Rendered contributions receive the shared `{ action, payload, fields, context }` event envelope. Static contributions currently support the `footer` and `fab` slots; rendered contributions support `header-action`, `artifact-action`, `git-tab`, and `panel`. + +When backing data changes without a browser interaction, call `ctx.ui.web.update(key)`. pi-web emits a lightweight invalidation and an active panel or Git tab pulls a fresh render. Updates for hidden surfaces do no work; they render when next opened. + +```ts +revision += 1; +ctx.ui.web.update("worker-status"); +``` + +The typed `setFooter`, `setHeaderAction`, `setArtifactAction`, `setGitTab`, `setPanel`, and `setFabAction` methods remain supported convenience wrappers over this registry. + +The [global notepad example](../examples/pi-web-extensions/notepad.ts) demonstrates a rendered panel, explicit FAB launcher, persisted cross-session data, and `update()` invalidation across every live session. + ## Footer API `ctx.ui.web.setFooter(key, footer)` sets a footer region between the composer and pinned session tabs. Multiple extensions can set independent footer regions by using different keys. diff --git a/examples/pi-web-extensions/notepad.ts b/examples/pi-web-extensions/notepad.ts new file mode 100644 index 0000000..ca74e15 --- /dev/null +++ b/examples/pi-web-extensions/notepad.ts @@ -0,0 +1,647 @@ +/** + * global-notepad — installable pi-web extension (opt-in) + * + * A persistent, machine-global day planner shared by every pi-web conversation, + * stored as structured entries in ~/.pi/agent/notepad.json (override with + * PI_WEB_NOTEPAD_FILE). Delete this file from your extensions directory to + * remove the feature entirely; nothing notepad-specific lives in core pi-web. + * + * Design principles: + * - Provenance: every entry records who wrote it (user or agent), from which + * session, in which project, and when. The panel links entries back to the + * conversation that created them. + * - Careful context: the model does NOT see notepad contents automatically. + * Its only default system-prompt footprint is the tool's own one-line + * snippet. An optional settings toggle (default OFF) can share pinned + * entries with the model. + * - Anti-mess: lifecycle (open/done/dropped), auto-archive of old closed + * entries, duplicate detection, and caps keep the active set small. + */ + +import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { Type } from "typebox"; +import { StringEnum } from "@earendil-works/pi-ai"; +import type { PiWebExtensionAPI, PiWebPanelEvent, PiWebPanelView } from "@ashwin-pc/pi-web/extensions"; + +const PANEL_KEY = "global-notepad"; +const SETTINGS_ID = "global-notepad.settings"; +const MAX_TEXT_CHARS = 2_000; +const MAX_ACTIVE_ENTRIES = 200; +const MAX_TAGS = 8; +const ARCHIVE_AFTER_DAYS = 7; +const MAX_PINNED_IN_PROMPT = 10; + +type EntryKind = "task" | "note" | "decision"; +type EntryStatus = "open" | "done" | "dropped"; + +type NotepadEntry = { + id: string; + text: string; + kind: EntryKind; + status: EntryStatus; + pinned: boolean; + tags: string[]; + due?: string; + created: string; + updated: string; + source: { by: "user" | "agent"; sessionId?: string; sessionName?: string; cwd?: string }; +}; + +type NotepadStore = { version: 1; entries: NotepadEntry[] }; + +function storePath() { + return process.env.PI_WEB_NOTEPAD_FILE || join(homedir(), ".pi", "agent", "notepad.json"); +} + +function archivePath() { + return storePath().replace(/\.json$/, "") + "-archive.jsonl"; +} + +function legacyMarkdownPath() { + return join(dirname(storePath()), "notepad.md"); +} + +function describeError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +// Serialize read-modify-write cycles within this server process so concurrent +// sessions cannot drop each other's changes. +let noteQueue: Promise = Promise.resolve(); +function withNoteQueue(work: () => Promise): Promise { + const next = noteQueue.then(work, work); + noteQueue = next.catch(() => undefined); + return next; +} + +function generateId(existing: Set) { + for (let attempt = 0; attempt < 20; attempt++) { + const id = `n-${randomBytes(3).toString("hex")}`; + if (!existing.has(id)) return id; + } + return `n-${randomBytes(6).toString("hex")}`; +} + +function normalizeEntry(raw: unknown): NotepadEntry | undefined { + if (!raw || typeof raw !== "object") return undefined; + const entry = raw as Record; + if (typeof entry.id !== "string" || typeof entry.text !== "string") return undefined; + const source = entry.source && typeof entry.source === "object" ? entry.source : {}; + return { + id: entry.id, + text: String(entry.text).slice(0, MAX_TEXT_CHARS), + kind: ["task", "note", "decision"].includes(entry.kind) ? entry.kind : "note", + status: ["open", "done", "dropped"].includes(entry.status) ? entry.status : "open", + pinned: Boolean(entry.pinned), + tags: Array.isArray(entry.tags) ? entry.tags.filter((tag: unknown) => typeof tag === "string").slice(0, MAX_TAGS) : [], + due: typeof entry.due === "string" && /^\d{4}-\d{2}-\d{2}$/.test(entry.due) ? entry.due : undefined, + created: typeof entry.created === "string" ? entry.created : new Date().toISOString(), + updated: typeof entry.updated === "string" ? entry.updated : new Date().toISOString(), + source: { + by: source.by === "agent" ? "agent" : "user", + sessionId: typeof source.sessionId === "string" ? source.sessionId : undefined, + sessionName: typeof source.sessionName === "string" ? source.sessionName : undefined, + cwd: typeof source.cwd === "string" ? source.cwd : undefined, + }, + }; +} + +async function loadStore(): Promise { + try { + const raw = JSON.parse(await readFile(storePath(), "utf8")); + const entries = Array.isArray(raw?.entries) + ? raw.entries.map(normalizeEntry).filter((entry: NotepadEntry | undefined): entry is NotepadEntry => Boolean(entry)) + : []; + return { version: 1, entries }; + } catch (error: any) { + if (error?.code === "ENOENT") return importLegacyMarkdown(); + // A corrupt store must not be silently overwritten: keep the bytes aside. + try { await rename(storePath(), `${storePath()}.corrupt-${Date.now()}`); } catch { /* best effort */ } + return { version: 1, entries: [] }; + } +} + +/** One-time import of the earlier flat-file notepad, kept in place afterward. */ +async function importLegacyMarkdown(): Promise { + try { + const text = (await readFile(legacyMarkdownPath(), "utf8")).trim(); + if (!text) return { version: 1, entries: [] }; + const now = new Date().toISOString(); + return { + version: 1, + entries: [{ + id: generateId(new Set()), + text: text.slice(0, MAX_TEXT_CHARS), + kind: "note", + status: "open", + pinned: false, + tags: ["imported"], + created: now, + updated: now, + source: { by: "user" }, + }], + }; + } catch { + return { version: 1, entries: [] }; + } +} + +const storeInvalidators = new Set<() => void>(); +const invalidatorByWebUi = new WeakMap void>(); + +async function saveStore(store: NotepadStore) { + const path = storePath(); + await mkdir(dirname(path), { recursive: true }); + const archived = await archiveOldEntries(store); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporaryPath, `${JSON.stringify({ version: 1, entries: store.entries }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } + for (const invalidate of storeInvalidators) invalidate(); + return archived; +} + +async function archiveOldEntries(store: NotepadStore) { + const cutoff = Date.now() - ARCHIVE_AFTER_DAYS * 24 * 60 * 60 * 1000; + const keep: NotepadEntry[] = []; + const archive: NotepadEntry[] = []; + for (const entry of store.entries) { + const closed = entry.status !== "open"; + if (closed && Date.parse(entry.updated) < cutoff) archive.push(entry); + else keep.push(entry); + } + if (archive.length) { + await appendFile(archivePath(), archive.map((entry) => JSON.stringify(entry)).join("\n") + "\n", { encoding: "utf8", mode: 0o600 }); + store.entries = keep; + } + return archive.length; +} + +function normalizedText(text: string) { + return text.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function findDuplicate(store: NotepadStore, text: string) { + const needle = normalizedText(text); + return store.entries.find((entry) => entry.status !== "dropped" && normalizedText(entry.text) === needle); +} + +function cleanTags(value: unknown): string[] { + const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; + const tags = raw + .map((tag) => String(tag).trim().replace(/^#/, "").slice(0, 32)) + .filter(Boolean); + return [...new Set(tags)].slice(0, MAX_TAGS); +} + +function cleanDue(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + const due = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(due)) throw new Error('due must be formatted as "YYYY-MM-DD"'); + return due; +} + +function today() { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +function dueRank(entry: NotepadEntry) { + if (!entry.due) return 3; + if (entry.due < today()) return 0; + if (entry.due === today()) return 1; + return 2; +} + +function sortEntries(entries: NotepadEntry[]) { + return [...entries].sort((a, b) => + Number(b.pinned) - Number(a.pinned) + || dueRank(a) - dueRank(b) + || (a.due && b.due && a.due !== b.due ? a.due.localeCompare(b.due) : 0) + || b.updated.localeCompare(a.updated)); +} + +function relativeTime(iso: string) { + const ms = Date.now() - Date.parse(iso); + if (!Number.isFinite(ms) || ms < 0) return "now"; + const minutes = Math.floor(ms / 60_000); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return days < 30 ? `${days}d ago` : iso.slice(0, 10); +} + +function requireEntry(store: NotepadStore, id: unknown) { + const entry = store.entries.find((candidate) => candidate.id === String(id || "").trim()); + if (!entry) throw new Error(`No notepad entry with id "${String(id)}". Use action "list" to see current ids.`); + return entry; +} + +function touch(entry: NotepadEntry) { + entry.updated = new Date().toISOString(); +} + +// --- Tool --- + +const toolParameters = Type.Object({ + action: StringEnum(["add", "list", "done", "drop", "edit", "pin", "unpin"] as const), + id: Type.Optional(Type.String({ description: "Entry id (from list), required for done/drop/edit/pin/unpin" })), + text: Type.Optional(Type.String({ description: "Entry text (required for add, optional for edit)" })), + kind: Type.Optional(StringEnum(["task", "note", "decision"] as const)), + tags: Type.Optional(Type.Array(Type.String())), + due: Type.Optional(Type.String({ description: 'Optional due date, "YYYY-MM-DD"' })), + query: Type.Optional(Type.String({ description: "For list: case-insensitive text/tag filter" })), + status: Type.Optional(StringEnum(["open", "done", "dropped", "all"] as const)), +}); + +function formatEntryLine(entry: NotepadEntry) { + const marker = entry.status === "open" ? (entry.kind === "task" ? "☐" : "·") : entry.status === "done" ? "✓" : "✗"; + const pieces = [ + `${entry.id} ${entry.pinned ? "📌 " : ""}${marker} [${entry.kind}] ${entry.text}`, + entry.due ? `(due ${entry.due})` : "", + entry.tags.length ? entry.tags.map((tag) => `#${tag}`).join(" ") : "", + `— ${entry.source.by}${entry.source.sessionName ? ` in "${entry.source.sessionName}"` : ""}, ${relativeTime(entry.updated)}`, + ]; + return pieces.filter(Boolean).join(" "); +} + +function entrySessionRefs(entries: NotepadEntry[]) { + const seen = new Map(); + for (const entry of entries) { + const sessionId = entry.source.sessionId; + if (sessionId && !seen.has(sessionId)) seen.set(sessionId, { sessionId, name: entry.source.sessionName }); + } + return [...seen.values()].slice(0, 8); +} + +// --- Panel (trusted extension HTML rendered by core pi-web) --- + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (character) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[character]!)); +} + +function firstField(event: PiWebPanelEvent | undefined, name: string) { + const value = event?.fields?.[name]; + return Array.isArray(value) ? value[0] : value; +} + +function payloadId(event?: PiWebPanelEvent) { + const payload = event?.payload; + if (payload && typeof payload === "object" && typeof (payload as any).id === "string") return (payload as any).id as string; + throw new Error("Missing entry id"); +} + +const panelStyles = ` +`; + +function renderEntryRow(entry: NotepadEntry) { + const isOpen = entry.status === "open"; + const dueClass = entry.due && entry.due < today() ? "gnpOverdue" : entry.due === today() ? "gnpDueSoon" : ""; + const payload = escapeHtml(JSON.stringify({ id: entry.id })); + const provenance = `${entry.source.by === "agent" ? "🤖 agent" : "👤 you"}${entry.source.cwd ? ` · ${escapeHtml(entry.source.cwd.split("/").at(-1) || "")}` : ""}`; + const sourceLink = entry.source.sessionId + ? `${escapeHtml(entry.source.sessionName || "source session")}` + : ""; + return `
  • + ${entry.kind === "task" + ? `` + : ``} +
    +
    ${escapeHtml(entry.text)}
    +
    + ${entry.due ? `due ${escapeHtml(entry.due)}` : ""} + ${entry.tags.map((tag) => `#${escapeHtml(tag)}`).join("")} + ${provenance}${sourceLink ? ` · ${sourceLink}` : ""} · ${escapeHtml(relativeTime(entry.updated))} +
    +
    +
    + + + ${isOpen ? `` : ""} +
    +
  • `; +} + +function renderSection(title: string, entries: NotepadEntry[]) { + if (!entries.length) return ""; + return `

    ${escapeHtml(title)}

      ${entries.map(renderEntryRow).join("")}
    `; +} + +function renderPanel(store: NotepadStore, options: { status?: string; query?: string } = {}): PiWebPanelView { + const query = (options.query || "").trim().toLowerCase(); + const matches = (entry: NotepadEntry) => !query + || entry.text.toLowerCase().includes(query) + || entry.tags.some((tag) => tag.toLowerCase().includes(query)); + const active = sortEntries(store.entries.filter((entry) => entry.status === "open" && matches(entry))); + const closed = sortEntries(store.entries.filter((entry) => entry.status !== "open" && matches(entry))); + + const pinned = active.filter((entry) => entry.pinned); + const tasks = active.filter((entry) => !entry.pinned && entry.kind === "task"); + const other = active.filter((entry) => !entry.pinned && entry.kind !== "task"); + + const html = `${panelStyles} +
    +
    + + + +
    +
    + + + +
    +
    ${escapeHtml(options.status || "")}
    + ${active.length === 0 ? `
    ${query ? "Nothing matches this filter." : "Nothing here yet. Notes added by you or by agents in any conversation show up in this one shared planner."}
    ` : ""} + ${renderSection("📌 Pinned", pinned)} + ${renderSection("☐ Tasks", tasks)} + ${renderSection("🗒 Notes & decisions", other)} + ${closed.length ? `
    Recently closed (${closed.length})
      ${closed.map(renderEntryRow).join("")}
    ` : ""} +
    `; + return { title: "Global notepad", html }; +} + +function renderEditForm(entry: NotepadEntry): PiWebPanelView { + const payload = escapeHtml(JSON.stringify({ id: entry.id })); + const html = `${panelStyles} +
    +

    Edit ${escapeHtml(entry.id)}

    +
    + + + +
    + + +
    +
    +
    `; + return { title: "Global notepad — edit", html }; +} + +// --- Extension wiring --- + +export default function globalNotepad(pi: PiWebExtensionAPI) { + const sessionSource = (ctx: { sessionManager?: any; cwd?: string }) => ({ + by: "agent" as const, + sessionId: String(ctx.sessionManager?.getSessionId?.() || "") || undefined, + sessionName: (typeof (pi as any).getSessionName === "function" ? (pi as any).getSessionName() : undefined) || undefined, + cwd: ctx.cwd, + }); + + pi.registerTool({ + name: "notepad", + label: "Notepad", + description: [ + "The user's global notepad: a persistent day planner of tasks, notes, and decisions shared across ALL conversations and projects, with provenance (who added what, from which session).", + 'Contents are NOT loaded automatically — use action "list" first when earlier notes may matter.', + 'Actions: "add" (text, kind, tags, due), "list" (query/status filters), "done"/"drop" (close an entry by id), "edit" (change text/tags/due), "pin"/"unpin" (mark importance).', + ].join(" "), + promptSnippet: "Read or update the user's persistent cross-conversation notepad (day planner)", + promptGuidelines: [ + "Use the notepad tool when the user asks to note, track, or remember something beyond this conversation, or refers to earlier notes or plans — list entries first; do not check it routinely.", + ], + parameters: toolParameters, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (params.action === "list") { + const store = await loadStore(); + const status = params.status || "open"; + const query = (params.query || "").trim().toLowerCase(); + const entries = sortEntries(store.entries.filter((entry) => + (status === "all" || entry.status === status) + && (!query || entry.text.toLowerCase().includes(query) || entry.tags.some((tag) => tag.toLowerCase().includes(query))))); + if (!entries.length) { + return { content: [{ type: "text", text: query ? `No ${status} entries match "${params.query}".` : `The notepad has no ${status === "all" ? "" : `${status} `}entries.` }], details: {} }; + } + const lines = entries.slice(0, 100).map(formatEntryLine); + const summary = `${entries.length} ${status === "all" ? "" : `${status} `}entr${entries.length === 1 ? "y" : "ies"}${entries.length > 100 ? " (showing first 100)" : ""}:`; + return { + content: [{ type: "text", text: [summary, ...lines].join("\n") }], + details: { count: entries.length, sessions: entrySessionRefs(entries.slice(0, 100)) }, + }; + } + + return withNoteQueue(async () => { + const store = await loadStore(); + + if (params.action === "add") { + const text = (params.text || "").trim(); + if (!text) throw new Error('text is required for action "add"'); + if (text.length > MAX_TEXT_CHARS) throw new Error(`Entry text is limited to ${MAX_TEXT_CHARS.toLocaleString()} characters; keep entries short and specific.`); + const duplicate = findDuplicate(store, text); + if (duplicate) { + return { content: [{ type: "text", text: `Not added: an equivalent entry already exists — ${formatEntryLine(duplicate)}. Use "edit" or "done" on ${duplicate.id} instead.` }], details: { duplicateOf: duplicate.id } }; + } + if (store.entries.filter((entry) => entry.status === "open").length >= MAX_ACTIVE_ENTRIES) { + throw new Error(`The notepad already has ${MAX_ACTIVE_ENTRIES} open entries. List them and close or consolidate stale ones first.`); + } + const now = new Date().toISOString(); + const entry: NotepadEntry = { + id: generateId(new Set(store.entries.map((candidate) => candidate.id))), + text, + kind: params.kind || "task", + status: "open", + pinned: false, + tags: cleanTags(params.tags), + due: cleanDue(params.due), + created: now, + updated: now, + source: sessionSource(ctx as any), + }; + store.entries.push(entry); + await saveStore(store); + return { content: [{ type: "text", text: `Added ${formatEntryLine(entry)}` }], details: { id: entry.id } }; + } + + const entry = requireEntry(store, params.id); + if (params.action === "done" || params.action === "drop") { + entry.status = params.action === "done" ? "done" : "dropped"; + entry.pinned = false; + } else if (params.action === "pin" || params.action === "unpin") { + entry.pinned = params.action === "pin"; + } else { + if (params.text !== undefined) { + const text = params.text.trim(); + if (!text) throw new Error("text cannot be empty"); + entry.text = text.slice(0, MAX_TEXT_CHARS); + } + if (params.kind !== undefined) entry.kind = params.kind; + if (params.tags !== undefined) entry.tags = cleanTags(params.tags); + if (params.due !== undefined) entry.due = cleanDue(params.due); + } + touch(entry); + await saveStore(store); + return { content: [{ type: "text", text: `Updated ${formatEntryLine(entry)}` }], details: { id: entry.id } }; + }); + }, + }); + + pi.on("session_start", async (_event, ctx) => { + await ctx.ui.web.registerSettings({ + id: SETTINGS_ID, + title: "Global notepad", + schemaVersion: 1, + fields: [{ + key: "pinnedInPrompt", + type: "toggle", + label: "Share pinned entries with the model", + description: "When on, pinned notepad entries are appended to the system prompt of every conversation. Off by default: the model then only knows the notepad exists and reads it on demand.", + default: false, + }], + }); + + const invalidate = () => ctx.ui.web.update(PANEL_KEY); + storeInvalidators.add(invalidate); + invalidatorByWebUi.set(ctx.ui.web, invalidate); + ctx.ui.web.contribute(PANEL_KEY, { + slot: "panel", + kind: "rendered", + title: "Global notepad", + label: "Notepad", + icon: "notebook-pen", + async render(event) { + try { + return await withNoteQueue(async () => { + const store = await loadStore(); + const action = event?.action || "view"; + + if (action === "add") { + const text = (firstField(event, "text") || "").trim(); + if (!text) return renderPanel(store, { status: "Type something to add first." }); + const duplicate = findDuplicate(store, text); + if (duplicate) return renderPanel(store, { status: `Already tracked as ${duplicate.id}.` }); + const kindField = firstField(event, "kind"); + const now = new Date().toISOString(); + store.entries.push({ + id: generateId(new Set(store.entries.map((entry) => entry.id))), + text: text.slice(0, MAX_TEXT_CHARS), + kind: kindField === "note" || kindField === "decision" ? kindField : "task", + status: "open", + pinned: false, + tags: [], + created: now, + updated: now, + source: { by: "user" }, + }); + await saveStore(store); + return renderPanel(store, { status: "Added." }); + } + + if (action === "filter") return renderPanel(store, { query: firstField(event, "query") }); + + if (["done", "reopen", "drop", "pin", "unpin"].includes(action)) { + const entry = requireEntry(store, payloadId(event)); + if (action === "done") { entry.status = "done"; entry.pinned = false; } + else if (action === "reopen") entry.status = "open"; + else if (action === "drop") { entry.status = "dropped"; entry.pinned = false; } + else entry.pinned = action === "pin"; + touch(entry); + await saveStore(store); + return renderPanel(store, { status: `${entry.id} ${action === "reopen" ? "reopened" : action === "pin" ? "pinned" : action === "unpin" ? "unpinned" : action}.` }); + } + + if (action === "edit-form") return renderEditForm(requireEntry(store, payloadId(event))); + + if (action === "save-edit") { + const entry = requireEntry(store, payloadId(event)); + const text = (firstField(event, "text") || "").trim(); + if (text) entry.text = text.slice(0, MAX_TEXT_CHARS); + entry.tags = cleanTags(firstField(event, "tags")); + try { entry.due = cleanDue(firstField(event, "due")); } + catch { /* leave due unchanged on malformed input */ } + touch(entry); + await saveStore(store); + return renderPanel(store, { status: `${entry.id} saved.` }); + } + + return renderPanel(store); + }); + } catch (error) { + return renderPanel(await loadStore(), { status: `Error: ${describeError(error)}` }); + } + }, + }); + + // Entry points are explicit: this panel is reachable from the mascot FAB. + ctx.ui.web.contribute(`${PANEL_KEY}-launcher`, { + slot: "fab", + kind: "static", + title: "Global notepad", + label: "Notepad", + icon: "notebook-pen", + opens: PANEL_KEY, + }); + }); + + // Optional, default-off ambient channel: only deliberately pinned entries, + // and only when the user turns the settings toggle on. + pi.on("before_agent_start", async (event, ctx) => { + try { + const { values } = await ctx.ui.web.getSettings(SETTINGS_ID); + if (!values?.pinnedInPrompt) return; + const store = await loadStore(); + const pinned = sortEntries(store.entries.filter((entry) => entry.pinned && entry.status === "open")).slice(0, MAX_PINNED_IN_PROMPT); + if (!pinned.length) return; + const lines = pinned.map((entry) => `- ${entry.text}${entry.due ? ` (due ${entry.due})` : ""}`); + return { + systemPrompt: `${event.systemPrompt}\n\n# Pinned notepad entries\nThe user pinned these cross-conversation notes as important. The full notepad is available through the notepad tool.\n${lines.join("\n")}`, + }; + } catch { + return; // Never block a turn on notepad failures. + } + }); + + pi.on("session_shutdown", (_event, ctx) => { + // Contribution state itself is session-scoped and released with the runtime. + // Remove this session's invalidator so later writes only notify live hosts. + const invalidate = invalidatorByWebUi.get(ctx.ui.web); + if (invalidate) storeInvalidators.delete(invalidate); + invalidatorByWebUi.delete(ctx.ui.web); + ctx.ui.web.contribute(`${PANEL_KEY}-launcher`, undefined); + ctx.ui.web.contribute(PANEL_KEY, undefined); + }); +} diff --git a/server.ts b/server.ts index 7030f8f..b6364b3 100644 --- a/server.ts +++ b/server.ts @@ -454,6 +454,15 @@ const server = createServer(async (req, res) => { return sendJson(res, 200, { ok: true, ...state }); } + if (mockMode && method === "POST" && url.pathname === "/api/mock/event") { + const body = await readBody(req); + if (!body || typeof body !== "object" || Array.isArray(body) || typeof (body as any).type !== "string") { + return sendJson(res, 400, { ok: false, error: "Mock event requires a type" }); + } + broadcast(body); + return sendJson(res, 200, { ok: true }); + } + if (mockMode && method === "GET" && url.pathname === "/api/mock/live-sessions") { return sendJson(res, 200, { ok: true, ...sessionService.lifecycleSnapshot(), lifecycle: getMockLifecycle() }); } diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts index 1f43785..7bbb1a0 100644 --- a/server/extensions/webUi.ts +++ b/server/extensions/webUi.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { ExtensionUIDialogOptions, ExtensionUIContext } from "@earendil-works/pi-coding-agent"; -import type { PiWebArtifactAction, PiWebFabAction, PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebPanel, PiWebRegisterSettingsResult, PiWebSettingsRegistration, PiWebStoredSettings, PiWebUi } from "../../src/extensions.js"; +import type { PiWebArtifactAction, PiWebContribution, PiWebFabAction, PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebPanel, PiWebRegisterSettingsResult, PiWebSettingsRegistration, PiWebStoredSettings, PiWebUi } from "../../src/extensions.js"; import type { createSettingsStore } from "../settings.js"; import { ExtensionRevisionConflictError, isValidExtensionOwnerId } from "../settings.js"; import { canonicalSchemaKey, defaultSettingsValues, validateSettingsValues } from "../extensionSettings.js"; @@ -365,7 +365,7 @@ function normalizePiWebFooter(value: unknown): PiWebFooter | undefined { const footer = value as Record; if (footer.kind === "text") return normalizeTextLines(footer.lines); if (footer.kind === "html") { - const html = cleanFooterText(footer.html, 20_000); + const html = cleanFooterText(footer.html, contributionPolicies.footer.viewBudget); return html ? { kind: "html", html } : undefined; } return undefined; @@ -378,15 +378,33 @@ const cleanArtifactExtensions = (value: unknown) => Array.isArray(value) ? value }).slice(0, 20) : undefined; const contributionPolicies = { - footer: { descriptor: (entry: Extract) => ({ view: entry.view }) }, - "header-action": { descriptor: (_entry: Extract) => ({}) }, - "artifact-action": { descriptor: (entry: Extract) => ({ match: { - kinds: Array.isArray(entry.source.kinds) ? entry.source.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, - extensions: cleanArtifactExtensions(entry.source.extensions), - } }) }, - "git-tab": { descriptor: (_entry: Extract) => ({}) }, - panel: { descriptor: (_entry: Extract) => ({}) }, - fab: { descriptor: (entry: Extract) => ({ opens: cleanContributionKey(entry.source.opens) }) }, + footer: { + allowedKinds: ["static"], viewFields: ["view"], viewBudget: 20_000, + descriptor: (entry: Extract) => ({ view: entry.view }), + }, + "header-action": { + allowedKinds: ["rendered"], viewFields: ["markdown"], effects: ["open-panel"], viewBudget: 200_000, + descriptor: (_entry: Extract) => ({}), + }, + "artifact-action": { + allowedKinds: ["rendered"], viewFields: ["markdown", "message", "download"], viewBudget: 200_000, + descriptor: (entry: Extract) => ({ match: { + kinds: Array.isArray(entry.source.kinds) ? entry.source.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, + extensions: cleanArtifactExtensions(entry.source.extensions), + } }), + }, + "git-tab": { + allowedKinds: ["rendered"], viewFields: ["html", "composerContext"], viewBudget: 500_000, + descriptor: (_entry: Extract) => ({}), + }, + panel: { + allowedKinds: ["rendered"], viewFields: ["html"], viewBudget: 500_000, maxFields: 128, + descriptor: (_entry: Extract) => ({}), + }, + fab: { + allowedKinds: ["static"], viewFields: [], viewBudget: 0, + descriptor: (entry: Extract) => ({ opens: cleanContributionKey(entry.source.opens) }), + }, } as const; type ContributionSlot = keyof typeof contributionPolicies; @@ -430,49 +448,78 @@ function setContribution( broadcastContributions(value); } +function normalizedPublicContribution(key: string, spec: PiWebContribution): WebContribution { + if (!spec || typeof spec !== "object") throw new TypeError("Contribution must be an object"); + const delivery = spec as PiWebContribution & { view?: unknown; render?: unknown; entry?: unknown }; + const deliveryFields = [delivery.view !== undefined, delivery.render !== undefined, delivery.entry !== undefined].filter(Boolean).length; + if (delivery.entry !== undefined) throw new TypeError("Webview contributions are not supported yet"); + if (spec.slot === "fab" ? deliveryFields !== 0 : deliveryFields !== 1) { + throw new TypeError("Contribution has conflicting or missing delivery fields"); + } + const policy = contributionPolicies[spec.slot as ContributionSlot]; + if (!policy || !(policy.allowedKinds as readonly string[]).includes(spec.kind)) { + throw new TypeError(`Unsupported contribution slot/kind: ${String(spec.slot)}/${String(spec.kind)}`); + } + if (spec.slot === "footer" && spec.kind === "static") { + const view = normalizePiWebFooter(spec.view); + if (!view) throw new TypeError("Footer contribution requires a valid view"); + return { version: 1, key, slot: "footer", kind: "static", view }; + } + if (spec.slot === "fab" && spec.kind === "static") { + if (!cleanContributionKey(spec.opens)) throw new TypeError("FAB contribution requires a valid panel key in opens"); + return { version: 1, key, slot: "fab", kind: "static", source: spec }; + } + if ((spec.slot === "header-action" || spec.slot === "artifact-action" || spec.slot === "git-tab" || spec.slot === "panel") + && spec.kind === "rendered" && typeof spec.render === "function") { + if (spec.slot === "header-action") return { + version: 1, key, slot: spec.slot, kind: "rendered", + source: { ...spec, invoke: () => spec.render() }, + }; + if (spec.slot === "artifact-action") return { + version: 1, key, slot: spec.slot, kind: "rendered", + source: { ...spec, kinds: spec.match?.kinds, extensions: spec.match?.extensions, invoke: (artifact) => spec.render({ context: artifact }) }, + }; + if (spec.slot === "git-tab") return { + version: 1, key, slot: spec.slot, kind: "rendered", + source: { ...spec, render: (event) => spec.render({ action: event?.action, payload: event?.payload, context: event?.repo }) }, + }; + return { + version: 1, key, slot: spec.slot, kind: "rendered", + source: { ...spec, render: spec.render } as PiWebPanel, + }; + } + throw new TypeError(`Unsupported contribution slot/kind: ${String((spec as any).slot)}/${String((spec as any).kind)}`); +} + function createPiWebUi(value: any): PiWebUi { + const contributeForSlot = (keyValue: unknown, slot: ContributionSlot, spec: PiWebContribution | undefined) => { + setContribution(value, slot, keyValue, (key) => spec ? normalizedPublicContribution(key, spec) : undefined); + }; return { - setFooter(key, footer) { - setContribution(value, "footer", key, (cleanKey) => { - const view = normalizePiWebFooter(footer); - return view ? { version: 1, key: cleanKey, slot: "footer", kind: "static", view } : undefined; - }); - }, - setHeaderAction(key, action) { - setContribution(value, "header-action", key, (cleanKey) => ( - action && typeof action === "object" && typeof action.invoke === "function" - ? { version: 1, key: cleanKey, slot: "header-action", kind: "rendered", source: action } - : undefined - )); - }, - setArtifactAction(key, action) { - setContribution(value, "artifact-action", key, (cleanKey) => ( - action && typeof action === "object" && typeof action.invoke === "function" - ? { version: 1, key: cleanKey, slot: "artifact-action", kind: "rendered", source: action } - : undefined - )); - }, - setGitTab(key, tab) { - setContribution(value, "git-tab", key, (cleanKey) => ( - tab && typeof tab === "object" && typeof tab.render === "function" - ? { version: 1, key: cleanKey, slot: "git-tab", kind: "rendered", source: tab } - : undefined - )); - }, - setPanel(key, panel) { - setContribution(value, "panel", key, (cleanKey) => ( - panel && typeof panel === "object" && typeof panel.render === "function" - ? { version: 1, key: cleanKey, slot: "panel", kind: "rendered", source: panel } - : undefined - )); + contribute(keyValue, spec) { + const key = cleanContributionKey(keyValue); + if (!key) throw new TypeError("Contribution key is required"); + if (!spec) { + for (const slot of Object.keys(contributionPolicies) as ContributionSlot[]) contributionState(value).delete(contributionId(slot, key)); + broadcastContributions(value); + return; + } + const contribution = normalizedPublicContribution(key, spec); + for (const slot of Object.keys(contributionPolicies) as ContributionSlot[]) contributionState(value).delete(contributionId(slot, key)); + contributionState(value).set(contributionId(contribution.slot, key), contribution); + broadcastContributions(value); }, - setFabAction(key, action) { - setContribution(value, "fab", key, (cleanKey) => ( - action && typeof action === "object" && cleanContributionKey(action.opens) - ? { version: 1, key: cleanKey, slot: "fab", kind: "static", source: action } - : undefined - )); + update(keyValue) { + const key = cleanContributionKey(keyValue); + if (!key || !Array.from(contributionState(value).values()).some((entry) => entry.key === key)) return; + deps.emit({ type: "web_contribution_updated", sessionId: value.sessionId, sessionFile: value.sessionFile, key }); }, + setFooter: (key, footer) => contributeForSlot(key, "footer", footer === undefined ? undefined : { slot: "footer", kind: "static", view: footer }), + setHeaderAction: (key, action) => contributeForSlot(key, "header-action", action === undefined ? undefined : { slot: "header-action", kind: "rendered", ...action, render: () => action.invoke() }), + setArtifactAction: (key, action) => contributeForSlot(key, "artifact-action", action === undefined ? undefined : { slot: "artifact-action", kind: "rendered", title: action.title, label: action.label, match: { kinds: action.kinds, extensions: action.extensions }, render: (event) => action.invoke(event?.context as any) }), + setGitTab: (key, tab) => contributeForSlot(key, "git-tab", tab === undefined ? undefined : { slot: "git-tab", kind: "rendered", title: tab.title, label: tab.label, render: (event) => tab.render({ action: event?.action, payload: event?.payload, repo: event?.context }) }), + setPanel: (key, panel) => contributeForSlot(key, "panel", panel === undefined ? undefined : { slot: "panel", kind: "rendered", ...panel }), + setFabAction: (key, action) => contributeForSlot(key, "fab", action === undefined ? undefined : { slot: "fab", kind: "static", ...action }), async registerSettings(schema) { return registerSessionSettings(value, schema); }, async getSettings(id) { return getExtensionSettings(id); }, }; @@ -664,7 +711,7 @@ async function bindWebExtensions(value: any) { if (!contribution) throw new Error("Header action not found"); const action = contribution.source; const result = await action.invoke(); - const markdown = cleanFooterText(result?.markdown, 200_000); + const markdown = cleanFooterText(result?.markdown, contributionPolicies["header-action"].viewBudget); const openPanelEffect = Array.isArray(result?.effects) ? result.effects.find((effect) => effect?.type === "open-panel") : undefined; @@ -693,7 +740,7 @@ async function bindWebExtensions(value: any) { if (Array.isArray(action.kinds) && action.kinds.length && !action.kinds.includes(kind)) throw new Error("Artifact action does not match this artifact"); if (Array.isArray(action.extensions) && action.extensions.length && !action.extensions.some((extension) => typeof extension === "string" && name.toLowerCase().endsWith(extension.toLowerCase()))) throw new Error("Artifact action does not match this artifact"); const result = await action.invoke({ name, path, kind }); - const markdown = cleanFooterText(result?.markdown, 200_000); + const markdown = cleanFooterText(result?.markdown, contributionPolicies["artifact-action"].viewBudget); const message = cleanHeaderActionText(result?.message, 2_000); const download = result?.download && typeof result.download === "object" ? { path, filename: cleanHeaderActionText(result.download.filename, 500) || name } : undefined; if (!markdown && !message && !download) throw new Error("Artifact action returned no result"); @@ -719,7 +766,7 @@ async function bindWebExtensions(value: any) { branch: typeof repo.branch === "string" ? repo.branch : undefined, } : undefined, }); - const html = cleanFooterText(result?.html, 500_000); + const html = cleanFooterText(result?.html, contributionPolicies["git-tab"].viewBudget); const rawContext = result?.composerContext && typeof result.composerContext === "object" ? result.composerContext as Record : undefined; @@ -748,7 +795,7 @@ async function bindWebExtensions(value: any) { const cleanFieldValue = (field: string) => field .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") .slice(0, 100_000); - const fields = rawFields ? Object.entries(rawFields).slice(0, 128).reduce>((cleaned, [name, field]) => { + const fields = rawFields ? Object.entries(rawFields).slice(0, contributionPolicies.panel.maxFields).reduce>((cleaned, [name, field]) => { const cleanName = cleanHeaderActionText(name, 200); if (!cleanName) return cleaned; if (typeof field === "string") cleaned[cleanName] = cleanFieldValue(field); @@ -760,7 +807,7 @@ async function bindWebExtensions(value: any) { payload: input.payload, fields, }); - const html = cleanFooterText(result?.html, 500_000); + const html = cleanFooterText(result?.html, contributionPolicies.panel.viewBudget); if (!html) throw new Error("Panel returned no HTML"); return { title: cleanHeaderActionText(result?.title), html }; } diff --git a/src/extensions.ts b/src/extensions.ts index 7962ab8..0c5b899 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -39,6 +39,36 @@ export type PiWebFooter = export type PiWebEffect = | { type: "open-panel"; key: string }; +export type PiWebContributionEvent = { + action?: string; + payload?: unknown; + fields?: Record; + context?: Record; +}; + +export type PiWebContributionView = { + title?: string; + html?: string; + markdown?: string; + message?: string; + composerContext?: PiWebComposerContext; + download?: { filename?: string }; + effects?: PiWebEffect[]; +}; + +export type PiWebContribution = + | { slot: "footer"; kind: "static"; view: PiWebFooter } + | { slot: "fab"; kind: "static"; title: string; label?: string; icon?: string; opens: string } + | { + slot: "header-action" | "artifact-action" | "git-tab" | "panel"; + kind: "rendered"; + title: string; + label?: string; + icon?: string; + match?: { kinds?: PiWebArtifactContext["kind"][]; extensions?: string[] }; + render: (event?: PiWebContributionEvent) => PiWebContributionView | Promise; + }; + export type PiWebHeaderActionResult = { /** Markdown rendered in the shared dismissible popover. */ markdown?: string; @@ -205,6 +235,12 @@ export type PiWebRegisterSettingsResult = { }; export type PiWebUi = { + /** Register or clear a normalized browser contribution. */ + contribute(key: string, contribution: PiWebContribution | undefined): void; + + /** Notify the active host that a rendered contribution should be pulled again. */ + update(key: string): void; + /** * Set or clear a pi-web footer region. * diff --git a/src/extensions/webPanels.ts b/src/extensions/webPanels.ts index 49b8f3f..48315d5 100644 --- a/src/extensions/webPanels.ts +++ b/src/extensions/webPanels.ts @@ -17,6 +17,7 @@ export type WebPanelsController = { setPanels(value: unknown, sessionId: string): void; entries(): WebPanelEntry[]; open(key: string): void; + update(key: string): void; isOpen(): boolean; }; @@ -204,6 +205,9 @@ export function createWebPanels(options: { }, entries: () => [...panels], open, + update: (key) => { + if (key === activeKey && panelHandle.isOpen()) void invoke(); + }, isOpen: () => panelHandle.isOpen(), }; } diff --git a/src/git/panel.ts b/src/git/panel.ts index 04acd1c..d5b4fdb 100644 --- a/src/git/panel.ts +++ b/src/git/panel.ts @@ -14,6 +14,7 @@ type GitExtensionTabView = { key: string; loading: boolean; title?: string; html export type GitPanelController = { setExtensionTabs(tabs: unknown): void; + updateExtensionTab(key: string): void; isOpen(): boolean; }; @@ -503,6 +504,9 @@ export function initGitPanel(options: { return { setExtensionTabs, + updateExtensionTab: (key) => { + if (extensionKeyFromView() === key && (panelHandle?.isOpen() ?? state.isOpen)) void loadExtensionTab(key); + }, isOpen: () => panelHandle?.isOpen() ?? state.isOpen, }; } diff --git a/src/main.ts b/src/main.ts index 5476ae2..040a106 100644 --- a/src/main.ts +++ b/src/main.ts @@ -516,6 +516,10 @@ realtime = createRealtime({ sessionState, refreshMessages, refreshState, + updateWebContribution: (key) => { + webPanels?.update(key); + gitPanel?.updateExtensionTab(key); + }, addMessage: messages.addMessage, }); diff --git a/src/realtime/realtime.ts b/src/realtime/realtime.ts index 5f69b17..61ee65d 100644 --- a/src/realtime/realtime.ts +++ b/src/realtime/realtime.ts @@ -51,9 +51,10 @@ export function createRealtime(options: { sessionState: SessionStateController; refreshMessages: () => Promise; refreshState: () => Promise; + updateWebContribution?: (key: string, sessionId: string) => void; addMessage: (role: "system", text: string, extraClass?: string) => HTMLDivElement; }): RealtimeController { - const { state, elements, api, composer, messages, models, sessions, settings, status, tools, conversationTree, sessionState, refreshMessages, refreshState, addMessage } = options; + const { state, elements, api, composer, messages, models, sessions, settings, status, tools, conversationTree, sessionState, refreshMessages, refreshState, updateWebContribution, addMessage } = options; let compactionMessage: HTMLDivElement | null = null; let retryErrorCard: HTMLDivElement | null = null; let terminalFailureCard: HTMLDivElement | null = null; @@ -782,6 +783,12 @@ export function createRealtime(options: { sessionState.applySnapshot(data); return; } + if (data.type === "web_contribution_updated") { + const sessionId = String(data.sessionId || ""); + const key = typeof data.key === "string" ? data.key : ""; + if (key && sessionId === state.currentSessionId) updateWebContribution?.(key, sessionId); + return; + } if (data.type === "committed_message") { const appliesToCurrentSession = !data.sessionId || data.sessionId === state.currentSessionId; if (isReplay && appliesToCurrentSession) { diff --git a/tests/e2e/pi-web.spec.ts b/tests/e2e/pi-web.spec.ts index 51ba9bf..09dfa50 100644 --- a/tests/e2e/pi-web.spec.ts +++ b/tests/e2e/pi-web.spec.ts @@ -1435,7 +1435,9 @@ test.describe("code block copy button", () => { await pre.hover(); const copyBtn = pre.locator(".copyCode"); await copyBtn.evaluate((el) => (el as HTMLElement).focus()); - await copyBtn.click(); + // This test exercises the timer, not hover visibility (covered above). On + // touch projects Playwright may clear synthetic hover before the click. + await copyBtn.click({ force: true }); await expect(copyBtn).toHaveAttribute("data-icon", "check"); await page.waitForTimeout(2000); diff --git a/tests/e2e/web-panel.spec.ts b/tests/e2e/web-panel.spec.ts index 16dac83..318bae3 100644 --- a/tests/e2e/web-panel.spec.ts +++ b/tests/e2e/web-panel.spec.ts @@ -18,6 +18,7 @@ test("opens an extension panel from the FAB and submits its form", async ({ page }); const invocations: any[] = []; + let revision = 1; await page.route("**/api/web-contributions/invoke", async (route) => { const input = route.request().postDataJSON(); invocations.push(input); @@ -31,7 +32,7 @@ test("opens an extension panel from the FAB and submits its form", async ({ page return; } const value = input.event?.fields?.content || "Initial global note"; - const status = input.event?.action === "save" ? "Saved globally" : "Shared with every conversation"; + const status = input.event?.action === "save" ? "Saved globally" : `Shared with every conversation · revision ${revision}`; await route.fulfill({ status: 200, contentType: "application/json", @@ -53,6 +54,13 @@ test("opens an extension panel from the FAB and submits its form", async ({ page const panel = page.locator("#webExtensionPanel"); await expect(panel).toBeVisible(); await expect(panel.locator("h2")).toHaveText("Global notepad"); await expect(panel.locator("textarea")).toHaveValue("Initial global note"); + await expect(panel.getByRole("status")).toContainText("revision 1"); + + const invokesBeforeUpdate = invocations.length; + revision = 2; + await page.request.post("/api/mock/event", { data: { type: "web_contribution_updated", sessionId: "mock-current", key: "notes" } }); + await expect(panel.getByRole("status")).toContainText("revision 2"); + expect(invocations.length).toBe(invokesBeforeUpdate + 1); await panel.locator("textarea").fill("Remember this everywhere"); await panel.getByRole("button", { name: "Save" }).click(); diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index ee81478..8583b6b 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -165,6 +165,49 @@ describe("bundled extension path discovery", () => { expect(emitted.at(-1)).toMatchObject({ type: "web_contributions_changed" }); }); + it("publishes normalized contributions and emits pull invalidations", async () => { + let ui: any; + const emitted: any[] = []; + const bridge = createWebUiBridge({ + emit: (value) => emitted.push(value), clientCount: () => 1, acquireWorkLease: () => () => undefined, + createNewSession: async () => ({}), sessionCwd: () => process.cwd(), state: () => ({}), + }); + const session = { + sessionId: "session", sessionFile: "/tmp/session.jsonl", agent: { waitForIdle: async () => undefined }, + bindExtensions: async (options: any) => { ui = options.uiContext; }, + }; + await bridge.bind(session); + + let revision = 1; + ui.web.contribute("status", { + slot: "panel", kind: "rendered", title: "Worker status", + render: () => ({ html: `

    Revision ${revision}

    ` }), + }); + expect(bridge.entries(session).webContributions).toEqual([ + expect.objectContaining({ version: 1, key: "status", slot: "panel", kind: "rendered", title: "Worker status" }), + ]); + await expect(bridge.invokeContribution(session, { slot: "panel", key: "status" })) + .resolves.toMatchObject({ html: "

    Revision 1

    " }); + + revision += 1; + ui.web.update("status"); + expect(emitted.at(-1)).toMatchObject({ type: "web_contribution_updated", sessionId: "session", key: "status" }); + const eventCount = emitted.length; + ui.web.update("missing"); + expect(emitted).toHaveLength(eventCount); + + ui.web.contribute("status", { slot: "footer", kind: "static", view: "Ready" }); + expect(bridge.entries(session).webContributions).toEqual([ + expect.objectContaining({ key: "status", slot: "footer", kind: "static" }), + ]); + expect(() => ui.web.contribute("bad", { slot: "panel", kind: "static", view: {} })).toThrow("Unsupported contribution slot/kind"); + expect(() => ui.web.contribute("conflict", { + slot: "panel", kind: "rendered", title: "Conflict", view: {}, render: () => ({ html: "" }), + })).toThrow("conflicting or missing delivery fields"); + ui.web.contribute("status", undefined); + expect(bridge.entries(session).webContributions).toEqual([]); + }); + it("serializes and invokes FAB-backed web panels through the web bridge", async () => { let ui: any; const emitted: any[] = []; From b734603d3c48623413264e0aaa4cab9dcf25a7dd Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Fri, 7 Aug 2026 20:25:32 -0700 Subject: [PATCH 2/2] Harden contribution runtime and invalidation --- docs/pi-web-extensions.md | 4 +++- examples/pi-web-extensions/notepad.ts | 25 ++++++++++++++++++++--- server/extensions/webUi.ts | 27 +++++++++++++++++++++++++ server/session/service.ts | 16 +++++++++++++-- src/extensions.ts | 10 +++++++++ src/extensions/webPanels.ts | 22 ++++++++++++++++++-- src/git/panel.ts | 13 +++++++++--- src/settings/settings.ts | 10 ++++++++- tests/e2e/git.spec.ts | 29 +++++++++++++++++++++++++++ tests/e2e/web-panel.spec.ts | 6 ++++++ tests/extensions.test.ts | 20 +++++++++++++++++- 11 files changed, 169 insertions(+), 13 deletions(-) diff --git a/docs/pi-web-extensions.md b/docs/pi-web-extensions.md index c62013d..c342df9 100644 --- a/docs/pi-web-extensions.md +++ b/docs/pi-web-extensions.md @@ -65,7 +65,7 @@ These are separate from regular pi extension locations on purpose. A pi-web exte ## Contribution API -`ctx.ui.web.contribute(key, spec)` is the canonical API for browser surfaces. Specs use an explicit `slot` and `kind`; pi-web normalizes them immediately into versioned descriptors. Passing `undefined` clears every contribution registered under that key. +`ctx.ui.web.contribute(key, spec)` is the canonical API for browser surfaces. Specs use an explicit `slot` and `kind`; pi-web normalizes them immediately into versioned descriptors. Passing `undefined` clears every contribution registered under that key. Keys are identities across slots, so prefix them with your extension name (for example, `acme-notes.panel`) to avoid collisions with other extensions and convenience wrappers. ```ts ctx.ui.web.contribute("worker-status", { @@ -80,6 +80,8 @@ ctx.ui.web.contribute("worker-status", { Rendered contributions receive the shared `{ action, payload, fields, context }` event envelope. Static contributions currently support the `footer` and `fab` slots; rendered contributions support `header-action`, `artifact-action`, `git-tab`, and `panel`. +Independently distributed extensions should inspect `ctx.ui.web.capabilities` before using newer facilities. It reports the additive runtime contract: `apiVersion`, `slots`, `kinds`, and `effects`. + When backing data changes without a browser interaction, call `ctx.ui.web.update(key)`. pi-web emits a lightweight invalidation and an active panel or Git tab pulls a fresh render. Updates for hidden surfaces do no work; they render when next opened. ```ts diff --git a/examples/pi-web-extensions/notepad.ts b/examples/pi-web-extensions/notepad.ts index ca74e15..ba4c7b6 100644 --- a/examples/pi-web-extensions/notepad.ts +++ b/examples/pi-web-extensions/notepad.ts @@ -521,7 +521,21 @@ export default function globalNotepad(pi: PiWebExtensionAPI) { }); pi.on("session_start", async (_event, ctx) => { - await ctx.ui.web.registerSettings({ + const web = ctx.ui.web as typeof ctx.ui.web | undefined; + const capabilities = web?.capabilities; + const compatible = capabilities?.apiVersion === 1 + && capabilities.slots.includes("panel") + && capabilities.slots.includes("fab") + && capabilities.kinds.includes("rendered") + && capabilities.kinds.includes("static") + && typeof web?.contribute === "function" + && typeof web?.update === "function"; + if (!compatible) { + ctx.ui.notify("Global notepad UI requires a newer pi-web contribution API. The notepad tool remains available.", "warning"); + return; + } + + await web.registerSettings({ id: SETTINGS_ID, title: "Global notepad", schemaVersion: 1, @@ -554,6 +568,9 @@ export default function globalNotepad(pi: PiWebExtensionAPI) { if (!text) return renderPanel(store, { status: "Type something to add first." }); const duplicate = findDuplicate(store, text); if (duplicate) return renderPanel(store, { status: `Already tracked as ${duplicate.id}.` }); + if (store.entries.filter((entry) => entry.status === "open").length >= MAX_ACTIVE_ENTRIES) { + return renderPanel(store, { status: `The notepad already has ${MAX_ACTIVE_ENTRIES} open entries. Close or consolidate stale ones first.` }); + } const kindField = firstField(event, "kind"); const now = new Date().toISOString(); store.entries.push({ @@ -641,7 +658,9 @@ export default function globalNotepad(pi: PiWebExtensionAPI) { const invalidate = invalidatorByWebUi.get(ctx.ui.web); if (invalidate) storeInvalidators.delete(invalidate); invalidatorByWebUi.delete(ctx.ui.web); - ctx.ui.web.contribute(`${PANEL_KEY}-launcher`, undefined); - ctx.ui.web.contribute(PANEL_KEY, undefined); + if (typeof ctx.ui.web.contribute === "function") { + ctx.ui.web.contribute(`${PANEL_KEY}-launcher`, undefined); + ctx.ui.web.contribute(PANEL_KEY, undefined); + } }); } diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts index 7bbb1a0..dbf685b 100644 --- a/server/extensions/webUi.ts +++ b/server/extensions/webUi.ts @@ -49,6 +49,20 @@ type WebContribution = /** Canonical per-runtime registry. Legacy surfaces below are wire adapters over it. */ const webContributionStates = new WeakMap>(); +type ExtensionRuntimeError = { path: string; event: string; error: string; timestamp: string }; +const extensionRuntimeErrors = new WeakMap(); + +function recordExtensionRuntimeError(session: object, input: any) { + const errors = extensionRuntimeErrors.get(session) || []; + errors.push({ + path: String(input?.extensionPath || input?.path || "unknown extension"), + event: String(input?.event?.type || input?.eventName || input?.hook || input?.event || "unknown event"), + error: input?.error instanceof Error ? input.error.message : String(input?.error || input), + timestamp: new Date().toISOString(), + }); + if (errors.length > 20) errors.splice(0, errors.length - 20); + extensionRuntimeErrors.set(session, errors); +} function contributionId(slot: WebContribution["slot"], key: string) { return `${slot}\0${key}`; @@ -377,6 +391,9 @@ const cleanArtifactExtensions = (value: unknown) => Array.isArray(value) ? value return cleaned && /^\.[a-z0-9]+$/.test(cleaned) ? [cleaned] : []; }).slice(0, 20) : undefined; +// `allowedKinds`, budgets, and field limits are executable guards. `viewFields` +// and `effects` document each slot's output contract; the slot-specific +// sanitizers below enforce those structural shapes. const contributionPolicies = { footer: { allowedKinds: ["static"], viewFields: ["view"], viewBudget: 20_000, @@ -409,6 +426,13 @@ const contributionPolicies = { type ContributionSlot = keyof typeof contributionPolicies; +const webCapabilities = Object.freeze({ + apiVersion: 1 as const, + slots: Object.freeze(Object.keys(contributionPolicies)), + kinds: Object.freeze([...new Set(Object.values(contributionPolicies).flatMap((policy) => [...policy.allowedKinds]))]), + effects: Object.freeze([...new Set(Object.values(contributionPolicies).flatMap((policy) => "effects" in policy ? [...policy.effects] : []))]), +}); + function webContributionEntries(value: any) { return Array.from(contributionState(value).values()).flatMap((entry) => { const source = "source" in entry ? entry.source : undefined; @@ -496,6 +520,7 @@ function createPiWebUi(value: any): PiWebUi { setContribution(value, slot, keyValue, (key) => spec ? normalizedPublicContribution(key, spec) : undefined); }; return { + capabilities: webCapabilities, contribute(keyValue, spec) { const key = cleanContributionKey(keyValue); if (!key) throw new TypeError("Contribution key is required"); @@ -693,6 +718,7 @@ async function bindWebExtensions(value: any) { deps.emit({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: "An extension requested shutdown; pi-web ignored the request." }); }, onError: (error: any) => { + recordExtensionRuntimeError(value, error); deps.emit({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: `Extension error (${error.extensionPath}): ${error.error}` }); }, }); @@ -845,6 +871,7 @@ async function bindWebExtensions(value: any) { invokeGitTab, invokePanel, respond, + runtimeErrors: (value: object) => [...(extensionRuntimeErrors.get(value) || [])], registerSettings: (session: any, schema: PiWebSettingsRegistration) => registerSessionSettings(session, schema), settingsSchemas: activeSettingsSchemaList, settingsSchemaEntry: (id: string) => { diff --git a/server/session/service.ts b/server/session/service.ts index 492478b..538087c 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -415,11 +415,23 @@ export class LocalSessionService implements SessionService { respondExtensionUi(id: string, response: Record) { return this.webUiBridge.respond(id, response); } + private extensionStatusFor(value: PiWebSession, loader: ResilientResourceLoader) { + const status = loader.getStatus(); + const runtimeErrors = this.webUiBridge.runtimeErrors(value); + if (!runtimeErrors.length) return { ...status, runtimeErrors }; + return { + ...status, + state: status.state === "loading" ? status.state : "degraded" as const, + runtimeErrors, + message: `${status.message} ${runtimeErrors.length} recent runtime error${runtimeErrors.length === 1 ? "" : "s"}.`, + }; + } + extensionStatus(sessionId: string) { const value = this.openExtensionSession(sessionId); const loader = this.extensionLoaders.get(value); if (!loader) throw new SessionServiceError("Extension status is not available for this session.", 404); - return loader.getStatus(); + return this.extensionStatusFor(value, loader); } async reloadExtensions(sessionId: string) { @@ -429,7 +441,7 @@ export class LocalSessionService implements SessionService { const loader = this.extensionLoaders.get(value); if (!loader || typeof value.reload !== "function") throw new SessionServiceError("Extension reload is not available for this session.", 404); await value.reload(); - const status = loader.getStatus(); + const status = this.extensionStatusFor(value, loader); this.emit({ type: "wire", value: { type: "extensions_reloaded", sessionId: value.sessionId, status } as JsonValue }); return status; } diff --git a/src/extensions.ts b/src/extensions.ts index 0c5b899..57d582f 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -234,7 +234,17 @@ export type PiWebRegisterSettingsResult = { error?: string; }; +export type PiWebCapabilities = Readonly<{ + apiVersion: 1; + slots: readonly string[]; + kinds: readonly string[]; + effects: readonly string[]; +}>; + export type PiWebUi = { + /** Runtime feature discovery for independently distributed extensions. */ + readonly capabilities: PiWebCapabilities; + /** Register or clear a normalized browser contribution. */ contribute(key: string, contribution: PiWebContribution | undefined): void; diff --git a/src/extensions/webPanels.ts b/src/extensions/webPanels.ts index 48315d5..fa3da4f 100644 --- a/src/extensions/webPanels.ts +++ b/src/extensions/webPanels.ts @@ -92,8 +92,15 @@ export function createWebPanels(options: { let sessionId = ""; let activeKey = ""; let requestGeneration = 0; + let updatePending = false; let panelHandle: RightPanelHandle; + function formControlIsFocused() { + const active = document.activeElement; + return active instanceof HTMLElement && body.contains(active) + && active.matches("input, textarea, select, button, [contenteditable]:not([contenteditable='false'])"); + } + function activePanel() { return panels.find((entry) => entry.key === activeKey); } @@ -161,6 +168,7 @@ export function createWebPanels(options: { if ((target instanceof HTMLButtonElement || target instanceof HTMLInputElement) && target.type === "submit" && target.form) return; event.preventDefault(); + updatePending = false; void invoke({ action: target.dataset.webAction || target.dataset.webPanelAction || "", payload: parsePayload(target.dataset.webPayload || target.dataset.webPanelPayload), @@ -171,6 +179,7 @@ export function createWebPanels(options: { body.addEventListener("submit", (event) => { if (!(event.target instanceof HTMLFormElement)) return; event.preventDefault(); + updatePending = false; const submitter = event.submitter instanceof HTMLElement ? event.submitter : undefined; void invoke({ action: submitter?.dataset.webAction || submitter?.dataset.webPanelAction || event.target.dataset.webAction || event.target.dataset.webPanelAction || "", @@ -179,6 +188,12 @@ export function createWebPanels(options: { }); }); + body.addEventListener("focusout", () => queueMicrotask(() => { + if (!updatePending || formControlIsFocused() || !panelHandle.isOpen()) return; + updatePending = false; + void invoke(); + })); + panelHandle = rightPanels.register({ id: "web-extension", side: "right", @@ -188,7 +203,7 @@ export function createWebPanels(options: { minWidth: 320, maxWidth: 900, focusOnOpen: close, - onClose: () => { requestGeneration += 1; }, + onClose: () => { requestGeneration += 1; updatePending = false; }, }); return { @@ -198,6 +213,7 @@ export function createWebPanels(options: { panels = normalizePanels(value); if (changedSession || (activeKey && !activePanel())) { requestGeneration += 1; + updatePending = false; activeKey = ""; body.textContent = ""; if (panelHandle.isOpen()) panelHandle.close(false); @@ -206,7 +222,9 @@ export function createWebPanels(options: { entries: () => [...panels], open, update: (key) => { - if (key === activeKey && panelHandle.isOpen()) void invoke(); + if (key !== activeKey || !panelHandle.isOpen()) return; + if (formControlIsFocused()) updatePending = true; + else void invoke(); }, isOpen: () => panelHandle.isOpen(), }; diff --git a/src/git/panel.ts b/src/git/panel.ts index d5b4fdb..f667a04 100644 --- a/src/git/panel.ts +++ b/src/git/panel.ts @@ -36,6 +36,7 @@ export function initGitPanel(options: { let panelHandle: RightPanelHandle | undefined; let extensionTabs: GitExtensionTab[] = []; let extensionTabView: GitExtensionTabView | undefined; + let extensionRequestGeneration = 0; const state: GitState = { isOpen: false, @@ -256,6 +257,7 @@ export function initGitPanel(options: { } async function loadExtensionTab(key: string, event?: { action?: string; payload?: unknown }) { + const generation = ++extensionRequestGeneration; state.primaryView = extensionViewKey(key); state.mobileView = extensionViewKey(key); if (!event) extensionTabView = { key, loading: true }; @@ -274,6 +276,7 @@ export function initGitPanel(options: { }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) throw new Error(data.error || res.statusText); + if (generation !== extensionRequestGeneration || extensionKeyFromView() !== key) return; if (data.composerContext && typeof data.composerContext === "object") { if (panelHandle) panelHandle.close(false); else setOpen(false); @@ -285,10 +288,14 @@ export function initGitPanel(options: { throw new Error("Git tab returned no content"); } } catch (error) { - extensionTabView = { key, loading: false, error: error instanceof Error ? error.message : String(error) }; + if (generation === extensionRequestGeneration && extensionKeyFromView() === key) { + extensionTabView = { key, loading: false, error: error instanceof Error ? error.message : String(error) }; + } } finally { - panel.removeAttribute("aria-busy"); - render(); + if (generation === extensionRequestGeneration) { + panel.removeAttribute("aria-busy"); + render(); + } } } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 178723a..995dea5 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -22,6 +22,7 @@ type ExtensionLoadStatus = { durationMs?: number; extensionCount: number; errors: Array<{ path: string; error: string }>; + runtimeErrors?: Array<{ path: string; event: string; error: string; timestamp: string }>; message: string; }; @@ -261,7 +262,14 @@ export function createSettings(options: { row.textContent = `${error.path}: ${error.error}`; elements.extensionStatusDetails.append(row); } - elements.extensionStatusDetails.hidden = status.state === "ready" && status.errors.length === 0; + for (const error of status.runtimeErrors || []) { + const row = document.createElement("div"); + row.className = "extensionStatusError"; + const time = Number.isNaN(Date.parse(error.timestamp)) ? error.timestamp : new Date(error.timestamp).toLocaleString(); + row.textContent = `${error.path} · ${error.event} · ${time}: ${error.error}`; + elements.extensionStatusDetails.append(row); + } + elements.extensionStatusDetails.hidden = status.state === "ready" && status.errors.length === 0 && !status.runtimeErrors?.length; } function renderExtensionStatusError(error: unknown) { diff --git a/tests/e2e/git.spec.ts b/tests/e2e/git.spec.ts index cab71b3..1920653 100644 --- a/tests/e2e/git.spec.ts +++ b/tests/e2e/git.spec.ts @@ -52,6 +52,35 @@ test("GitHub issue numbers attach issue details to the composer context", async await page.unrouteAll({ behavior: "wait" }); }); +test("Git-tab invalidation ignores an older in-flight response", async ({ page }) => { + await page.request.post("/api/mock/state", { data: { + webContributions: [{ version: 1, key: "github", slot: "git-tab", kind: "rendered", title: "GitHub issues", label: "GitHub" }], + } }); + let requestCount = 0; + let releaseOld!: () => void; + const oldPending = new Promise((resolve) => { releaseOld = resolve; }); + await page.route("**/api/web-contributions/invoke", async (route) => { + requestCount += 1; + if (requestCount === 1) { + await oldPending; + await route.fulfill({ json: { ok: true, html: '
    old
    ' } }); + return; + } + await route.fulfill({ json: { ok: true, html: '
    fresh
    ' } }); + }); + + await page.goto("/"); + await page.locator("#sessionInfoButton").click(); + await page.locator("#sessionInfoGit").click(); + await page.locator(".gitExtensionTab", { hasText: "GitHub" }).click(); + await expect.poll(() => requestCount).toBe(1); + await page.request.post("/api/mock/event", { data: { type: "web_contribution_updated", sessionId: "mock-current", key: "github" } }); + await expect(page.locator(".gitRevision")).toHaveText("fresh"); + releaseOld(); + await expect.poll(() => requestCount).toBe(2); + await expect(page.locator(".gitRevision")).toHaveText("fresh"); +}); + test("extension tabs remain available in split view with a reduced viewport height", async ({ page }) => { await page.setViewportSize({ width: 900, height: 500 }); await page.request.post("/api/mock/state", { data: { diff --git a/tests/e2e/web-panel.spec.ts b/tests/e2e/web-panel.spec.ts index 318bae3..aa98b79 100644 --- a/tests/e2e/web-panel.spec.ts +++ b/tests/e2e/web-panel.spec.ts @@ -57,8 +57,14 @@ test("opens an extension panel from the FAB and submits its form", async ({ page await expect(panel.getByRole("status")).toContainText("revision 1"); const invokesBeforeUpdate = invocations.length; + await panel.locator("textarea").fill("Unsubmitted draft"); revision = 2; await page.request.post("/api/mock/event", { data: { type: "web_contribution_updated", sessionId: "mock-current", key: "notes" } }); + await page.waitForTimeout(100); + await expect(panel.locator("textarea")).toHaveValue("Unsubmitted draft"); + expect(invocations.length).toBe(invokesBeforeUpdate); + + await page.locator("#prompt").focus(); await expect(panel.getByRole("status")).toContainText("revision 2"); expect(invocations.length).toBe(invokesBeforeUpdate + 1); diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index 8583b6b..25d23ed 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -167,6 +167,7 @@ describe("bundled extension path discovery", () => { it("publishes normalized contributions and emits pull invalidations", async () => { let ui: any; + let bindOptions: any; const emitted: any[] = []; const bridge = createWebUiBridge({ emit: (value) => emitted.push(value), clientCount: () => 1, acquireWorkLease: () => () => undefined, @@ -174,10 +175,27 @@ describe("bundled extension path discovery", () => { }); const session = { sessionId: "session", sessionFile: "/tmp/session.jsonl", agent: { waitForIdle: async () => undefined }, - bindExtensions: async (options: any) => { ui = options.uiContext; }, + bindExtensions: async (options: any) => { ui = options.uiContext; bindOptions = options; }, }; await bridge.bind(session); + expect(ui.web.capabilities).toEqual({ + apiVersion: 1, + slots: ["footer", "header-action", "artifact-action", "git-tab", "panel", "fab"], + kinds: ["static", "rendered"], + effects: ["open-panel"], + }); + expect(Object.isFrozen(ui.web.capabilities)).toBe(true); + expect(Object.isFrozen(ui.web.capabilities.slots)).toBe(true); + + bindOptions.onError({ extensionPath: "/tmp/broken.ts", eventName: "session_start", error: new Error("registration failed") }); + expect(bridge.runtimeErrors(session)).toEqual([ + expect.objectContaining({ path: "/tmp/broken.ts", event: "session_start", error: "registration failed" }), + ]); + for (let index = 0; index < 21; index++) bindOptions.onError({ extensionPath: "/tmp/noisy.ts", eventName: "turn_start", error: `failure ${index}` }); + expect(bridge.runtimeErrors(session)).toHaveLength(20); + expect(bridge.runtimeErrors(session).at(-1)).toMatchObject({ error: "failure 20" }); + let revision = 1; ui.web.contribute("status", { slot: "panel", kind: "rendered", title: "Worker status",