import { fileURLToPath } from "node:url"; import * as ai from "@earendil-works/pi-ai"; import type { ExtensionAPI, ToolDefinition, } from "@earendil-works/pi-coding-agent"; import * as sdk from "@earendil-works/pi-coding-agent"; import * as tui from "@earendil-works/pi-tui"; import * as schema from "typebox"; import type { EphemletRuntime } from "./runtime.ts"; import { closeDebug, dbg } from "./src/debug.ts"; import { ephemletRenderers } from "./src/tool-renderer.ts"; export default function registerEphemlets(pi: ExtensionAPI): void { let runtime: Promise | undefined; let generation = 0; let activeBeforeHide = false; const get = () => (runtime ??= import("jiti") .then(async ({ createJiti }) => { // Pi aliases are unavailable to native lazy chunks; preserve them in this lazy loader. const loader = createJiti(import.meta.url, { fsCache: false, moduleCache: false, tryNative: false, virtualModules: { "@earendil-works/pi-ai": ai, "@earendil-works/pi-coding-agent": sdk, "@earendil-works/pi-tui": tui, typebox: schema, }, }); const path = fileURLToPath( new URL( import.meta.url.endsWith(".ts") ? "./runtime.ts" : "./runtime.js", import.meta.url, ), ); const { EphemletRuntime: Runtime } = await loader.import(path); return new Runtime(pi, dbg); }) .catch((error: unknown) => { runtime = undefined; throw error; })); pi.on("session_start", (_event, ctx) => { const current = ++generation; const active = pi.getActiveTools(); tool.exposure = ctx.mode === "tui" ? "model-only" : "hidden"; pi.registerTool(tool); if (ctx.mode === "tui" && activeBeforeHide && !active.includes("ephemlet")) pi.setActiveTools([...active, "ephemlet"]); if (ctx.mode !== "tui") { activeBeforeHide ||= active.includes("ephemlet"); if (active.includes("ephemlet")) pi.setActiveTools(active.filter((name) => name !== "ephemlet")); if (runtime) void runtime.then((host) => host.dispose()).catch(() => {}); return; } activeBeforeHide = false; dbg?.("session.start"); const hasEphemlets = ctx.sessionManager .getBranch() .some( (entry) => entry.type === "custom" && entry.customType === "ephemlets", ); if (runtime || hasEphemlets) { ctx.ui.setStatus("ephemlets", "Restoring ephemlets…"); void get() .then(async (host) => { if (current === generation) await host.restore(ctx); }) .catch((error: unknown) => { if (current === generation) ctx.ui.notify(`Ephemlet restore failed: ${String(error)}`, "error"); }) .finally(() => { if (current === generation) ctx.ui.setStatus("ephemlets", undefined); }); } }); pi.on("session_tree", async (_event, ctx) => { ++generation; if (ctx.mode === "tui") ctx.ui.setStatus("ephemlets", undefined); if (ctx.mode === "tui" && runtime) await (await runtime).restore(ctx); }); pi.on("before_agent_start", async (_event, ctx) => { if (ctx.mode === "tui" && runtime) (await runtime).setContext(ctx); }); pi.on("session_shutdown", async () => { ++generation; if (runtime) (await runtime).dispose(); dbg?.("session.shutdown"); closeDebug(); }); pi.registerShortcut("alt+e", { description: "Hide/show ephemlet panel", handler: async (ctx) => { if (ctx.mode === "tui") await (await get()).toggle(ctx); }, }); pi.registerShortcut("ctrl+alt+e", { description: "Focus ephemlet panel", handler: async (ctx) => { if (ctx.mode === "tui") await (await get()).show(ctx); }, }); pi.registerCommand("ephemlets", { description: "Toggle ephemlets, or show/hide/focus panel", getArgumentCompletions(prefix) { return ["show", "hide", "focus"] .filter((value) => value.startsWith(prefix)) .map((value) => ({ value, label: value })); }, handler: async (args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("Ephemlets require interactive TUI mode", "warning"); return; } const host = await get(); if (args === "show" || args === "focus") await host.show(ctx); else if (args === "hide") await host.hide(ctx); else if (!args) await host.toggle(ctx); else ctx.ui.notify("Use /ephemlets [show|hide|focus]", "warning"); }, }); // Declare capabilities eagerly; load domain implementation/compiler only on demand. const tool: ToolDefinition, unknown> = { name: "ephemlet", annotations: { readOnlyHint: false }, exposure: "model-only", label: "Ephemlet", description: "Create and update session-persistent Markdown reports or native interactive Pi UI without /reload. Use list to discover IDs and authoring guidance; get returns bounded metadata unless include requests source/state. Put writes source directly; build delegates interactive construction using API tokens. State replaces JSON state; delete removes an ephemlet from the current branch; show/hide controls visibility. Available only in the interactive TUI.", promptSnippet: "Present living task reports and purpose-built native interactive UI", promptGuidelines: [ "Use ephemlet for substantial task reports (subgoals, decisions, forks, failed attempts, verified and omitted work) or useful interactions; the main agent owns intent and authoritative facts. Markdown reports need no builder.", 'Discover existing ephemlets with list on resume or before creating duplicates. Reuse IDs; read get for the current revision before put/build/state updates, which require expectedRevision. Request include: ["source"] or ["state"] only when needed; metadata is the default.', "Before directly authoring interactive source, use the guidance path returned by list and read it. Components are trusted native code with Pi's permissions; never embed credentials in source/state. Build uses this guidance automatically.", 'The agent cannot see the rendered panel. Read include: ["state"] when task decisions need current user interaction data; local state changes do not automatically send conversation messages.', "Build is asynchronous by default and returns ID/jobId, not completed interaction. Ready/failed notices do not start a new turn; inspect get before claiming success. Use wait: true only when the result is needed before continuing.", "Hide preserves an ephemlet; Close/delete removes it from the active branch. Save queues an exact snapshot as a user follow-up for the main agent to preserve as a durable deliverable; it does not export a file itself.", ], executionMode: "sequential", constrainedSampling: { type: "json_schema", strict: "prefer" }, parameters: parameters(), outputSchema: outputSchema(), ...ephemletRenderers, async execute(_id, params, signal, _onUpdate, ctx) { if (ctx.mode !== "tui") throw new Error("Ephemlets require interactive TUI mode"); return (await get()).execute(params, ctx, signal); }, }; pi.registerTool(tool); } import { Type } from "typebox"; function jsonSchema() { return Type.Union([ Type.Null(), Type.Boolean(), Type.Number(), Type.String(), Type.Array(Type.Ref("#/$defs/json")), Type.Object({}, { additionalProperties: Type.Ref("#/$defs/json") }), ]); } function parameters() { return Type.Object( { action: Type.Union( ( [ "put", "build", "list", "get", "state", "delete", "show", "hide", ] as const ).map((value) => Type.Literal(value)), { description: "Operation; get reads metadata, state replaces durable JSON.", }, ), id: Type.Optional( Type.String({ minLength: 1, maxLength: 80, description: "Stable ID; required for get/state/delete and existing updates.", }), ), expectedRevision: Type.Optional( Type.Integer({ minimum: 1, description: "Current revision from get; required for existing put/build/state updates.", }), ), title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })), purpose: Type.Optional(Type.String({ minLength: 1, maxLength: 4096 })), kind: Type.Optional( Type.Union([Type.Literal("markdown"), Type.Literal("interactive")]), ), source: Type.Optional( Type.String({ maxLength: 131072, description: "Markdown or complete native TypeScript module source", }), ), state: Type.Optional( Type.Ref("#/$defs/json", { description: "JSON-serializable durable data/state; required for state action.", }), ), include: Type.Optional( Type.Array( Type.Union([Type.Literal("source"), Type.Literal("state")]), { minItems: 1, maxItems: 2, uniqueItems: true, description: "Get only: explicitly include source and/or state; rollback internals are never returned.", }, ), ), brief: Type.Optional( Type.String({ maxLength: 32768, description: "Authoritative interaction intent/facts for builder", }), ), wait: Type.Optional( Type.Boolean({ description: "Await builder; default false (background)", }), ), model: Type.Optional( Type.String({ maxLength: 256, description: "Optional builder provider/model; default current model", }), ), }, { $defs: { json: jsonSchema() } }, ); } function outputSchema() { const ephemlet = Type.Object({ id: Type.String(), title: Type.String(), purpose: Type.String(), kind: Type.Union([Type.Literal("markdown"), Type.Literal("interactive")]), revision: Type.Integer({ minimum: 1 }), status: Type.Union([ Type.Literal("ready"), Type.Literal("building"), Type.Literal("failed"), ]), error: Type.Union([Type.String(), Type.Null()]), jobId: Type.Union([Type.String(), Type.Null()]), source: Type.Optional(Type.String()), state: Type.Optional(Type.Ref("#/$defs/json")), }); return Type.Union( [ ephemlet, Type.Object({ ephemlets: Type.Array(ephemlet), guidance: Type.String() }), Type.Object({ visible: Type.Boolean(), selected: Type.Union([Type.String(), Type.Null()]), }), Type.Object({ deleted: Type.String() }), ], { $defs: { json: jsonSchema() } }, ); }