{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/core/tools/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAEzE,8DAA8D;AAC9D,eAAO,MAAM,kCAAkC,6BAA6B,CAAC;AAC7E,2DAA2D;AAC3D,eAAO,MAAM,8BAA8B,yBAAyB,CAAC;AACrE;;;GAGG;AACH,eAAO,MAAM,uBAAuB,kBAAkB,CAAC;AAEvD;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AA0B7C,+CAA+C;AAC/C,MAAM,WAAW,yBAAyB;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,oCAAoC;AACpC,MAAM,WAAW,mBAAmB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACvB;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAmB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;CACnB;AAgND;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,cAAc,GAAG,cAAc,EAAE,CAGtF","sourcesContent":["/**\n * The three agent-facing canvas tools.\n *\n * Design: `docs/canvas-extensions-design.md` §11.5. Copilot names its own shape in\n * the SDK types — `list_canvas_capabilities` to discover, `invoke_canvas_action` to\n * invoke — and hoocode mirrors it, for its own reason as well as fidelity:\n * `AGENTS.md` budgets the prompt at ~4,140 tokens with ~2,710 of it tool schemas,\n * and every active tool's schema is re-sent on every request. One tool per open\n * action would make that surface grow with how many canvases are open. Fixed tools\n * keep it flat.\n *\n * `reload_canvas` is hoocode's, not Copilot's, and it is what makes a canvas\n * something the agent can *iterate on* rather than only drive: an edit to\n * `extension.mjs` is invisible until the child forked from the old bytes is\n * replaced. See {@link createReloadTool} for why reloading is the agent's to do\n * while opening is not.\n *\n * **There is deliberately no \"open a canvas\" tool.** Opening forks a process and\n * binds a listening socket; that is a person's decision, gated by workspace trust\n * (§5). The agent drives a surface a human has already opened. This also keeps the\n * injection surface flat: a poisoned issue title rendered into a canvas can at most\n * cause an action on an instance the person chose to open.\n *\n * These are optional tools, created only when canvas support is available and at\n * least one canvas is open — so a repository without canvases pays nothing.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport type { CanvasRegistry } from \"../canvas/registry.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\n\n/** Tool name for capability discovery, matching Copilot's. */\nexport const LIST_CANVAS_CAPABILITIES_TOOL_NAME = \"list_canvas_capabilities\";\n/** Tool name for action invocation, matching Copilot's. */\nexport const INVOKE_CANVAS_ACTION_TOOL_NAME = \"invoke_canvas_action\";\n/**\n * Tool name for re-forking an edited extension. hoocode's own — Copilot has no\n * equivalent because its `/create-canvas` flow reloads the panel itself.\n */\nexport const RELOAD_CANVAS_TOOL_NAME = \"reload_canvas\";\n\n/**\n * Ceiling on a serialized action result, in characters.\n *\n * Whatever an action returns lands in the model's context window.\n * `pr-artifact-explorer` truncates its own payloads (`entries.slice(0, 200)`), but\n * nothing in the contract obliges a canvas to, so the host caps it too.\n */\nexport const CANVAS_RESULT_MAX_CHARS = 8_000;\n\nconst listParams = Type.Object({}, { additionalProperties: false });\n\nconst reloadParams = Type.Object(\n\t{\n\t\textensionId: Type.String({\n\t\t\tdescription: \"The extension whose code changed. From list_canvas_capabilities (the `extension` field).\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype ReloadParams = Static<typeof reloadParams>;\n\nconst invokeParams = Type.Object(\n\t{\n\t\tinstanceId: Type.String({ description: \"From list_canvas_capabilities.\" }),\n\t\taction: Type.String({ description: \"Action name declared by that instance's canvas.\" }),\n\t\tinput: Type.Optional(Type.Unknown({ description: \"Action input, matching the action's declared schema.\" })),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype InvokeParams = Static<typeof invokeParams>;\n\n/** What `list_canvas_capabilities` reports. */\nexport interface CanvasCapabilitiesDetails {\n\tinstances: number;\n\tactions: number;\n}\n\n/** What `reload_canvas` reports. */\nexport interface CanvasReloadDetails {\n\textensionId: string;\n\treopened: number;\n\tdropped: number;\n\tactionsAdded: number;\n\tactionsRemoved: number;\n\tactionsChanged: number;\n}\n\n/** What `invoke_canvas_action` reports. */\nexport interface CanvasInvokeDetails {\n\tinstanceId: string;\n\taction: string;\n\ttruncated: boolean;\n}\n\nfunction textResult(text: string) {\n\treturn { content: [{ type: \"text\" as const, text }] };\n}\n\n/** Serialize an action result, capped so a chatty canvas cannot flood the context. */\nfunction renderResult(value: unknown): { text: string; truncated: boolean } {\n\tconst serialized = value === undefined ? \"null\" : JSON.stringify(value, null, 1);\n\tif (serialized.length <= CANVAS_RESULT_MAX_CHARS) return { text: serialized, truncated: false };\n\treturn {\n\t\ttext: `${serialized.slice(0, CANVAS_RESULT_MAX_CHARS)}\\n… truncated at ${CANVAS_RESULT_MAX_CHARS} characters.`,\n\t\ttruncated: true,\n\t};\n}\n\n/**\n * Discovery: every open instance, its canvas, and the actions it declares with\n * their input schemas.\n *\n * Takes no parameters. A filter would add schema bytes on every request to save\n * bytes in a response the model reads once.\n */\nfunction createListCapabilitiesTool(registry: CanvasRegistry): ToolDefinition {\n\treturn defineTool<typeof listParams, CanvasCapabilitiesDetails>({\n\t\tname: LIST_CANVAS_CAPABILITIES_TOOL_NAME,\n\t\tlabel: LIST_CANVAS_CAPABILITIES_TOOL_NAME,\n\t\tdescription:\n\t\t\t\"List the open canvases and the actions each one accepts, with their input schemas. Call this before invoke_canvas_action to learn the instanceId and the action's schema.\",\n\t\tpromptSnippet: \"Discover open canvases and the actions they accept\",\n\t\tparameters: listParams,\n\t\tasync execute() {\n\t\t\tconst instances = registry.listInstances();\n\t\t\tconst bindings = registry.activeActions();\n\t\t\tif (instances.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\t...textResult(\"No canvas is open. A person opens a canvas; you can then drive it.\"),\n\t\t\t\t\tdetails: { instances: 0, actions: 0 },\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst report = instances.map((instance) => ({\n\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\tcanvas: instance.canvasId,\n\t\t\t\textension: instance.extensionId,\n\t\t\t\ttitle: instance.title,\n\t\t\t\tstatus: instance.status,\n\t\t\t\tactions: bindings\n\t\t\t\t\t.filter((binding) => binding.instanceId === instance.instanceId)\n\t\t\t\t\t.map((binding) => binding.action),\n\t\t\t}));\n\t\t\treturn {\n\t\t\t\t...textResult(JSON.stringify(report, null, 1)),\n\t\t\t\tdetails: { instances: instances.length, actions: bindings.length },\n\t\t\t};\n\t\t},\n\t});\n}\n\n/** Invocation: run one declared action against one open instance. */\nfunction createInvokeActionTool(registry: CanvasRegistry): ToolDefinition {\n\treturn defineTool<typeof invokeParams, CanvasInvokeDetails>({\n\t\tname: INVOKE_CANVAS_ACTION_TOOL_NAME,\n\t\tlabel: INVOKE_CANVAS_ACTION_TOOL_NAME,\n\t\t// The description deliberately makes no safety claim. An earlier version said\n\t\t// actions \"cannot edit files or run commands\", which is false: a canvas\n\t\t// extension is arbitrary Node code running with the user's privileges, and\n\t\t// `pr-artifact-explorer` really does download artifacts to disk and call the\n\t\t// GitHub API. Those side effects never pass hoocode's permission gate, because\n\t\t// the gate sits in front of hoocode's own tools, not inside a forked\n\t\t// extension — the workspace-trust gate (canvas/trust.ts) is the control here,\n\t\t// not a sentence in a tool schema. Never tell the model a safety property the\n\t\t// runtime does not enforce.\n\t\tdescription:\n\t\t\t\"Invoke an action on an open canvas. Actions are implemented by the canvas extension itself: an action may change what the person is looking at and can have side effects of its own, so read the action's description before calling it.\",\n\t\tpromptSnippet: \"Act on an open canvas the user is looking at\",\n\t\tparameters: invokeParams,\n\t\tasync execute(_toolCallId, params: InvokeParams, signal) {\n\t\t\t// instanceId is a UUID and unique across every canvas, so the model does not\n\t\t\t// have to carry the extension and canvas ids too — the registry already knows\n\t\t\t// which instance a given id belongs to.\n\t\t\tconst instance = registry.listInstances().find((open) => open.instanceId === params.instanceId);\n\t\t\tif (!instance) {\n\t\t\t\t// Tools report failure by throwing here, as the built-ins do; the loop turns\n\t\t\t\t// a rejection into the model's tool result.\n\t\t\t\tconst open = registry.listInstances().map((other) => other.instanceId);\n\t\t\t\tthrow new Error(\n\t\t\t\t\topen.length === 0\n\t\t\t\t\t\t? \"No canvas is open, so there is nothing to act on.\"\n\t\t\t\t\t\t: `No open canvas instance \"${params.instanceId}\". Open instances: ${open.join(\", \")}.`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Honour the turn's abort signal: without this, aborting a turn leaves the\n\t\t\t\t// request running and its answer arriving for a turn nobody awaits.\n\t\t\t\tconst result = await registry.invokeAction(instance, params.action, params.input as never, { signal });\n\t\t\t\tconst { text, truncated } = renderResult(result);\n\t\t\t\treturn {\n\t\t\t\t\t...textResult(text),\n\t\t\t\t\tdetails: { instanceId: params.instanceId, action: params.action, truncated },\n\t\t\t\t};\n\t\t\t} catch (cause) {\n\t\t\t\t// Canvas handlers throw CanvasError with a machine-readable code, which the\n\t\t\t\t// runner preserves across the process boundary as CanvasCallError.code. Only\n\t\t\t\t// the message is rendered to the model, so fold the code into it rather than\n\t\t\t\t// letting the typed-error intent (§8) stop at the tool boundary.\n\t\t\t\tconst code = cause instanceof Error && \"code\" in cause ? String(cause.code) : undefined;\n\t\t\t\tconst message = cause instanceof Error ? cause.message : String(cause);\n\t\t\t\tthrow new Error(code ? `${code}: ${message}` : message);\n\t\t\t}\n\t\t},\n\t});\n}\n\n/**\n * Reload: re-fork an extension whose source changed, carrying its open instances.\n *\n * This is the tool that makes a canvas *iterable* by the agent — \"add a column\",\n * \"make the header sticky\" — which is the whole point of authoring one in a\n * session. Editing `extension.mjs` alone changes nothing: the child forked from\n * the old bytes keeps serving until it is replaced.\n *\n * It reloads; it does not open. That distinction is what keeps §11.5's reasoning\n * intact. Opening is a person's decision because it starts a process from a\n * directory nobody has vouched for; reloading only restarts an extension the\n * person already opened, in a workspace they already trusted, from a path the\n * host already resolved. The model cannot reach a new extension through it, and\n * a poisoned string in some canvas's data still cannot cause one to start.\n *\n * It is not a safety boundary on the *contents* of the file, and must not be\n * described as one: whatever wrote `extension.mjs` — the model's own edit,\n * through the permission gate — is what runs.\n */\nfunction createReloadTool(registry: CanvasRegistry): ToolDefinition {\n\treturn defineTool<typeof reloadParams, CanvasReloadDetails>({\n\t\tname: RELOAD_CANVAS_TOOL_NAME,\n\t\tlabel: RELOAD_CANVAS_TOOL_NAME,\n\t\tdescription:\n\t\t\t\"Restart an open canvas extension so your edits to its source take effect. Editing the extension's file does nothing on its own — the running process was forked from the old code. Call this after every edit. It reports which actions you added, removed or changed, so use it to confirm an action you just wrote is really callable. Open instances are carried across and keep their instanceId, but each gets a NEW url: tell the person the new url, because the tab they have open is now dead.\",\n\t\tpromptSnippet: \"Restart an edited canvas so the change is live\",\n\t\tparameters: reloadParams,\n\t\tasync execute(_toolCallId, params: ReloadParams, signal) {\n\t\t\tconst running = [...new Set(registry.listInstances().map((instance) => instance.extensionId))];\n\t\t\tif (!running.includes(params.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\trunning.length === 0\n\t\t\t\t\t\t? \"No canvas is open, so there is nothing to reload.\"\n\t\t\t\t\t\t: `Canvas extension \"${params.extensionId}\" has nothing open. Running: ${running.join(\", \")}.`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\t// A failed reload is the common case while iterating — the edit did not\n\t\t\t// parse, or threw at module scope. The registry leaves the old child\n\t\t\t// serving in that case, so this reads as \"your edit is broken and the\n\t\t\t// canvas is untouched\", which is what the model needs to hear to fix it.\n\t\t\tconst result = await registry.reload(params.extensionId, { signal });\n\n\t\t\tconst lines = [`Reloaded ${params.extensionId}. It declares: ${result.canvases.join(\", \") || \"no canvases\"}.`];\n\n\t\t\t// The capability delta is the answer to the question an author actually has\n\t\t\t// after an edit — did the host see the action I just wrote? Silence would read\n\t\t\t// as success, so \"nothing changed\" is said out loud too.\n\t\t\tconst { added, removed, changed, current } = result.actions;\n\t\t\tif (added.length + removed.length + changed.length === 0) {\n\t\t\t\tlines.push(`Actions unchanged: ${current.join(\", \") || \"none\"}.`);\n\t\t\t} else {\n\t\t\t\tif (added.length > 0) lines.push(`Actions added: ${added.join(\", \")}.`);\n\t\t\t\tif (removed.length > 0) lines.push(`Actions removed: ${removed.join(\", \")}.`);\n\t\t\t\tif (changed.length > 0) {\n\t\t\t\t\tlines.push(\n\t\t\t\t\t\t`Actions changed (description or inputSchema): ${changed.join(\", \")}. Any schema you were holding for these is stale.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tlines.push(`Now callable: ${current.join(\", \") || \"none\"}.`);\n\t\t\t}\n\t\t\tif (result.reopened.length > 0) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\"Re-opened (give the person the new url — their old tab points at a closed port):\",\n\t\t\t\t\t...result.reopened.map(\n\t\t\t\t\t\t(instance) =>\n\t\t\t\t\t\t\t`  ${instance.canvasId} (${instance.instanceId})${instance.url ? ` — ${instance.url}` : \"\"}`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (result.dropped.length > 0) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\"Did not come back:\",\n\t\t\t\t\t...result.dropped.map((drop) => `  ${drop.canvasId} (${drop.instanceId}): ${drop.reason}`),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (result.reopened.length === 0 && result.dropped.length === 0) {\n\t\t\t\tlines.push(\"Nothing was open, so nothing was re-opened.\");\n\t\t\t}\n\t\t\treturn {\n\t\t\t\t...textResult(lines.join(\"\\n\")),\n\t\t\t\tdetails: {\n\t\t\t\t\textensionId: params.extensionId,\n\t\t\t\t\treopened: result.reopened.length,\n\t\t\t\t\tdropped: result.dropped.length,\n\t\t\t\t\tactionsAdded: added.length,\n\t\t\t\t\tactionsRemoved: removed.length,\n\t\t\t\t\tactionsChanged: changed.length,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n\n/**\n * The canvas tools, or none.\n *\n * Returns an empty array while nothing is open, so the two schemas are absent from\n * the prompt in the overwhelmingly common case of a repository with no canvases —\n * the same reason `registry.activeActions()` is empty until an instance exists\n * (§7). Callers re-derive this when the open set changes.\n */\nexport function createCanvasToolDefinitions(registry: CanvasRegistry): ToolDefinition[] {\n\tif (registry.listInstances().length === 0) return [];\n\treturn [createListCapabilitiesTool(registry), createInvokeActionTool(registry), createReloadTool(registry)];\n}\n"]}