{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAaH,OAAO,EAGN,KAAK,oBAAoB,EAEzB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AAwD5F;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,oBAAoB,CAAC,gBAAgB,CAAC,CAAC;CACxD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAwarF","sourcesContent":["/**\n * `/canvas` — the interactive surface for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision\n * lives in `core/canvas/session.ts`, which is testable without a terminal, so this\n * file only renders and supplies an `AbortSignal`.\n *\n * The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel\n * and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc\n * reaches the abandon path (§11.6) and the extension is told to release the port it\n * may already have bound, rather than the spinner merely disappearing.\n *\n * `/new-canvas` is registered here rather than beside `/new-skill` and friends\n * because it is not a file-writing command any more: it opens what it scaffolds\n * and hands the agent a brief to build it, which needs this file's session and\n * `hoo.sendUserMessage`. Its decisions live in `core/canvas/scaffold.ts`.\n *\n * The agent tools register on the first successful open and stay for the session:\n * `registerTool` has no counterpart to remove a tool. So a session that never opens a\n * canvas pays nothing for them, which is the case that matters (§11.5); after the\n * first open they cost ~235 tokens and answer honestly when nothing is open.\n */\n\nimport { homedir } from \"node:os\";\nimport { getAgentDir } from \"../../config.js\";\nimport { CATEGORY_GLYPH } from \"../../core/brand.js\";\nimport { canvasDesignGuidePath } from \"../../core/builtin-skills.js\";\n\n/** Canvas extensions are extensions, so they wear the extension glyph. */\nconst GLYPH = CATEGORY_GLYPH.extensions;\n\nimport { isCanvasRefusal } from \"../../core/canvas/lifecycle.js\";\nimport type { CanvasInstance } from \"../../core/canvas/registry.js\";\nimport { CANVAS_HOMES, canvasBuildBrief, parseCanvasRequest, scaffoldCanvas } from \"../../core/canvas/scaffold.js\";\nimport {\n\ttype CanvasOverview,\n\tCanvasSession,\n\ttype CanvasSessionOptions,\n\tparseCanvasRef,\n} from \"../../core/canvas/session.js\";\nimport { getWorkspacePlatforms } from \"../../core/extensions/plugins/formats/platform-targets.js\";\nimport { isWorkspaceTrusted, trustWorkspace } from \"../../core/extensions/plugins/trust.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { createCanvasToolDefinitions } from \"../../core/tools/canvas.js\";\nimport { BorderedLoader } from \"../../modes/interactive/components/bordered-loader.js\";\n\nconst SUBCOMMANDS = [\"list\", \"open\", \"close\", \"reload\", \"rename\", \"remove\"] as const;\n\n/** How an open attempt ended. `custom()` resolves with exactly one of these. */\ntype OpenOutcome =\n\t| { kind: \"opened\"; instance: CanvasInstance }\n\t| { kind: \"failed\"; message: string }\n\t| { kind: \"cancelled\" };\n\nfunction describeInstance(instance: CanvasInstance): string {\n\tconst title = instance.title ?? instance.canvasId;\n\treturn `${instance.instanceId}  ${title}${instance.url ? `  ${instance.url}` : \"\"}`;\n}\n\nfunction renderOverview(overview: CanvasOverview): string {\n\tconst lines: string[] = [];\n\tif (!overview.availability.available) {\n\t\tlines.push(`Canvases are unavailable: ${overview.availability.reason}`, \"\");\n\t}\n\tif (overview.listings.length === 0) {\n\t\tlines.push(\n\t\t\t\"No canvas extensions found in .agents/extensions, .github/extensions, ~/.copilot/extensions,\",\n\t\t\t\"or any installed plugin. Create one with /new-canvas <what it should do>, or install one with /plugin.\",\n\t\t);\n\t\treturn lines.join(\"\\n\");\n\t}\n\tfor (const listing of overview.listings) {\n\t\tconst name = listing.canvasId ? `${listing.extensionId}:${listing.canvasId}` : listing.extensionId;\n\t\tconst label = listing.displayName ? `  ${listing.displayName}` : \"\";\n\t\tif (listing.withheld === \"untrusted-workspace\") {\n\t\t\tlines.push(`${GLYPH} ${name}${label}  [withheld: untrusted workspace]`);\n\t\t\tcontinue;\n\t\t}\n\t\tlines.push(`${GLYPH} ${name}${label}  (${listing.scope})`);\n\t\tfor (const instance of listing.open) {\n\t\t\tlines.push(`    open  ${describeInstance(instance)}`);\n\t\t\t// What a canvas can do is otherwise visible only to the model, through\n\t\t\t// `list_canvas_capabilities` — so the person driving the session could not\n\t\t\t// see the surface they were being asked about. Only for open instances,\n\t\t\t// because actions come from running the code (§5.1).\n\t\t\tconst actions = overview.actionsByInstance.get(instance.instanceId) ?? [];\n\t\t\tif (actions.length > 0) lines.push(`          actions  ${actions.join(\", \")}`);\n\t\t}\n\t}\n\tif (overview.withheldCount > 0) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`,\n\t\t);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Test seams, and only that.\n *\n * `/new-canvas` opens what it writes, so driving it without a terminal needs a\n * runtime that does not depend on hoocode having been built, and a home\n * directory that is not the developer's. Everything else this file does is\n * decided in `core/canvas/`, where it is testable without any of this.\n */\nexport interface CanvasSetupOverrides {\n\thomeDir?: string;\n\tresolveRuntime?: CanvasSessionOptions[\"resolveRuntime\"];\n}\n\nexport function setupCanvas(hoo: ExtensionAPI, overrides?: CanvasSetupOverrides): void {\n\tlet session: CanvasSession | undefined;\n\tlet toolsRegistered = false;\n\t/**\n\t * Points at the most recent command's UI.\n\t *\n\t * A canvas keeps talking after the command that opened it has returned — logs,\n\t * stray stdout, a leaked-port warning — so the callbacks cannot close over one\n\t * invocation's `ctx`.\n\t */\n\tlet notify: (message: string, type?: \"info\" | \"warning\" | \"error\") => void = () => {};\n\n\tconst ensureSession = (ctx: ExtensionCommandContext): CanvasSession => {\n\t\tnotify = (message, type) => ctx.ui.notify(message, type);\n\t\tsession ??= new CanvasSession({\n\t\t\tcwd: ctx.cwd,\n\t\t\thomeDir: overrides?.homeDir ?? homedir(),\n\t\t\tagentDir: getAgentDir(),\n\t\t\tresolveRuntime: overrides?.resolveRuntime,\n\t\t\t// A canvas's own diagnostics are the user's business: a stray stdout line means\n\t\t\t// its author reached for console.log, and a possible leaked port is worth saying.\n\t\t\tonLog: (id, message) => notify(`[canvas ${id}] ${message}`, \"info\"),\n\t\t\tonStray: (id, line) => notify(`[canvas ${id}] non-protocol stdout (use session.log): ${line}`, \"warning\"),\n\t\t\tonDiagnostic: (id, message) => notify(`[canvas ${id}] ${message}`, \"warning\"),\n\t\t});\n\t\treturn session;\n\t};\n\n\tconst registerToolsOnce = (canvas: CanvasSession): void => {\n\t\tif (toolsRegistered) return;\n\t\tconst registry = canvas.registryOrUndefined();\n\t\tif (!registry) return;\n\t\ttoolsRegistered = true;\n\t\tfor (const definition of createCanvasToolDefinitions(registry)) hoo.registerTool(definition);\n\t};\n\n\t/**\n\t * Open a canvas behind a cancellable loader.\n\t *\n\t * Shared by `/canvas open` and `/new-canvas`, which want identical behaviour:\n\t * opening forks a process and binds a port, so it can be slow and must be\n\t * interruptible. The loader's signal is what makes Esc mean something — it\n\t * reaches the registry's abandon path, which tells the extension to release a\n\t * port it may already have bound. Outside a terminal (--print, RPC) there is\n\t * nothing to draw and nothing to press, so the open simply runs.\n\t */\n\tconst openWithLoader = async (\n\t\tcanvas: CanvasSession,\n\t\tref: { extensionId: string; canvasId?: string },\n\t\tctx: ExtensionCommandContext,\n\t): Promise<OpenOutcome | undefined> =>\n\t\tctx.hasUI\n\t\t\t? await ctx.ui.custom<OpenOutcome | undefined>((tui, theme, _keybindings, done) => {\n\t\t\t\t\tconst loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);\n\t\t\t\t\tloader.onAbort = () => done({ kind: \"cancelled\" });\n\t\t\t\t\tvoid canvas\n\t\t\t\t\t\t.open(ref, { signal: loader.signal })\n\t\t\t\t\t\t.then((instance) => done({ kind: \"opened\", instance }))\n\t\t\t\t\t\t// Cancelling races: the signal rejects the pending call at the same moment\n\t\t\t\t\t\t// onAbort fires, and whichever lands first resolves `custom`. Deciding from\n\t\t\t\t\t\t// the signal rather than from who won means a cancel always reads as a\n\t\t\t\t\t\t// cancel instead of surfacing as an error.\n\t\t\t\t\t\t.catch((error: unknown) =>\n\t\t\t\t\t\t\tdone(\n\t\t\t\t\t\t\t\tloader.signal.aborted\n\t\t\t\t\t\t\t\t\t? { kind: \"cancelled\" }\n\t\t\t\t\t\t\t\t\t: { kind: \"failed\", message: error instanceof Error ? error.message : String(error) },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\treturn loader;\n\t\t\t\t})\n\t\t\t: await canvas\n\t\t\t\t\t.open(ref)\n\t\t\t\t\t.then((instance): OpenOutcome => ({ kind: \"opened\", instance }))\n\t\t\t\t\t.catch(\n\t\t\t\t\t\t(error: unknown): OpenOutcome => ({\n\t\t\t\t\t\t\tkind: \"failed\",\n\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\n\thoo.registerCommand(\"canvas\", {\n\t\tdescription:\n\t\t\t\"Work with canvas extensions. /canvas list | open <extension>[:<canvas>] | reload [extension] | close <instanceId> | rename <extension> <new-name> | remove <extension>\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\tSUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst trimmed = args.trim();\n\t\t\tconst canvas = ensureSession(ctx);\n\n\t\t\tif (trimmed.length === 0 || trimmed === \"list\") {\n\t\t\t\tctx.ui.notify(renderOverview(await canvas.list()), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"close\")) {\n\t\t\t\tconst instanceId = trimmed.slice(\"close\".length).trim();\n\t\t\t\tif (!instanceId) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas close <instanceId>  (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst closed = await canvas.close(instanceId);\n\t\t\t\tif (!closed) ctx.ui.notify(`No open canvas instance \"${instanceId}\".`, \"warning\");\n\t\t\t\telse ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"reload\")) {\n\t\t\t\t// A reload re-forks the extension, so it can take as long as an open and is\n\t\t\t\t// worth being able to abandon — but unlike an open there is nothing to\n\t\t\t\t// abandon *to*: the registry keeps the old child serving until the new one\n\t\t\t\t// answers, so a cancel here just stops waiting.\n\t\t\t\tconst requested = trimmed.slice(\"reload\".length).trim();\n\t\t\t\tconst running = canvas.runningExtensionIds();\n\t\t\t\tconst extensionId = requested || (running.length === 1 ? running[0] : undefined);\n\t\t\t\tif (!extensionId) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\trunning.length === 0\n\t\t\t\t\t\t\t? \"Nothing is open, so there is nothing to reload. Open a canvas first with /canvas open <extension>.\"\n\t\t\t\t\t\t\t: `Several extensions are open; name one: ${running.join(\", \")}.`,\n\t\t\t\t\t\t\"warning\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await canvas.reload(extensionId);\n\t\t\t\t\tconst lines = [`Reloaded ${result.extensionId} from disk.`];\n\t\t\t\t\t// Same reason the tool reports it: an edit to `actions: [...]` is\n\t\t\t\t\t// otherwise invisible, and a typo there fails by the action simply\n\t\t\t\t\t// not being there.\n\t\t\t\t\tconst { added, removed, changed } = result.actions;\n\t\t\t\t\tif (added.length > 0) lines.push(`  + ${added.join(\", \")}`);\n\t\t\t\t\tif (removed.length > 0) lines.push(`  - ${removed.join(\", \")}`);\n\t\t\t\t\tif (changed.length > 0) lines.push(`  ~ ${changed.join(\", \")} (description or schema)`);\n\t\t\t\t\tfor (const instance of result.reopened) {\n\t\t\t\t\t\t// The url is the point of saying anything: the extension binds a new\n\t\t\t\t\t\t// port and mints a new token on every open, so the tab the person has\n\t\t\t\t\t\t// in front of them is now pointing at a closed port.\n\t\t\t\t\t\tlines.push(`  ${describeInstance(instance)}`);\n\t\t\t\t\t}\n\t\t\t\t\tif (result.reopened.length > 0) lines.push(\"\", \"Open the new url(s) — the previous tab is dead.\");\n\t\t\t\t\tfor (const drop of result.dropped) {\n\t\t\t\t\t\tlines.push(`  ${drop.canvasId} (${drop.instanceId}) did not come back: ${drop.reason}`);\n\t\t\t\t\t}\n\t\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), result.dropped.length > 0 ? \"warning\" : \"info\");\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// The registry only swaps children once the new one is ready, so the\n\t\t\t\t\t// canvas the person is looking at survived this. Say so, or they will\n\t\t\t\t\t// think they just lost it.\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`Reloading ${extensionId} failed, so it is still running the code it was started with: ` +\n\t\t\t\t\t\t\t`${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t\t\"error\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"rename\")) {\n\t\t\t\tconst [from, to, ...rest] = trimmed.slice(\"rename\".length).trim().split(/\\s+/).filter(Boolean);\n\t\t\t\tif (!from || !to || rest.length > 0) {\n\t\t\t\t\tctx.ui.notify(`Usage: /canvas rename <extension> <new-name>  (see /canvas list)`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Renaming closes whatever the extension had open, because the directory\n\t\t\t\t// is about to move out from under it. Say so before doing it, not after.\n\t\t\t\tconst wasOpen = canvas.instances().filter((instance) => instance.extensionId === from);\n\t\t\t\tconst result = await canvas.rename(from, to);\n\t\t\t\tif (isCanvasRefusal(result)) {\n\t\t\t\t\tctx.ui.notify(`/canvas rename: ${result.detail}`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst lines = [`Renamed ${result.from} → ${result.to}.`, `  ${result.dir}`];\n\t\t\t\tif (result.rewrites.length > 0) {\n\t\t\t\t\tlines.push(\"\", \"Rewrote in extension.mjs:\");\n\t\t\t\t\tfor (const rewrite of result.rewrites) lines.push(`  line ${rewrite.line}: ${rewrite.after.trim()}`);\n\t\t\t\t}\n\t\t\t\tif (result.leftovers.length > 0) {\n\t\t\t\t\t// Prose is not identity, so it is reported rather than edited — a rename\n\t\t\t\t\t// that silently rewrote a description would be worse than one that\n\t\t\t\t\t// admits what it left.\n\t\t\t\t\tlines.push(\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t`\"${result.from}\" still appears on line(s) ${result.leftovers.join(\", \")}; those look like prose, so they were left alone.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (wasOpen.length > 0) lines.push(\"\", `Closed ${wasOpen.length} open instance(s) to move the directory.`);\n\t\t\t\tlines.push(\"\", `Open it with /canvas open ${result.to}.`);\n\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"remove\")) {\n\t\t\t\tconst target = trimmed.slice(\"remove\".length).trim();\n\t\t\t\tif (!target) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas remove <extension>  (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst known = canvas.knownExtensionIds();\n\t\t\t\tif (!known.includes(target)) {\n\t\t\t\t\tctx.ui.notify(`No canvas extension \"${target}\" (found: ${known.join(\", \") || \"none\"}).`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Deleting source is not undoable from here, so it is confirmed — and\n\t\t\t\t// outside a terminal there is nobody to ask, so it is refused rather than\n\t\t\t\t// assumed. `--print` and RPC should not be able to delete a directory\n\t\t\t\t// because a command happened to be piped in.\n\t\t\t\tif (!ctx.hasUI) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`/canvas remove needs to ask before deleting ${target}, and there is no interactive surface here. Delete the directory yourself, or run this in a terminal.`,\n\t\t\t\t\t\t\"warning\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst confirmed = await ctx.ui.confirm(\n\t\t\t\t\t`Delete canvas \"${target}\"?`,\n\t\t\t\t\t\"This deletes the extension directory and everything in it. It is not undoable from here.\",\n\t\t\t\t);\n\t\t\t\tif (!confirmed) {\n\t\t\t\t\tctx.ui.notify(`Left ${target} alone.`, \"info\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst openCount = canvas.instances().filter((instance) => instance.extensionId === target).length;\n\t\t\t\tconst result = await canvas.remove(target);\n\t\t\t\tif (isCanvasRefusal(result)) {\n\t\t\t\t\tctx.ui.notify(`/canvas remove: ${result.detail}`, \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t[\n\t\t\t\t\t\t`Removed ${result.id}.`,\n\t\t\t\t\t\t`  ${result.dir}`,\n\t\t\t\t\t\topenCount > 0 ? `Closed ${openCount} open instance(s) first.` : \"\",\n\t\t\t\t\t]\n\t\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!trimmed.startsWith(\"open\")) {\n\t\t\t\tctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(\", \")}.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst ref = parseCanvasRef(trimmed.slice(\"open\".length));\n\t\t\tif (!ref) {\n\t\t\t\tctx.ui.notify(\"Usage: /canvas open <extension>[:<canvas>]  (see /canvas list)\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst outcome = await openWithLoader(canvas, ref, ctx);\n\n\t\t\t// `custom` can also settle on its own when the overlay is dismissed, without\n\t\t\t// our `done` ever running — an escape that closes the surface leaves no\n\t\t\t// outcome. Treat that as the cancel it is rather than reading `.kind` off\n\t\t\t// undefined and failing silently.\n\t\t\tif (!outcome || outcome.kind === \"cancelled\") {\n\t\t\t\t// KNOWN ISSUE: this confirmation does not render when the cancel came from the\n\t\t\t\t// loader's own escape handling, though every effect of cancelling is correct\n\t\t\t\t// and verified (the open rejects, no instance is registered, and the extension\n\t\t\t\t// is told to close the instance it never finished opening). Ruled out: the\n\t\t\t\t// continuation does run and `ctx.ui.notify` works here — the failure path\n\t\t\t\t// through the same lines renders its error, and the canvas's own diagnostic\n\t\t\t\t// arrives moments later through this very function. Deferring a tick did not\n\t\t\t\t// help either. Left as an unexplained cosmetic gap rather than papered over\n\t\t\t\t// with a sleep; the loader disappearing is itself the signal.\n\t\t\t\tctx.ui.notify(\"Canvas open cancelled.\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (outcome.kind === \"failed\") {\n\t\t\t\tctx.ui.notify(outcome.message, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tregisterToolsOnce(canvas);\n\t\t\tctx.ui.notify(\n\t\t\t\t[\n\t\t\t\t\t`Opened ${outcome.instance.canvasId} (${outcome.instance.instanceId}).`,\n\t\t\t\t\toutcome.instance.url ? `Open in a browser: ${outcome.instance.url}` : \"\",\n\t\t\t\t\t\"The agent can now read and drive it; close it with /canvas close <instanceId>.\",\n\t\t\t\t]\n\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// ── /new-canvas <name> | <description> ────────────────────────────────────\n\t// Authoring, in Copilot's `/create-canvas` shape: describe what you want, the\n\t// agent writes it, and it is already open while it does. Design:\n\t// `docs/canvas-extensions-design.md` §9 Phase 3, §13.\n\t//\n\t// Unlike the other `/new-*` scaffolds this one needs no /reload: canvases are\n\t// discovered when /canvas runs, not loaded at session start.\n\n\thoo.registerCommand(\"new-canvas\", {\n\t\tdescription:\n\t\t\t\"Create a canvas extension. Usage: /new-canvas <what it should do> | /new-canvas <name> | /new-canvas <name>: <what it should do>\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst request = parseCanvasRequest(args);\n\t\t\tif (typeof request === \"string\") {\n\t\t\t\tctx.ui.notify(`/new-canvas: ${request}`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// `--platform claude` is dropped rather than redirected: Claude has no\n\t\t\t// canvas convention, and silently writing into someone else's marker\n\t\t\t// directory would put an extension where that vendor will never look.\n\t\t\tconst requested = getWorkspacePlatforms() ?? [\"agents\"];\n\t\t\tconst targets = requested.filter((platform) => CANVAS_HOMES[platform] !== undefined);\n\t\t\tif (targets.length === 0) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`/new-canvas: no canvas home for platform \"${requested.join(\", \")}\". ` +\n\t\t\t\t\t\t\"Canvas extensions exist under .agents/extensions (agents) and .github/extensions (github) only.\",\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { created, skipped } = scaffoldCanvas(ctx.cwd, request.name, targets);\n\n\t\t\t// A canvas lands in the working tree, which is where the trust gate looks\n\t\t\t// (`core/canvas/trust.ts`) — so without this the canvas you just asked for\n\t\t\t// is withheld the moment you try to open it, and refused with \"came with\n\t\t\t// this repository\", which is not true of a file created seconds ago.\n\t\t\t//\n\t\t\t// Granting is the same call `/plugin install --scope project` makes, for\n\t\t\t// the same stated reason: a person typing this command in this directory\n\t\t\t// is the human act workspace trust asks for. It is deliberately wider than\n\t\t\t// this one canvas — it also lets plugins already committed here run their\n\t\t\t// hooks and MCP servers — so it is said out loud and pointed at its\n\t\t\t// reverse, never done silently.\n\t\t\tlet trustNote = \"\";\n\t\t\tif (created.length > 0 && !isWorkspaceTrusted(ctx.cwd, getAgentDir())) {\n\t\t\t\ttrustWorkspace(ctx.cwd, getAgentDir());\n\t\t\t\ttrustNote =\n\t\t\t\t\t`Trusted this workspace so the canvas can run. Plugins committed here may now run hooks ` +\n\t\t\t\t\t`and MCP servers too; \\`/plugin untrust\\` reverses it.`;\n\t\t\t}\n\n\t\t\tif (created.length === 0) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t[\n\t\t\t\t\t\t\"Nothing created — these already exist:\",\n\t\t\t\t\t\t...skipped.map((file) => `  ${file}`),\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t`Open the existing one with /canvas open ${request.name}, or pick another name.`,\n\t\t\t\t\t].join(\"\\n\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Open before saying anything. Copilot's `/create-canvas` puts the canvas\n\t\t\t// in front of the person and *then* builds it, and the order is the point:\n\t\t\t// a build brief that can name a live url and instance id is a different\n\t\t\t// instruction than one that cannot, and the person watches it change.\n\t\t\tconst canvas = ensureSession(ctx);\n\t\t\tconst outcome = await openWithLoader(canvas, { extensionId: request.name }, ctx);\n\t\t\tconst opened = outcome?.kind === \"opened\" ? outcome.instance : undefined;\n\t\t\tif (opened) registerToolsOnce(canvas);\n\n\t\t\tconst lines = [\"Canvas created:\", ...created.map((file) => `  ${file}`)];\n\t\t\tif (skipped.length > 0) lines.push(\"Skipped (already exist):\", ...skipped.map((file) => `  ${file}`));\n\t\t\tlines.push(\"\");\n\t\t\tif (opened) {\n\t\t\t\tlines.push(`Opened ${opened.canvasId} (${opened.instanceId}).`);\n\t\t\t\tif (opened.url) lines.push(`Open in a browser: ${opened.url}`);\n\t\t\t} else if (outcome?.kind === \"failed\") {\n\t\t\t\t// Not fatal: the file is written and discoverable, so say what broke and\n\t\t\t\t// leave them a way in rather than making it look like nothing happened.\n\t\t\t\tlines.push(\n\t\t\t\t\t`Created, but opening it failed: ${outcome.message}`,\n\t\t\t\t\t`Retry with /canvas open ${request.name}.`,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlines.push(`Opening cancelled. Open it when you want with /canvas open ${request.name}.`);\n\t\t\t}\n\t\t\tif (trustNote) lines.push(\"\", trustNote);\n\n\t\t\tif (request.description) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Building it now from: \"${request.description}\"`,\n\t\t\t\t\t\"Steer it like any other turn, or interrupt to take over the file yourself.\",\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlines.push(\"\", `Edit ${created[0]} and run /canvas reload ${request.name} to see each change.`);\n\t\t\t}\n\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\n\t\t\t// The half that makes this `/create-canvas` rather than a scaffold. Queued\n\t\t\t// as a follow-up so it lands on the next turn whether or not the agent is\n\t\t\t// mid-stream, and only when a description was given: `/new-canvas my-board`\n\t\t\t// still means \"give me the template\", and starting a build nobody asked for\n\t\t\t// would burn a turn and overwrite the file they meant to edit.\n\t\t\tif (request.description) {\n\t\t\t\tawait hoo.sendUserMessage(\n\t\t\t\t\tcanvasBuildBrief(\n\t\t\t\t\t\trequest.name,\n\t\t\t\t\t\trequest.description,\n\t\t\t\t\t\tcreated[0] as string,\n\t\t\t\t\t\topened ? { instanceId: opened.instanceId, url: opened.url } : undefined,\n\t\t\t\t\t\tcanvasDesignGuidePath(),\n\t\t\t\t\t),\n\t\t\t\t\t{ deliverAs: \"followUp\" },\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t});\n\n\t// Teardown on shutdown (§6): a browser tab gives no close signal, so without this\n\t// every child and loopback port outlives the session. `session_shutdown` is where\n\t// loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and\n\t// not awaited.\n\thoo.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}