/** * Canvas instance registry — owns children, instances, and reaping. * * Design: `docs/canvas-extensions-design.md` §4, §6, §7. One child process per * extension, many instances per child, keyed by `(extensionId, canvasId, * instanceId)` because `joinSession({ canvases: [...] })` takes an array and each * canvas can be opened more than once. * * **Correction to the design doc's §6.** That section called for an "SSE-liveness * heartbeat — an instance with no connected client for N seconds is idle". That is * not implementable. The SSE endpoint and its client set live inside the * extension's own HTTP server (`entry.sseClients` in `pr-artifact-explorer`'s * `server.mjs`); the host never sees them. Learning otherwise would take either * proxying the canvas URL — which breaks the token, origin and CSP model the * extension built — or adding a liveness call to the contract, which breaks tier-2 * portability. Neither is worth it for a reaper. * * So idleness here means something narrower and honest: **time since hoocode last * touched the instance** (opened it, or invoked an action on it). A person reading * a canvas in a browser tab is invisible to us, so a generous timeout is the point * rather than a limitation, and `reapIdle` is advisory cleanup — not a claim about * whether anybody is watching. * * The registry starts no timers. `reapIdle()` is driven by the caller and `now` is * injectable, so lifetime policy belongs to whoever owns the session clock and the * tests do not sleep. */ import type { DiscoveredCanvasExtension } from "./discovery.js"; import type { CanvasActionDeclaration, CanvasDeclaration, JsonValue } from "./protocol.js"; import { type CanvasCallOptions, type CanvasRunnerOptions, type CanvasRuntime } from "./runner.js"; /** Default idle ceiling before an untouched instance is reaped. */ export declare const CANVAS_INSTANCE_IDLE_MS: number; /** Default grace period a child is kept alive after its last instance closes. */ export declare const CANVAS_CHILD_LINGER_MS: number; /** Default cap on concurrent instances of a single canvas. */ export declare const CANVAS_MAX_INSTANCES_PER_CANVAS = 8; /** Stable identity of one open instance. */ export interface CanvasInstanceKey { extensionId: string; canvasId: string; instanceId: string; } /** An open canvas instance. */ export interface CanvasInstance extends CanvasInstanceKey { /** URL the host hands to a browser. */ url: string | undefined; title: string | undefined; status: string | undefined; /** When hoocode last opened this instance or invoked one of its actions. */ lastTouchedAt: number; /** * The `input` this instance was opened with, kept so {@link CanvasRegistry.reload} * can re-open it the same way. `canvas.open` is the only place a canvas is told * what it is opening *onto*, so replaying it is what makes a reload a reload * rather than a fresh, emptier canvas. */ openInput: JsonValue | undefined; } /** * One agent-callable action on an open instance. This is the input the future * tool bridge consumes; nothing registers it as a tool yet, deliberately — that * makes canvases reachable by the agent and must follow the trust gate (§5). */ export interface CanvasActionBinding extends CanvasInstanceKey { action: CanvasActionDeclaration; } /** An instance that did not survive a {@link CanvasRegistry.reload}, and why. */ export interface CanvasReloadDrop { instanceId: string; canvasId: string; reason: string; } /** * How a reload changed what the agent can call. * * Reported because editing a canvas's actions is otherwise invisible. A reload * that only said which canvases exist leaves the one question an author actually * has unanswered — *did the host see the action I just wrote?* — and the answer * matters: a typo in `actions: [...]`, a handler that throws at declaration time, * or an action defined on the wrong canvas all fail by the action simply not * being there. * * `changed` means same name, different declaration — a reworded description or a * reshaped `inputSchema`. That is worth separating from added and removed * because it is the case where a stale `list_canvas_capabilities` result in the * model's context is now wrong rather than merely incomplete. */ export interface CanvasActionDelta { /** `canvasId.actionName`, so a multi-canvas extension stays unambiguous. */ added: string[]; removed: string[]; changed: string[]; /** Everything the extension declares now, in the same form. */ current: string[]; } /** What a {@link CanvasRegistry.reload} did. */ export interface CanvasReloadResult { extensionId: string; /** * Instances that came back, with their **new** urls — the old ones are dead * ports. Instance ids are unchanged. */ reopened: CanvasInstance[]; /** Instances that could not be re-opened. */ dropped: CanvasReloadDrop[]; /** Canvas ids the reloaded extension declares, which the edit may have changed. */ canvases: string[]; /** What the edit did to the action inventory. */ actions: CanvasActionDelta; } /** Diagnostics the registry emits. The host decides how to surface them. */ export interface CanvasRegistryEvents { /** A `session.log` call from an extension. */ onLog?: (extensionId: string, message: string, level: string | undefined) => void; /** A non-protocol stdout line — almost always a stray `console.log`. */ onStray?: (extensionId: string, line: string) => void; /** The child's stderr. */ onStderr?: (extensionId: string, chunk: string) => void; /** Something the host should tell the user about once. */ onDiagnostic?: (extensionId: string, message: string) => void; } /** Registry configuration. */ export interface CanvasRegistryOptions extends CanvasRegistryEvents { runtime: CanvasRuntime; /** * Working directory the trust gate is evaluated against (§5). Required: forking * a canvas that arrived in a clone is exactly what the gate exists to prevent, * so there is no sensible default to fall back to. */ cwd: string; /** Trust-store location. Defaults to the agent dir; injectable for tests. */ agentDir?: string; /** Clock, injectable so idle policy is testable without sleeping. */ now?: () => number; idleTimeoutMs?: number; childLingerMs?: number; /** * Per-method provider-call ceilings, merged over the runner's defaults. * * Plumbed through because the registry is the entry point everything real goes * via: without this the ceilings in `runner.ts` were only reachable by calling * `spawnCanvasExtension` directly, which nothing does. */ requestTimeoutMs?: CanvasRunnerOptions["requestTimeoutMs"]; maxInstancesPerCanvas?: number; /** Instance id generator, injectable for deterministic tests. */ newInstanceId?: () => string; } /** Render a key as a stable string, for maps and messages. */ export declare function canvasInstanceKeyOf(key: CanvasInstanceKey): string; export declare class CanvasRegistry { private readonly children; private readonly instances; private readonly options; private readonly now; private readonly newInstanceId; constructor(options: CanvasRegistryOptions); /** Canvases an extension declares, forking it if it is not already running. */ declarations(extension: DiscoveredCanvasExtension): Promise; /** Open a canvas instance and return what the host needs to render it. */ open(extension: DiscoveredCanvasExtension, canvasId: string, input?: JsonValue, options?: CanvasCallOptions): Promise; /** Invoke an action on an open instance. */ invokeAction(key: CanvasInstanceKey, actionName: string, input?: JsonValue, options?: CanvasCallOptions): Promise; /** Close one instance. Unknown keys are a no-op, so close is idempotent. */ close(key: CanvasInstanceKey): Promise; /** * Re-fork a running extension from disk and put its open instances back. * * This is what makes a canvas *iterable*. A canvas has no passive half — its * id, its actions and its UI all come from running its code — so editing * `extension.mjs` changes nothing at all while the child that was forked from * the old bytes is still serving: not the open page, and not even a freshly * opened second instance, because {@link child} hands back the child already in * the table. Without a reload the only way to see an edit is to end the session. * * The order is deliberate. The new child is forked and asked for its * declarations **before** the old one is touched, so an edit that does not run — * a syntax error, a throw at module scope, a `joinSession` that never resolves — * leaves the person looking at exactly the canvas they had, and the error is * reported instead of being paid for with their open surface. * * Instance ids are preserved, so an `instanceId` the model already holds keeps * working across a reload. **URLs are not**: the extension binds a fresh * ephemeral port and mints a fresh capability token in `open()`, and the host * has no way to make it reuse either. So a reload always hands back new URLs, * and the caller must show them — an already-open browser tab is pointing at a * port that is now closed. * * The `input` each instance was opened with is replayed, so a reload restores * the canvas rather than a blank one. Everything the *extension* kept in memory * is gone, which is the honest meaning of restarting a process. */ reload(extensionId: string, options?: CanvasCallOptions): Promise; /** * Stop an extension's child now, rather than at the end of its linger period. * * The reaper's grace period is right for an extension nobody is using and wrong * for one whose directory is about to be moved or deleted: a child outliving * its own source is the most confusing state a canvas can be in, because it * keeps serving code that is no longer anywhere on disk. */ stopChild(extensionId: string): Promise; /** Every open instance. */ listInstances(): CanvasInstance[]; /** * Actions currently invocable, one entry per open instance per declared action. * Empty when nothing is open — which is the point: a canvas that is not open * costs the prompt nothing (§7). */ activeActions(): CanvasActionBinding[]; /** * Close instances hoocode has not touched within the idle timeout, then reap * children that have had no instances for the linger period. Advisory cleanup: * see the module header on what "idle" can and cannot mean here. * * @returns The instance keys that were closed. */ reapIdle(): Promise; /** Close everything and terminate every child. Safe to call twice. */ shutdown(): Promise; private abandon; private instancesOf; private child; private spawn; /** Drop a dead child and its instances, so a crash cannot leave stale entries. */ private forget; } //# sourceMappingURL=registry.d.ts.map