import { procedure } from "../protocol/route-meta.js"; import { CapabilityIdParamSchema } from "../schemas/capabilities.js"; import { ExtensionApproveInputSchema, ExtensionDevClearedSchema, ExtensionDevListSchema, ExtensionDevSetInputSchema, ExtensionDevStateSchema, ExtensionDevTargetSchema, ExtensionEnabledInputSchema, ExtensionProcessParamSchema, ExtensionProcessStatusSchema, ExtensionRemovalPlanSchema, ExtensionRemovedSchema, ExtensionSettingsInputSchema, ExtensionSettingsSchema, ExtensionsListSchema, ExtensionUpdateActionSchema, ExtensionUpdateAppliedSchema, ExtensionUpdatePolicyInputSchema, ExtensionUpdatePreviewSchema, ExtensionUpdatesCheckedSchema, ExtensionUsageBatchSchema, WorkspaceExtensionCreatedSchema, WorkspaceExtensionCreateSchema, } from "../schemas/extension-updates.js"; import { ExtensionReadinessSchema } from "../schemas/maintenance.js"; import { OkSchema } from "../schemas/shared.js"; // Dev mode's verbs: the agent's `extension` CLI drives them on the agent token, and the Extensions tab's way back // calls devClear. None reads or moves a credential. const devRoute = procedure.meta({ agent: true }); // Installed extensions resolved to their approved manifests. The bundle itself is a plain Hono route, `GET // /extensions/{id}/bundle`, not part of this oRPC contract: raw ESM bytes aren't a JSON payload. export const extensionsContract = { list: procedure .route({ method: "GET", path: "/extensions", summary: "Installed extensions", description: "Every extension installed here, resolved to the manifest the owner approved, which is what the app boots its extension host from. The code itself is served separately, because raw script bytes are not a JSON answer.", }) .output(ExtensionsListSchema), // Writes into `.intentic/config/workspace-extensions//`. create: procedure .route({ method: "POST", path: "/extensions/workspace", summary: "Write a new extension in place", description: "Scaffolds a working extension into this workspace and installs it. The only call here that creates one, and it exists because that folder is otherwise reachable only through an agent's file tools, which is a fine way to change an extension and a poor way to meet the idea of one.", }) .input(WorkspaceExtensionCreateSchema) .output(WorkspaceExtensionCreatedSchema), // Read the plan first, then send the removal: the plan is the only place the owner is told what else goes, and the // connections it names are not derivable from the manifest alone. removalPlan: procedure .route({ method: "GET", path: "/extensions/{id}/removal", summary: "What removing an extension would take away", description: "Everything one removal destroys, before it happens: the files deleted, the connections configured from its cards, the settings and credentials forgotten, the background processes stopped, and the owner's own automations that quietly stop firing. Also answerable for an extension that cannot be removed, in which case it says why.", }) .input(CapabilityIdParamSchema) .output(ExtensionRemovalPlanSchema), remove: procedure .route({ method: "POST", path: "/extensions/{id}/remove", summary: "Remove an extension", description: "Uninstalls it and everything that only existed because it was here: the connections added from its cards, with their stored credentials, its settings, its switch and its update record. What the owner made with it — automations, files in the workspace — is left alone. Owner only, for the same reason installing is. Built-in extensions cannot be removed; switch them off instead.", }) .input(CapabilityIdParamSchema) .output(ExtensionRemovedSchema), settings: procedure .route({ method: "GET", path: "/extensions/{id}/settings", summary: "An extension's settings", description: "The current values for the settings this extension declared it has.", }) .input(CapabilityIdParamSchema) .output(ExtensionSettingsSchema), setSettings: procedure .route({ method: "POST", path: "/extensions/{id}/settings", summary: "Change an extension's settings", description: "Writes new values. A key the extension never declared is refused rather than quietly stored, the same honesty rule that governs everything else an extension claims.", }) .input(ExtensionSettingsInputSchema) .output(OkSchema), // The install moment a workspace extension otherwise never has. Withheld from every machine credential: an agent can // write the folder, so it must not also be able to give the yes that makes it run. approve: procedure .route({ method: "POST", path: "/extensions/{id}/approve", summary: "Let a workspace extension run", description: "Approves an extension written in this workspace with the powers it declares now: its background processes start, its backend loads, and what it contributes is wired from the next turn. Editing its code keeps the approval; declaring a power it did not have puts it back in the pending list. Owner and maintainers only.", }) .meta({ panel: false, control: "never" }) .input(ExtensionApproveInputSchema) .output(OkSchema), // The Extensions tab states which of these apply to a given extension. setEnabled: procedure .route({ method: "POST", path: "/extensions/{id}/enabled", summary: "Turn an extension on or off", description: "The owner's switch. Turning one off stops its background processes at once. What it contributes to an agent's tools is rebuilt at the start of the next turn, and anything it adds to the sandbox image only at the next rebuild.", }) .input(ExtensionEnabledInputSchema) .output(OkSchema), // The permission gate that produces these counts runs in the browser (apiImpl.ts). recordUsage: procedure .route({ method: "POST", path: "/extensions/usage", summary: "Record what extensions just used", description: "One batch written by the app rather than measured by the daemon, because the permission gate runs in the browser: from the sandbox's side extension traffic is indistinguishable from anyone else's. This is how the record of which powers each extension actually exercises gets kept without one reporting request per extension.", }) .input(ExtensionUsageBatchSchema) .output(OkSchema), readiness: procedure .route({ method: "GET", path: "/extensions/{id}/readiness", summary: "Whether an extension is fit to share", description: "The checks that can be answered from an extension's own files, for an author about to publish. Read on demand rather than carried on the list, because it reads the code off disk each time.", }) .input(CapabilityIdParamSchema) .output(ExtensionReadinessSchema), // The extensions list itself carries what the periodic registry check found (update/advisory/health per row); these // routes are the verbs around it. checkUpdates: procedure .route({ method: "POST", path: "/extensions/updates/check", summary: "Look for extension updates now", description: "Compares every installed extension against its source and reports what is newer, what carries an advisory and what looks unhealthy. This also happens on a schedule; call it to check on demand.", }) .output(ExtensionUpdatesCheckedSchema), updatePreview: procedure .route({ method: "POST", path: "/extensions/{id}/update/preview", summary: "What an update would change", description: "The read before the click: which versions are involved and exactly which powers the new code asks for that the running one does not. Costs one throwaway copy of the source, the same as browsing a registry entry.", }) .input(ExtensionUpdateActionSchema) .output(ExtensionUpdatePreviewSchema), applyUpdate: procedure .route({ method: "POST", path: "/extensions/{id}/update", summary: "Update an extension", description: "The whole swap as one transaction: fetch, check, quiet the running one, replace it while keeping the outgoing copy one step back, restart and watch it come up. The existing configuration is kept, so a token for a private source survives what removing and re-adding would lose. Owner only, because it changes what code runs.", }) .input(ExtensionUpdateActionSchema) .output(ExtensionUpdateAppliedSchema), revert: procedure .route({ method: "POST", path: "/extensions/{id}/revert", summary: "Go back to the previous version", description: "Swaps the copy kept from before the last update back into place. Owner only, for the same reason updating is.", }) .input(CapabilityIdParamSchema) .output(ExtensionUpdateAppliedSchema), setUpdatePolicy: procedure .route({ method: "POST", path: "/extensions/{id}/update-policy", summary: "How an extension should handle its own updates", description: "The owner's standing answer for one extension: tell me, have an agent look at it, or just do it. Security advisories can be opted out of separately.", }) .input(ExtensionUpdatePolicyInputSchema) .output(OkSchema), // Declared via `contributes.processes`; the terminals list shows each one's log as `svc-ext--`. processStatus: procedure .route({ method: "GET", path: "/extensions/{id}/processes/{name}", summary: "Whether an extension's background process is up", description: "The state of one process an extension declared, with the port it was given and its preview address if it has one.", }) .input(ExtensionProcessParamSchema) .output(ExtensionProcessStatusSchema), processStart: procedure .route({ method: "POST", path: "/extensions/{id}/processes/{name}/start", summary: "Start an extension's background process", description: "Brings one of an extension's declared processes up in an attachable terminal.", }) .input(ExtensionProcessParamSchema) .output(OkSchema), processStop: procedure .route({ method: "POST", path: "/extensions/{id}/processes/{name}/stop", summary: "Stop an extension's background process", description: "Shuts one of an extension's declared processes down and frees its port.", }) .input(ExtensionProcessParamSchema) .output(OkSchema), // Dev mode (.intentic/local/extension-dev.json): an installed extension served from a source checkout. Same powers // only: a checkout declaring more is held, and the pinned version keeps running. devList: devRoute .route({ method: "GET", path: "/extensions/dev", summary: "Installed extensions and their source checkouts", description: "Every extension installed from a repository, with its pinned commit, whether it runs from a source checkout, and the checkout of its repository this workspace holds, if any. The calling conversation's own copy is named first.", }) .output(ExtensionDevListSchema), devSet: devRoute .route({ method: "POST", path: "/extensions/{id}/dev", summary: "Run an installed extension from its source checkout", description: "Serves the extension's browser bundle, backend, processes, skills and contributions from a checkout of its source instead of its pinned copy, so a change to it shows on reload without being landed first. The checkout must hold the same extension and declare the same powers; one asking for more, or not built yet, is held and the pinned version keeps running, with the reason. Nothing is installed or updated: the pinned copy stays where it is.", }) .input(ExtensionDevSetInputSchema) .output(ExtensionDevStateSchema), devClear: devRoute .route({ method: "POST", path: "/extensions/{id}/dev/clear", summary: "Go back to the pinned version", description: "Stops serving the extension from its source checkout. The checkout itself is left exactly as it is.", }) .input(ExtensionDevTargetSchema) .output(ExtensionDevClearedSchema), devReload: devRoute .route({ method: "POST", path: "/extensions/{id}/dev/reload", summary: "Pick up a rebuilt checkout", description: "Call after rebuilding an extension that runs from its source checkout: its backend restarts on the new code, and an open app is told the list changed, so it offers to reload the new browser bundle.", }) .input(ExtensionDevTargetSchema) .output(ExtensionDevStateSchema), };