{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Canvas instance registry — owns children, instances, and reaping.\n *\n * Design: `docs/canvas-extensions-design.md` §4, §6, §7. One child process per\n * extension, many instances per child, keyed by `(extensionId, canvasId,\n * instanceId)` because `joinSession({ canvases: [...] })` takes an array and each\n * canvas can be opened more than once.\n *\n * **Correction to the design doc's §6.** That section called for an \"SSE-liveness\n * heartbeat — an instance with no connected client for N seconds is idle\". That is\n * not implementable. The SSE endpoint and its client set live inside the\n * extension's own HTTP server (`entry.sseClients` in `pr-artifact-explorer`'s\n * `server.mjs`); the host never sees them. Learning otherwise would take either\n * proxying the canvas URL — which breaks the token, origin and CSP model the\n * extension built — or adding a liveness call to the contract, which breaks tier-2\n * portability. Neither is worth it for a reaper.\n *\n * So idleness here means something narrower and honest: **time since hoocode last\n * touched the instance** (opened it, or invoked an action on it). A person reading\n * a canvas in a browser tab is invisible to us, so a generous timeout is the point\n * rather than a limitation, and `reapIdle` is advisory cleanup — not a claim about\n * whether anybody is watching.\n *\n * The registry starts no timers. `reapIdle()` is driven by the caller and `now` is\n * injectable, so lifetime policy belongs to whoever owns the session clock and the\n * tests do not sleep.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport * as path from \"node:path\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport type {\n\tCanvasActionDeclaration,\n\tCanvasDeclaration,\n\tCanvasProviderOpenResult,\n\tCanvasReadyMessage,\n\tJsonValue,\n} from \"./protocol.js\";\nimport {\n\ttype CanvasCallOptions,\n\ttype CanvasExtensionProcess,\n\ttype CanvasRunnerOptions,\n\ttype CanvasRuntime,\n\tspawnCanvasExtension,\n} from \"./runner.js\";\nimport { CanvasTrustError, shouldWithholdCanvas } from \"./trust.js\";\n\n/** Default idle ceiling before an untouched instance is reaped. */\nexport const CANVAS_INSTANCE_IDLE_MS = 30 * 60 * 1_000;\n\n/** Default grace period a child is kept alive after its last instance closes. */\nexport const CANVAS_CHILD_LINGER_MS = 60 * 1_000;\n\n/** Default cap on concurrent instances of a single canvas. */\nexport const CANVAS_MAX_INSTANCES_PER_CANVAS = 8;\n\n/** Stable identity of one open instance. */\nexport interface CanvasInstanceKey {\n\textensionId: string;\n\tcanvasId: string;\n\tinstanceId: string;\n}\n\n/** An open canvas instance. */\nexport interface CanvasInstance extends CanvasInstanceKey {\n\t/** URL the host hands to a browser. */\n\turl: string | undefined;\n\ttitle: string | undefined;\n\tstatus: string | undefined;\n\t/** When hoocode last opened this instance or invoked one of its actions. */\n\tlastTouchedAt: number;\n\t/**\n\t * The `input` this instance was opened with, kept so {@link CanvasRegistry.reload}\n\t * can re-open it the same way. `canvas.open` is the only place a canvas is told\n\t * what it is opening *onto*, so replaying it is what makes a reload a reload\n\t * rather than a fresh, emptier canvas.\n\t */\n\topenInput: JsonValue | undefined;\n}\n\n/**\n * One agent-callable action on an open instance. This is the input the future\n * tool bridge consumes; nothing registers it as a tool yet, deliberately — that\n * makes canvases reachable by the agent and must follow the trust gate (§5).\n */\nexport interface CanvasActionBinding extends CanvasInstanceKey {\n\taction: CanvasActionDeclaration;\n}\n\n/** An instance that did not survive a {@link CanvasRegistry.reload}, and why. */\nexport interface CanvasReloadDrop {\n\tinstanceId: string;\n\tcanvasId: string;\n\treason: string;\n}\n\n/**\n * How a reload changed what the agent can call.\n *\n * Reported because editing a canvas's actions is otherwise invisible. A reload\n * that only said which canvases exist leaves the one question an author actually\n * has unanswered — *did the host see the action I just wrote?* — and the answer\n * matters: a typo in `actions: [...]`, a handler that throws at declaration time,\n * or an action defined on the wrong canvas all fail by the action simply not\n * being there.\n *\n * `changed` means same name, different declaration — a reworded description or a\n * reshaped `inputSchema`. That is worth separating from added and removed\n * because it is the case where a stale `list_canvas_capabilities` result in the\n * model's context is now wrong rather than merely incomplete.\n */\nexport interface CanvasActionDelta {\n\t/** `canvasId.actionName`, so a multi-canvas extension stays unambiguous. */\n\tadded: string[];\n\tremoved: string[];\n\tchanged: string[];\n\t/** Everything the extension declares now, in the same form. */\n\tcurrent: string[];\n}\n\n/** What a {@link CanvasRegistry.reload} did. */\nexport interface CanvasReloadResult {\n\textensionId: string;\n\t/**\n\t * Instances that came back, with their **new** urls — the old ones are dead\n\t * ports. Instance ids are unchanged.\n\t */\n\treopened: CanvasInstance[];\n\t/** Instances that could not be re-opened. */\n\tdropped: CanvasReloadDrop[];\n\t/** Canvas ids the reloaded extension declares, which the edit may have changed. */\n\tcanvases: string[];\n\t/** What the edit did to the action inventory. */\n\tactions: CanvasActionDelta;\n}\n\n/**\n * Index an extension's actions by `canvasId.actionName`, against a stable\n * serialization of the declaration.\n *\n * `JSON.stringify` of the whole declaration is the comparison, which makes it\n * sensitive to key order — but both sides come from the same code path in the\n * same shim, so a reordering here means the author reordered the source, and\n * reporting that as \"changed\" is closer to true than missing a reshaped schema.\n */\nfunction indexActions(declarations: Map<string, CanvasDeclaration>): Map<string, string> {\n\tconst index = new Map<string, string>();\n\tfor (const [canvasId, declaration] of declarations) {\n\t\tfor (const action of declaration.actions ?? []) {\n\t\t\tindex.set(`${canvasId}.${action.name}`, JSON.stringify(action));\n\t\t}\n\t}\n\treturn index;\n}\n\n/** Compare two action inventories. */\nfunction diffActions(before: Map<string, string>, after: Map<string, string>): CanvasActionDelta {\n\tconst added: string[] = [];\n\tconst removed: string[] = [];\n\tconst changed: string[] = [];\n\tfor (const [key, shape] of after) {\n\t\tif (!before.has(key)) added.push(key);\n\t\telse if (before.get(key) !== shape) changed.push(key);\n\t}\n\tfor (const key of before.keys()) if (!after.has(key)) removed.push(key);\n\treturn { added: added.sort(), removed: removed.sort(), changed: changed.sort(), current: [...after.keys()].sort() };\n}\n\n/** Diagnostics the registry emits. The host decides how to surface them. */\nexport interface CanvasRegistryEvents {\n\t/** A `session.log` call from an extension. */\n\tonLog?: (extensionId: string, message: string, level: string | undefined) => void;\n\t/** A non-protocol stdout line — almost always a stray `console.log`. */\n\tonStray?: (extensionId: string, line: string) => void;\n\t/** The child's stderr. */\n\tonStderr?: (extensionId: string, chunk: string) => void;\n\t/** Something the host should tell the user about once. */\n\tonDiagnostic?: (extensionId: string, message: string) => void;\n}\n\n/** Registry configuration. */\nexport interface CanvasRegistryOptions extends CanvasRegistryEvents {\n\truntime: CanvasRuntime;\n\t/**\n\t * Working directory the trust gate is evaluated against (§5). Required: forking\n\t * a canvas that arrived in a clone is exactly what the gate exists to prevent,\n\t * so there is no sensible default to fall back to.\n\t */\n\tcwd: string;\n\t/** Trust-store location. Defaults to the agent dir; injectable for tests. */\n\tagentDir?: string;\n\t/** Clock, injectable so idle policy is testable without sleeping. */\n\tnow?: () => number;\n\tidleTimeoutMs?: number;\n\tchildLingerMs?: number;\n\t/**\n\t * Per-method provider-call ceilings, merged over the runner's defaults.\n\t *\n\t * Plumbed through because the registry is the entry point everything real goes\n\t * via: without this the ceilings in `runner.ts` were only reachable by calling\n\t * `spawnCanvasExtension` directly, which nothing does.\n\t */\n\trequestTimeoutMs?: CanvasRunnerOptions[\"requestTimeoutMs\"];\n\tmaxInstancesPerCanvas?: number;\n\t/** Instance id generator, injectable for deterministic tests. */\n\tnewInstanceId?: () => string;\n}\n\ninterface ChildEntry {\n\tprocess: CanvasExtensionProcess;\n\tdeclarations: Map<string, CanvasDeclaration>;\n\t/** When the child's instance count last dropped to zero; undefined while in use. */\n\tidleSince: number | undefined;\n\t/**\n\t * The descriptor this child was forked from.\n\t *\n\t * Kept because {@link CanvasRegistry.reload} is reached by extension id — from a\n\t * tool call, or from `/canvas reload` — and re-forking needs the entry file and\n\t * the scope the trust gate reads. Re-discovering it here would duplicate the\n\t * search roots and could silently resolve a *different* extension than the one\n\t * that is running.\n\t */\n\textension: DiscoveredCanvasExtension;\n}\n\n/** Render a key as a stable string, for maps and messages. */\nexport function canvasInstanceKeyOf(key: CanvasInstanceKey): string {\n\treturn `${key.extensionId}::${key.canvasId}::${key.instanceId}`;\n}\n\nexport class CanvasRegistry {\n\tprivate readonly children = new Map<string, ChildEntry>();\n\tprivate readonly instances = new Map<string, CanvasInstance>();\n\tprivate readonly options: CanvasRegistryOptions;\n\tprivate readonly now: () => number;\n\tprivate readonly newInstanceId: () => string;\n\n\tconstructor(options: CanvasRegistryOptions) {\n\t\tthis.options = options;\n\t\tthis.now = options.now ?? Date.now;\n\t\tthis.newInstanceId = options.newInstanceId ?? randomUUID;\n\t}\n\n\t/** Canvases an extension declares, forking it if it is not already running. */\n\tasync declarations(extension: DiscoveredCanvasExtension): Promise<CanvasDeclaration[]> {\n\t\tconst child = await this.child(extension);\n\t\treturn [...child.declarations.values()];\n\t}\n\n\t/** Open a canvas instance and return what the host needs to render it. */\n\tasync open(\n\t\textension: DiscoveredCanvasExtension,\n\t\tcanvasId: string,\n\t\tinput?: JsonValue,\n\t\toptions?: CanvasCallOptions,\n\t): Promise<CanvasInstance> {\n\t\tconst child = await this.child(extension);\n\t\tif (!child.declarations.has(canvasId)) {\n\t\t\tconst known = [...child.declarations.keys()].join(\", \") || \"none\";\n\t\t\tthrow new Error(`Extension \"${extension.id}\" declares no canvas \"${canvasId}\" (declares: ${known}).`);\n\t\t}\n\n\t\tconst limit = this.options.maxInstancesPerCanvas ?? CANVAS_MAX_INSTANCES_PER_CANVAS;\n\t\tconst open = this.listInstances().filter(\n\t\t\t(instance) => instance.extensionId === extension.id && instance.canvasId === canvasId,\n\t\t);\n\t\tif (open.length >= limit) {\n\t\t\tthrow new Error(`Canvas \"${canvasId}\" already has ${open.length} open instances (limit ${limit}).`);\n\t\t}\n\n\t\tconst instanceId = this.newInstanceId();\n\t\tlet result: CanvasProviderOpenResult | null;\n\t\ttry {\n\t\t\tresult = (await child.process.open(\n\t\t\t\t{ sessionId: extension.id, extensionId: extension.id, canvasId, instanceId, input },\n\t\t\t\toptions,\n\t\t\t)) as CanvasProviderOpenResult | null;\n\t\t} catch (cause) {\n\t\t\tawait this.abandon(extension.id, canvasId, instanceId);\n\t\t\tthrow cause;\n\t\t}\n\n\t\tconst instance: CanvasInstance = {\n\t\t\textensionId: extension.id,\n\t\t\tcanvasId,\n\t\t\tinstanceId,\n\t\t\turl: result?.url,\n\t\t\ttitle: result?.title,\n\t\t\tstatus: result?.status,\n\t\t\tlastTouchedAt: this.now(),\n\t\t\topenInput: input,\n\t\t};\n\t\tthis.instances.set(canvasInstanceKeyOf(instance), instance);\n\t\tchild.idleSince = undefined;\n\t\treturn instance;\n\t}\n\n\t/** Invoke an action on an open instance. */\n\tasync invokeAction(\n\t\tkey: CanvasInstanceKey,\n\t\tactionName: string,\n\t\tinput?: JsonValue,\n\t\toptions?: CanvasCallOptions,\n\t): Promise<JsonValue> {\n\t\tconst instance = this.instances.get(canvasInstanceKeyOf(key));\n\t\tif (!instance) throw new Error(`No open canvas instance ${canvasInstanceKeyOf(key)}.`);\n\t\tconst child = this.children.get(key.extensionId);\n\t\tif (!child) throw new Error(`Canvas extension \"${key.extensionId}\" is not running.`);\n\n\t\tconst result = await child.process.invokeAction(\n\t\t\t{\n\t\t\t\tsessionId: key.extensionId,\n\t\t\t\textensionId: key.extensionId,\n\t\t\t\tcanvasId: key.canvasId,\n\t\t\t\tinstanceId: key.instanceId,\n\t\t\t\tactionName,\n\t\t\t\tinput,\n\t\t\t},\n\t\t\toptions,\n\t\t);\n\t\tinstance.lastTouchedAt = this.now();\n\t\treturn result;\n\t}\n\n\t/** Close one instance. Unknown keys are a no-op, so close is idempotent. */\n\tasync close(key: CanvasInstanceKey): Promise<void> {\n\t\tconst id = canvasInstanceKeyOf(key);\n\t\tif (!this.instances.delete(id)) return;\n\t\tconst child = this.children.get(key.extensionId);\n\t\tif (!child) return;\n\t\ttry {\n\t\t\tawait child.process.close({\n\t\t\t\tsessionId: key.extensionId,\n\t\t\t\textensionId: key.extensionId,\n\t\t\t\tcanvasId: key.canvasId,\n\t\t\t\tinstanceId: key.instanceId,\n\t\t\t});\n\t\t} catch (cause) {\n\t\t\t// onClose is fire-and-forget in the SDK contract, so a failure here must not\n\t\t\t// leave the instance half-closed in our table.\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\tkey.extensionId,\n\t\t\t\t`Closing canvas instance ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,\n\t\t\t);\n\t\t}\n\t\tif (this.instancesOf(key.extensionId).length === 0) child.idleSince = this.now();\n\t}\n\n\t/**\n\t * Re-fork a running extension from disk and put its open instances back.\n\t *\n\t * This is what makes a canvas *iterable*. A canvas has no passive half — its\n\t * id, its actions and its UI all come from running its code — so editing\n\t * `extension.mjs` changes nothing at all while the child that was forked from\n\t * the old bytes is still serving: not the open page, and not even a freshly\n\t * opened second instance, because {@link child} hands back the child already in\n\t * the table. Without a reload the only way to see an edit is to end the session.\n\t *\n\t * The order is deliberate. The new child is forked and asked for its\n\t * declarations **before** the old one is touched, so an edit that does not run —\n\t * a syntax error, a throw at module scope, a `joinSession` that never resolves —\n\t * leaves the person looking at exactly the canvas they had, and the error is\n\t * reported instead of being paid for with their open surface.\n\t *\n\t * Instance ids are preserved, so an `instanceId` the model already holds keeps\n\t * working across a reload. **URLs are not**: the extension binds a fresh\n\t * ephemeral port and mints a fresh capability token in `open()`, and the host\n\t * has no way to make it reuse either. So a reload always hands back new URLs,\n\t * and the caller must show them — an already-open browser tab is pointing at a\n\t * port that is now closed.\n\t *\n\t * The `input` each instance was opened with is replayed, so a reload restores\n\t * the canvas rather than a blank one. Everything the *extension* kept in memory\n\t * is gone, which is the honest meaning of restarting a process.\n\t */\n\tasync reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult> {\n\t\tconst previous = this.children.get(extensionId);\n\t\tif (!previous) {\n\t\t\tthrow new Error(\n\t\t\t\t`Canvas extension \"${extensionId}\" is not running, so there is nothing to reload. Open it first.`,\n\t\t\t);\n\t\t}\n\n\t\t// Snapshot before anything moves: `close` mutates the instance table, and the\n\t\t// old child's declarations go with it when it is terminated.\n\t\tconst carried = this.instancesOf(extensionId);\n\t\tconst actionsBefore = indexActions(previous.declarations);\n\n\t\t// Fork the edited code first. If it does not come up, the old child is still\n\t\t// registered and still serving, and this throws without costing anything.\n\t\tconst next = await this.spawn(previous.extension);\n\n\t\t// The swap. Registering the new child before stopping the old one is what\n\t\t// makes the old one's `onExit` a no-op rather than a table-clearing race.\n\t\tthis.children.set(extensionId, next);\n\t\tfor (const instance of carried) this.instances.delete(canvasInstanceKeyOf(instance));\n\t\tfor (const instance of carried) {\n\t\t\ttry {\n\t\t\t\tawait previous.process.close({\n\t\t\t\t\tsessionId: extensionId,\n\t\t\t\t\textensionId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// The old child is about to be killed, so a refused close costs nothing:\n\t\t\t\t// its ports go with the process. Reporting it would be noise on a path\n\t\t\t\t// the person asked for.\n\t\t\t}\n\t\t}\n\t\tawait previous.process.terminate();\n\n\t\tconst reopened: CanvasInstance[] = [];\n\t\tconst dropped: CanvasReloadDrop[] = [];\n\t\tfor (const instance of carried) {\n\t\t\t// The edit may have renamed or removed the canvas. That is a legitimate\n\t\t\t// thing for an author to do mid-iteration, so it is reported rather than\n\t\t\t// thrown — the other instances still come back.\n\t\t\tif (!next.declarations.has(instance.canvasId)) {\n\t\t\t\tdropped.push({\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\treason: `the reloaded extension no longer declares canvas \"${instance.canvasId}\" (declares: ${[...next.declarations.keys()].join(\", \") || \"none\"})`,\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst result = (await next.process.open(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: extensionId,\n\t\t\t\t\t\textensionId,\n\t\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\t\tinput: instance.openInput,\n\t\t\t\t\t},\n\t\t\t\t\toptions,\n\t\t\t\t)) as CanvasProviderOpenResult | null;\n\t\t\t\tconst fresh: CanvasInstance = {\n\t\t\t\t\t...instance,\n\t\t\t\t\turl: result?.url,\n\t\t\t\t\ttitle: result?.title,\n\t\t\t\t\tstatus: result?.status,\n\t\t\t\t\tlastTouchedAt: this.now(),\n\t\t\t\t};\n\t\t\t\tthis.instances.set(canvasInstanceKeyOf(fresh), fresh);\n\t\t\t\treopened.push(fresh);\n\t\t\t} catch (cause) {\n\t\t\t\tawait this.abandon(extensionId, instance.canvasId, instance.instanceId);\n\t\t\t\tdropped.push({\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\treason: cause instanceof Error ? cause.message : String(cause),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tnext.idleSince = reopened.length === 0 ? this.now() : undefined;\n\n\t\treturn {\n\t\t\textensionId,\n\t\t\treopened,\n\t\t\tdropped,\n\t\t\tcanvases: [...next.declarations.keys()],\n\t\t\tactions: diffActions(actionsBefore, indexActions(next.declarations)),\n\t\t};\n\t}\n\n\t/**\n\t * Stop an extension's child now, rather than at the end of its linger period.\n\t *\n\t * The reaper's grace period is right for an extension nobody is using and wrong\n\t * for one whose directory is about to be moved or deleted: a child outliving\n\t * its own source is the most confusing state a canvas can be in, because it\n\t * keeps serving code that is no longer anywhere on disk.\n\t */\n\tasync stopChild(extensionId: string): Promise<boolean> {\n\t\tconst child = this.children.get(extensionId);\n\t\tif (!child) return false;\n\t\tfor (const instance of this.instancesOf(extensionId)) await this.close(instance);\n\t\tthis.children.delete(extensionId);\n\t\tawait child.process.terminate();\n\t\treturn true;\n\t}\n\n\t/** Every open instance. */\n\tlistInstances(): CanvasInstance[] {\n\t\treturn [...this.instances.values()];\n\t}\n\n\t/**\n\t * Actions currently invocable, one entry per open instance per declared action.\n\t * Empty when nothing is open — which is the point: a canvas that is not open\n\t * costs the prompt nothing (§7).\n\t */\n\tactiveActions(): CanvasActionBinding[] {\n\t\tconst bindings: CanvasActionBinding[] = [];\n\t\tfor (const instance of this.instances.values()) {\n\t\t\tconst declaration = this.children.get(instance.extensionId)?.declarations.get(instance.canvasId);\n\t\t\tfor (const action of declaration?.actions ?? []) {\n\t\t\t\tbindings.push({\n\t\t\t\t\textensionId: instance.extensionId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\taction,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\treturn bindings;\n\t}\n\n\t/**\n\t * Close instances hoocode has not touched within the idle timeout, then reap\n\t * children that have had no instances for the linger period. Advisory cleanup:\n\t * see the module header on what \"idle\" can and cannot mean here.\n\t *\n\t * @returns The instance keys that were closed.\n\t */\n\tasync reapIdle(): Promise<string[]> {\n\t\tconst idleTimeout = this.options.idleTimeoutMs ?? CANVAS_INSTANCE_IDLE_MS;\n\t\tconst linger = this.options.childLingerMs ?? CANVAS_CHILD_LINGER_MS;\n\t\tconst now = this.now();\n\n\t\tconst expired = this.listInstances().filter((instance) => now - instance.lastTouchedAt >= idleTimeout);\n\t\tfor (const instance of expired) await this.close(instance);\n\n\t\tfor (const [extensionId, child] of [...this.children.entries()]) {\n\t\t\tconst unused = this.instancesOf(extensionId).length === 0;\n\t\t\tif (!unused) continue;\n\t\t\tconst since = child.idleSince ?? now;\n\t\t\tchild.idleSince = since;\n\t\t\tif (now - since >= linger) {\n\t\t\t\tthis.children.delete(extensionId);\n\t\t\t\tawait child.process.terminate();\n\t\t\t}\n\t\t}\n\n\t\treturn expired.map((instance) => canvasInstanceKeyOf(instance));\n\t}\n\n\t/** Close everything and terminate every child. Safe to call twice. */\n\tasync shutdown(): Promise<void> {\n\t\tfor (const instance of this.listInstances()) await this.close(instance);\n\t\tconst children = [...this.children.values()];\n\t\tthis.children.clear();\n\t\tawait Promise.all(children.map((child) => child.process.terminate()));\n\t}\n\n\t/**\n\t * Reconcile an instance we asked to open but never saw open — because a person\n\t * cancelled, or the call timed out. One path serves both.\n\t *\n\t * The provider protocol has no cancel verb, so the child may have finished opening\n\t * and be holding a port. `canvas.close` is the only way to tell it to let go, and\n\t * it can be sent because the instance id was generated before the open call.\n\t *\n\t * If the close itself goes unanswered the child is wedged, and the only remaining\n\t * lever is terminating it — but that kills every instance of that extension, so it\n\t * is done only when no other instance is live. When siblings exist the child is left\n\t * alone and the leak is reported, rather than paid for by someone else's open canvas.\n\t */\n\tprivate async abandon(extensionId: string, canvasId: string, instanceId: string): Promise<void> {\n\t\tconst child = this.children.get(extensionId);\n\t\tif (!child) return;\n\t\ttry {\n\t\t\tawait child.process.close({ sessionId: extensionId, extensionId, canvasId, instanceId });\n\t\t\treturn;\n\t\t} catch (cause) {\n\t\t\tconst detail = cause instanceof Error ? cause.message : String(cause);\n\t\t\tif (this.instancesOf(extensionId).length > 0) {\n\t\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\t\textensionId,\n\t\t\t\t\t`Stopped opening canvas \"${canvasId}\" but the extension did not confirm the close (${detail}). ` +\n\t\t\t\t\t\t\"It has other canvases open, so it was left running; a port may stay bound until it exits.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.children.delete(extensionId);\n\t\t\tawait child.process.terminate();\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\textensionId,\n\t\t\t\t`Stopped opening canvas \"${canvasId}\" and the extension did not confirm the close (${detail}); it was stopped.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate instancesOf(extensionId: string): CanvasInstance[] {\n\t\treturn this.listInstances().filter((instance) => instance.extensionId === extensionId);\n\t}\n\n\tprivate async child(extension: DiscoveredCanvasExtension): Promise<ChildEntry> {\n\t\tconst existing = this.children.get(extension.id);\n\t\tif (existing?.process.running) return existing;\n\t\tif (existing) this.children.delete(extension.id);\n\n\t\tconst entry = await this.spawn(extension);\n\t\tthis.children.set(extension.id, entry);\n\t\treturn entry;\n\t}\n\n\t/**\n\t * Fork one extension and wait for its declarations, without registering it.\n\t *\n\t * Separate from {@link child} because {@link reload} needs to fork a *second*\n\t * child while the first is still serving the person's open canvas: if the edit\n\t * that prompted the reload does not run, the old child is still there and\n\t * nothing was lost. Registering is therefore the caller's step, taken only once\n\t * the new child has answered.\n\t */\n\tprivate async spawn(extension: DiscoveredCanvasExtension): Promise<ChildEntry> {\n\t\t// The single choke point: every path that could start a process comes through\n\t\t// here, so the gate is enforced once and cannot be bypassed by a caller that\n\t\t// forgot to filter. Callers should still filter with `gateCanvasExtensions`\n\t\t// so they can explain the refusal; this is the backstop, not the UI.\n\t\tif (shouldWithholdCanvas(extension, this.options.cwd, this.options.agentDir)) {\n\t\t\tthrow new CanvasTrustError(extension.id, this.options.cwd);\n\t\t}\n\n\t\tlet spawned: CanvasExtensionProcess | undefined;\n\t\tconst process = spawnCanvasExtension({\n\t\t\textensionId: extension.id,\n\t\t\tentry: extension.entry,\n\t\t\truntime: this.options.runtime,\n\t\t\trequestTimeoutMs: this.options.requestTimeoutMs,\n\t\t\tcwd: path.dirname(extension.dir),\n\t\t\tonLog: (message, level) => this.options.onLog?.(extension.id, message, level),\n\t\t\tonStray: (line) => this.options.onStray?.(extension.id, line),\n\t\t\tonStderr: (chunk) => this.options.onStderr?.(extension.id, chunk),\n\t\t\t// Only the child that is *currently registered* may clear the table. A\n\t\t\t// reload's probe dying before it is adopted must not take the live child's\n\t\t\t// instances with it, and the old child's own exit — which reload causes on\n\t\t\t// purpose, after the new one is registered — must not undo the swap.\n\t\t\tonExit: () => {\n\t\t\t\tconst registered = this.children.get(extension.id);\n\t\t\t\tif (spawned && registered?.process === spawned) this.forget(extension.id);\n\t\t\t},\n\t\t});\n\t\tspawned = process;\n\n\t\tlet ready: CanvasReadyMessage;\n\t\ttry {\n\t\t\tready = await process.ready;\n\t\t} catch (cause) {\n\t\t\t// `ready` rejects when the child exits first, but a child that answered\n\t\t\t// nothing and stayed up would otherwise be orphaned by the throw.\n\t\t\tawait process.terminate();\n\t\t\tthrow cause;\n\t\t}\n\n\t\tif (ready.unsupported && ready.unsupported.length > 0) {\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\textension.id,\n\t\t\t\t`Canvas extension \"${extension.id}\" declares ${ready.unsupported.join(\", \")}, which hoocode does not support; those surfaces are ignored.`,\n\t\t\t);\n\t\t}\n\n\t\treturn {\n\t\t\tprocess,\n\t\t\tdeclarations: new Map(ready.canvases.map((declaration) => [declaration.id, declaration])),\n\t\t\tidleSince: this.now(),\n\t\t\textension,\n\t\t};\n\t}\n\n\t/** Drop a dead child and its instances, so a crash cannot leave stale entries. */\n\tprivate forget(extensionId: string): void {\n\t\tthis.children.delete(extensionId);\n\t\tfor (const [id, instance] of [...this.instances.entries()]) {\n\t\t\tif (instance.extensionId === extensionId) this.instances.delete(id);\n\t\t}\n\t}\n}\n"]}