// Extensions: installed extension-kind capabilities resolved to their manifests; extension updates: what the registry // check found and what the owner decided to do about it. import { z } from "zod"; import { ExtensionManifestSchema } from "@intentic/extension-manifest"; import { RegistryTrustSchema } from "@intentic/registry"; // Each extension is addressed by its capability entry id (git-installed) or its manifest's `publisher.name` // (image-baked, no entry), hence the dot allowed here. A rotted checkout is skipped; its capability row still shows // status. const extensionId = z .string() .min(1) .max(121) .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/); // Owner's per-extension update posture. // updates: notify (default, badge and wait), agent (also prepares an agent diff-read), auto (apply unattended if // verified and powers didn't grow, health-watched with auto-revert; otherwise falls back to notify). // advisories: auto-disable (default, since disabling runs no new code) or notify. export const ExtensionUpdatePolicySchema = z.object({ updates: z.enum(["notify", "agent", "auto"]), advisories: z.enum(["auto-disable", "notify"]), }); export type ExtensionUpdatePolicy = z.infer; // A newer sha the registry lists, the update-available badge's substance. `url`/`path` ride along because updating // follows this row as it stands now, not the original install. export const ExtensionUpdateSchema = z.object({ ref: z.string().describe("The commit being offered."), version: z.string().optional().describe("What it calls itself."), url: z.string().describe("Where it comes from."), path: z.string().optional().describe("Where inside that repository it lives."), // The registry's trust, less `blocked`: a blocked row is an advisory, never an update on offer. trust: RegistryTrustSchema.exclude(["blocked"]).describe("Whether anybody vouched for it, or it is merely listed."), // The badge renders loud (urgent) when this is set. securityFix: z .boolean() .optional() .describe("This release fixes a security problem in earlier ones, so here the old version is the dangerous one."), registry: z.string().describe("Which registry said so."), at: z.string().describe("When it was published."), // The card leads with this, so the owner knows why the decision is theirs. needsReview: z .string() .optional() .describe( "Why this one was not taken automatically and is asking for a person instead: it wants more than it used to, or nobody has vouched for it.", ), review: z .object({ conversationId: z.string().describe("Where to read what it found."), at: z.string().describe("When it looked."), }) .optional() .describe( "An agent has already read the difference between what is installed and this, so the card can link to what it found rather than offer to start looking.", ), }); export type ExtensionUpdate = z.infer; // This installed extension was delisted; the record is for someone still running it. export const ExtensionAdvisorySchema = z.object({ reason: z .string() .describe( "Why the registry pulled the listing, in its own words. Delisting protects people browsing; this record is for the person already running it.", ), registry: z.string().describe("Which registry said so."), at: z.string().describe("When."), autoDisabled: z.boolean().describe("Whether the sandbox has already switched it off."), }); export type ExtensionAdvisory = z.infer; // Checks that the new version's declared autoStart processes and backend actually came up. export const ExtensionHealthSchema = z.object({ state: z .enum(["watching", "healthy", "unhealthy"]) .describe("How it has behaved since the last update. Checks catch broken, not wrong, so for a while after a swap it is simply watched."), detail: z.string().optional().describe("What is going wrong, when something is."), fromRef: z.string().optional().describe("Which version it was updated from, which is what going back would return to."), at: z.string().describe("When the watching started."), autoReverted: z .boolean() .optional() .describe("The update was already rolled back without anybody asking. The record stays rather than pretending the attempt never happened."), }); export type ExtensionHealth = z.infer; // Mechanical comparison of two manifests' declared reach (`diffPowers`), as plain sentences the update dialog can // render directly. export const PowersDiffSchema = z.object({ added: z.array(z.string()).describe("What the new version asks for that the running one does not. The whole point of the comparison."), removed: z.array(z.string()).describe("What it no longer asks for."), unchanged: z.array(z.string()).describe("What stays the same."), }); export type PowersDiff = z.infer; // What an owner reads before clicking Update: the offered version's story, the engines verdict, and its powers diff // against what is installed. export const ExtensionUpdateActionSchema = z.object({ id: extensionId.describe("Which extension."), ref: z .string() .regex(/^[0-9a-f]{40}$/) .optional() .describe("Which commit, in full. Leave it out for whatever the last check found, which is what most callers mean."), }); export const ExtensionUpdatePreviewSchema = z.object({ ref: z.string().describe("The commit this would install."), version: z.string().describe("What that version calls itself."), installedVersion: z.string().describe("What is running now."), engines: z.string().describe("Which sandbox versions the new one says it needs."), compatible: z.boolean().describe("Whether this sandbox is one of them."), powers: PowersDiffSchema.describe( "Exactly what the new code asks for that the running one does not. This is what approving an update is approving.", ), }); // Environment fragment change that alters the composed overlay; the update is not wholly landed until rebuilt. export const ExtensionUpdateAppliedSchema = z.object({ ok: z.literal(true).describe("It went through."), ref: z.string().describe("Which commit is now running."), rebuildNeeded: z .boolean() .optional() .describe( "The new version changes what the sandbox image contains, so a one-time rebuild is still pending and the update is not wholly landed yet.", ), }); export const ExtensionUpdatePolicyInputSchema = z.object({ id: extensionId.describe("Which extension."), updates: z .enum(["notify", "agent", "auto"]) .optional() .describe("What to do about a newer version: tell you, have an agent read the difference first, or just take it."), advisories: z.enum(["auto-disable", "notify"]).optional().describe("What to do about a security warning: switch it off at once, or tell you."), }); export const ExtensionUpdatesCheckedSchema = z.object({ ok: z.literal(true).describe("The check ran."), checkedAt: z.string().describe("When, so a screen can date the answer."), }); // Dev mode: an extension installed from a repository, served from a source checkout in this workspace instead of its // pinned clone (.intentic/local/extension-dev.json), so a change to its code is seen on reload without being landed. // `path` and `conversation` are kept apart rather than joined into one display string: a screen says "in conversation // " in its own words. export const ExtensionDevSchema = z.object({ path: z .string() .describe( "The source checkout it was pointed at, such as extensions/maintenance: relative to the workspace, or to the conversation's own copy of it when `conversation` is set.", ), conversation: z .string() .optional() .describe( "The conversation whose isolated copy of the workspace holds that checkout. Absent means the workspace itself. That copy goes when the conversation does, and the pinned version runs again then.", ), uncommitted: z .number() .int() .nonnegative() .optional() .describe("How many files in the checkout differ from its last commit, a rebuilt bundle included. Absent when git could not say."), revision: z .string() .optional() .describe( "A short fingerprint of the browser bundle served from the checkout. It changes with every rebuild, which is how a screen tells the copy it loaded is behind. Absent while held, and for an extension with no browser half.", ), held: z .string() .optional() .describe( "Why the checkout is not what runs, as a sentence: it is gone, holds a different extension, is not built yet, or asks for powers the pinned version was never approved for. Present means the pinned version still runs.", ), }); export type ExtensionDev = z.infer; export const ExtensionSummarySchema = z.object({ id: extensionId.describe("The extension's id."), manifest: ExtensionManifestSchema.describe("What it declares about itself: what it contributes, what it needs, and what it may reach."), commit: z.string().describe("Exactly which commit is installed."), // builtin: hides the uninstall affordance. // installed: shows the pinned commit. // workspace: "uninstalled" by deleting its directory. source: z .enum(["builtin", "installed", "workspace"]) .describe( "Where the code comes from: baked into the sandbox image and not removable, installed from a repository at a pinned commit, or written in this workspace and edited in place.", ), // Persisted at .intentic/config/extension-enablement.json. enabled: z .boolean() .describe( "The owner's switch. A switched-off extension is still listed, which is what makes it switchable back on, but nothing it contributes is wired up.", ), essential: z .boolean() .optional() .describe( "Its switch is fixed on, because it is the only way to see or stop an engine the sandbox runs regardless. Hiding that page would not stop the spending, only your ability to notice it. Declared by the core about its own surfaces, never by an extension about itself, which would be a pack making itself un-removable.", ), usage: z .record( z.string(), z.object({ calls: z.number().int().nonnegative().describe("How many times."), last: z.string().describe("When, most recently.") }), ) .optional() .describe( "How much of the reach it asked for it has actually used, keyed by what it declared. Absent means never observed doing anything, which is a different claim from uses none of them, and the two have to stay tellable apart: reading either as these permissions are unnecessary turns evidence into a guess with a number on it.", ), // starting/stopped mean the host itself is between states. backend: z .object({ state: z .enum(["running", "degraded", "error", "absent", "incompatible", "starting", "stopped"]) .describe( "How its server half is doing. Degraded means it serves but says something it relies on is not working; absent means the code is not in this image at all; incompatible means it needs a different sandbox version.", ), detail: z .string() .optional() .describe("What went wrong, so a backend that failed to start is a sentence rather than an address that answers nothing."), }) .optional() .describe("Present only for an extension that ships a server half."), problems: z .array(z.string()) .optional() .describe( "Declarations in its manifest the sandbox refused at load, each a sentence saying what and why, such as a listener for a provider another extension already owns. The rest of it still loads. Absent when nothing was refused.", ), // Update lifecycle, present only for a git-installed extension (absent for builtin/workspace): // update: the badge. // advisory: the alarm. // health: the after-the-click watch. // previous: the way back (kept one-back checkout's sha). // updatePolicy: the owner's standing answer. update: ExtensionUpdateSchema.optional().describe( "A newer version waiting. All five of these exist only for one installed from a repository: a built-in updates with the image and one written here is edited live.", ), advisory: ExtensionAdvisorySchema.optional().describe("A security warning about the installed version."), health: ExtensionHealthSchema.optional().describe("How it has behaved since the last update, which is what decides whether that update sticks."), previous: z .object({ ref: z.string().describe("The commit that was running before."), version: z.string().optional().describe("What it called itself."), }) .optional() .describe("The version kept one step back, which is what going back means."), updatePolicy: ExtensionUpdatePolicySchema.optional().describe( "The owner's standing answer for this one: tell me, have an agent look, or just do it.", ), dev: ExtensionDevSchema.optional().describe( "Set while an extension installed from a repository is pointed at a source checkout in this workspace, for working on the extension itself: its code is served from there instead of the pinned copy. `commit` keeps naming the pinned version, which is what updates and the registry compare against.", ), }); export type ExtensionSummary = z.infer; // The dev-mode verbs address an installed extension any of the ways a person names one. const devTarget = z .string() .min(1) .describe( "Which installed extension: its connection id (intentic-maintenance), its manifest id (intentic.maintenance) or its short name (maintenance).", ); export const ExtensionDevSetInputSchema = z.object({ id: devTarget, path: z .string() .min(1) .optional() .describe( "The checkout to run it from. From an agent in an isolated conversation, a relative or /work path reads against that conversation's own copy. Absent: the workspace repository cloned from the address it was installed from, the calling conversation's copy first.", ), }); export const ExtensionDevTargetSchema = z.object({ id: devTarget }); export const ExtensionDevStateSchema = z.object({ id: extensionId.describe("Its connection id."), name: z.string().describe("Its manifest id, publisher.name."), dev: ExtensionDevSchema.describe("Where it runs from now, or why the pinned version still runs."), }); export const ExtensionDevClearedSchema = z.object({ id: extensionId.describe("Its connection id."), name: z.string().describe("Its manifest id, publisher.name."), cleared: z.boolean().describe("Whether it was pointed at a checkout. False means it already ran its pinned version and nothing changed."), }); export const ExtensionDevListSchema = z.object({ extensions: z .array( z.object({ id: extensionId.describe("Its connection id."), name: z.string().describe("Its manifest id, publisher.name."), commit: z.string().describe("The pinned commit, in full."), dev: ExtensionDevSchema.optional().describe("Present while it is pointed at a source checkout."), checkout: z .object({ path: z.string().describe("Relative to the workspace, or to the conversation's copy of it when `conversation` is set."), conversation: z.string().optional().describe("Set when the calling conversation's own copy is the one found."), }) .optional() .describe( "A checkout of its source repository in the workspace, which is where a change to it belongs. Absent when the workspace has none.", ), }), ) .describe("Every extension installed from a repository. Built-in and workspace extensions are not here: they already run from their source."), }); // Read by both the Extensions tab and an authoring agent off GET /extensions. export const InvalidWorkspaceExtensionSchema = z.object({ dir: z.string().describe("Which folder."), error: z.string().describe("Why it could not be read."), }); export type InvalidWorkspaceExtension = z.infer; // A workspace extension nobody has approved in its current shape. It stands in for the install moment a folder // otherwise never has: until the owner says yes, none of its code runs and none of its contributions are wired. export const PendingWorkspaceExtensionSchema = z.object({ id: extensionId.describe("The extension's id."), dir: z.string().describe("Which folder under .intentic/config/workspace-extensions/."), manifest: ExtensionManifestSchema.describe("What it declares about itself."), powers: PowersDiffSchema.describe( "What saying yes allows, as plain sentences. Against what was approved before when something was: `added` is what it asks for now that it did not then. Never approved before, everything it declares is `added`.", ), approvedBefore: z .boolean() .describe("An earlier shape of it was approved, and the powers it declares have changed since, which is what put it back here."), digest: z .string() .regex(/^[0-9a-f]{64}$/) .describe("The fingerprint of the powers shown, sent back with the approval so a change made while you were reading is caught rather than approved."), }); export type PendingWorkspaceExtension = z.infer; export const ExtensionApproveInputSchema = z.object({ id: extensionId.describe("Which extension."), digest: z .string() .regex(/^[0-9a-f]{64}$/) .describe("The fingerprint of the powers you read, from the pending list. A mismatch means they changed since, and nothing is approved."), }); export const ExtensionsListSchema = z.object({ extensions: z.array(ExtensionSummarySchema).describe("What is installed."), invalid: z .array(InvalidWorkspaceExtensionSchema) .describe( "Extensions written here that could not be read at all. Listed rather than dropped, because there is no install moment at which to reject a broken one, so this is its only way of saying anything.", ), pending: z .array(PendingWorkspaceExtensionSchema) .describe( "Extensions written in this workspace that wait for the owner's approval before anything of theirs runs: never approved, or approved when they declared less than they do now.", ), updatesCheckedAt: z .string() .optional() .describe( "When updates were last looked for. Absent until the first check has run. Sent so a screen can say checked an hour ago rather than presenting staleness as certainty.", ), }); // Persisted at .intentic/config/extension-settings.json, keyed by extension id so a re-clone never loses them. Secret // values are stripped from `settings`; `secretsSet` names which ones are set. export const ExtensionSettingsSchema = z.object({ settings: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe("The values, minus anything marked secret."), secretsSet: z .array(z.string()) .describe("Which of its secret settings actually hold a value. Names only: the values themselves never come back."), }); export type ExtensionSettings = z.infer; export const ExtensionSettingsInputSchema = z.object({ id: z.string().describe("Which extension."), settings: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) .describe("The values to write. A key the extension never declared is refused rather than quietly stored."), }); // Persisted by publisher.name (like settings) so the choice outlives the checkout; declared processes converge in the // same handler. export const ExtensionEnabledInputSchema = z.object({ id: z.string().describe("Which extension."), enabled: z.boolean().describe("On or off."), }); // Identity only; what gets written is the daemon's decision, not a form filled in — shaping happens by editing the // files afterward. Slugs match the manifest schema's shape, re-checked here because `name` becomes a directory. export const WorkspaceExtensionCreateSchema = z.object({ publisher: z .string() .regex(/^[a-z0-9][a-z0-9-]*$/) .describe("Who it is by, which together with the name makes its id."), name: z .string() .regex(/^[a-z0-9][a-z0-9-]*$/) .describe("What it is called."), }); // `dir` is workspace-root-relative, so the caller can name files to open next. export const WorkspaceExtensionCreatedSchema = z.object({ id: z.string().describe("The id it was given."), dir: z.string().describe("Where its files are, so you can open them."), }); // One connection the owner configured from a card this extension supplies. It goes when the extension does: its form, // its skill file and its environment all come out of the extension's own checkout, so leaving the entry behind would // leave a connection nothing can read, edit or teach an agent to use. export const ExtensionRemovalConnectionSchema = z.object({ id: z.string().describe("The name the owner gave it, which is also the agent's handle for it."), kind: z.string().describe("Which core kind it is underneath: cli, browser, host or webext."), entry: z.string().describe("The catalog entry it was added from, named as the grid names it."), secrets: z .array(z.string()) .describe("Credential fields stored for it, by name. The values are deleted with the entry and cannot be recovered from here."), effect: z.string().describe("What tearing it down actually takes away, in one sentence."), }); export type ExtensionRemovalConnection = z.infer; // Everything removing one extension would destroy, read before the decision rather than reported after it. A plan is // always answerable: `blocked` carries the refusal for an extension that cannot be removed at all, so the reason is // readable on the same screen as the button. export const ExtensionRemovalPlanSchema = z.object({ id: extensionId.describe("The extension's id, as the list addresses it."), name: z.string().describe("Its publisher.name identity, which is the key its settings and switch are stored under."), version: z.string().describe("The version being removed."), source: z.enum(["builtin", "installed", "workspace"]).describe("Where its code comes from, which decides what removal means."), blocked: z .string() .optional() .describe("Why this one cannot be removed, when it cannot. Present means every other field is what would go if it could."), files: z .array(z.object({ path: z.string().describe("Workspace-relative."), detail: z.string().describe("What is in there.") })) .describe("Directories deleted outright. For an extension written here this is the owner's own source, which nothing else keeps a copy of."), connections: z.array(ExtensionRemovalConnectionSchema).describe("Connections configured from its cards, which are removed with it."), settings: z .array(z.object({ key: z.string().describe("Which setting."), secret: z.boolean().describe("Whether its value is a stored credential.") })) .describe("Values the owner entered for this extension that are forgotten. Only keys actually holding a value are listed."), processes: z.array(z.string()).describe("Background processes it declared, stopped before its files go."), automations: z .array(z.string()) .describe( "Automations of the owner's own that wake on a listener this extension provides. They are NOT removed, and are listed because they stop firing, which is the sort of thing a removal is otherwise discovered by.", ), rebuildNeeded: z .boolean() .describe("It bakes a layer into the sandbox image, so what it added to the image is only gone after the next environment rebuild."), keeps: z.array(z.string()).describe("What removal deliberately leaves alone, so the list of what goes can be read as complete."), }); export type ExtensionRemovalPlan = z.infer; export const ExtensionRemovedSchema = z.object({ ok: z.literal(true).describe("It is gone."), connections: z.array(z.string()).describe("Which configured connections went with it, by name."), rebuildNeeded: z .boolean() .optional() .describe("Its image layer is still in the running sandbox until the next environment rebuild; nothing else is pending."), }); // Extension id → declared entry → count since last report; counts, not events, and declared entries, not paths, since // this answers whether a permission is earned, not logs activity. One request per batch across all extensions. export const ExtensionUsageBatchSchema = z.object({ reports: z .record(z.string(), z.record(z.string(), z.number().int().positive())) .describe("Each extension that called something, and the counts against the declared powers it exercised."), }); // One declared background process, addressed by capability entry id + process name; an undeclared name is NOT_FOUND. export const ExtensionProcessParamSchema = z.object({ id: z.string().describe("Which extension."), name: z.string().describe("Which of its declared processes."), }); export const ExtensionProcessStatusSchema = z.object({ name: z.string().describe("Which process."), running: z.boolean().describe("Whether it is up. False with a port means it crashed and the supervisor is waiting to retry it."), port: z.number().optional().describe("The port it was given."), restarts: z.number().optional().describe("How many times it died and was brought back since it was started. A growing number is a service in trouble."), lastExitCode: z.number().optional().describe("How it last exited, when it has crashed at least once."), previewUrl: z.string().optional().describe("Where to open it, when it has an address."), }); export type ExtensionProcessStatus = z.infer;