@kolisachint/hoocode-agent 0.5.24 → 0.5.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/dist/core/brand.d.ts +1 -0
  3. package/dist/core/brand.d.ts.map +1 -1
  4. package/dist/core/brand.js +1 -0
  5. package/dist/core/brand.js.map +1 -1
  6. package/dist/core/canvas/discovery.d.ts +11 -3
  7. package/dist/core/canvas/discovery.d.ts.map +1 -1
  8. package/dist/core/canvas/discovery.js +18 -3
  9. package/dist/core/canvas/discovery.js.map +1 -1
  10. package/dist/core/canvas/lifecycle.d.ts +93 -0
  11. package/dist/core/canvas/lifecycle.d.ts.map +1 -0
  12. package/dist/core/canvas/lifecycle.js +165 -0
  13. package/dist/core/canvas/lifecycle.js.map +1 -0
  14. package/dist/core/canvas/plugin-canvases.d.ts +30 -0
  15. package/dist/core/canvas/plugin-canvases.d.ts.map +1 -0
  16. package/dist/core/canvas/plugin-canvases.js +51 -0
  17. package/dist/core/canvas/plugin-canvases.js.map +1 -0
  18. package/dist/core/canvas/registry.d.ts +89 -0
  19. package/dist/core/canvas/registry.d.ts.map +1 -1
  20. package/dist/core/canvas/registry.js +205 -10
  21. package/dist/core/canvas/registry.js.map +1 -1
  22. package/dist/core/canvas/scaffold.d.ts +123 -0
  23. package/dist/core/canvas/scaffold.d.ts.map +1 -0
  24. package/dist/core/canvas/scaffold.js +376 -0
  25. package/dist/core/canvas/scaffold.js.map +1 -0
  26. package/dist/core/canvas/session.d.ts +46 -1
  27. package/dist/core/canvas/session.d.ts.map +1 -1
  28. package/dist/core/canvas/session.js +91 -2
  29. package/dist/core/canvas/session.js.map +1 -1
  30. package/dist/core/canvas/trust.d.ts +4 -5
  31. package/dist/core/canvas/trust.d.ts.map +1 -1
  32. package/dist/core/canvas/trust.js +23 -8
  33. package/dist/core/canvas/trust.js.map +1 -1
  34. package/dist/core/extensions/plugins/formats/copilot.d.ts +10 -0
  35. package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -1
  36. package/dist/core/extensions/plugins/formats/copilot.js +36 -4
  37. package/dist/core/extensions/plugins/formats/copilot.js.map +1 -1
  38. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -1
  39. package/dist/core/extensions/plugins/formats/jsonManifest.js +15 -2
  40. package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -1
  41. package/dist/core/extensions/plugins/formats/shared.d.ts +30 -2
  42. package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -1
  43. package/dist/core/extensions/plugins/formats/shared.js +72 -0
  44. package/dist/core/extensions/plugins/formats/shared.js.map +1 -1
  45. package/dist/core/extensions/plugins/install.d.ts +31 -0
  46. package/dist/core/extensions/plugins/install.d.ts.map +1 -1
  47. package/dist/core/extensions/plugins/install.js +102 -12
  48. package/dist/core/extensions/plugins/install.js.map +1 -1
  49. package/dist/core/extensions/plugins/listing.d.ts.map +1 -1
  50. package/dist/core/extensions/plugins/listing.js +1 -0
  51. package/dist/core/extensions/plugins/listing.js.map +1 -1
  52. package/dist/core/extensions/plugins/manifest.d.ts +18 -0
  53. package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
  54. package/dist/core/extensions/plugins/manifest.js.map +1 -1
  55. package/dist/core/extensions/plugins/packaging.d.ts.map +1 -1
  56. package/dist/core/extensions/plugins/packaging.js +1 -0
  57. package/dist/core/extensions/plugins/packaging.js.map +1 -1
  58. package/dist/core/tools/canvas.d.ts +23 -3
  59. package/dist/core/tools/canvas.d.ts.map +1 -1
  60. package/dist/core/tools/canvas.js +99 -4
  61. package/dist/core/tools/canvas.js.map +1 -1
  62. package/dist/extensions/core/canvas.d.ts +20 -2
  63. package/dist/extensions/core/canvas.d.ts.map +1 -1
  64. package/dist/extensions/core/canvas.js +279 -36
  65. package/dist/extensions/core/canvas.js.map +1 -1
  66. package/dist/extensions/core/marketplace.d.ts.map +1 -1
  67. package/dist/extensions/core/marketplace.js +28 -1
  68. package/dist/extensions/core/marketplace.js.map +1 -1
  69. package/dist/extensions/core/scaffold.d.ts +6 -0
  70. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  71. package/dist/extensions/core/scaffold.js +6 -0
  72. package/dist/extensions/core/scaffold.js.map +1 -1
  73. package/dist/modes/interactive/interactive-mode.d.ts +11 -0
  74. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  75. package/dist/modes/interactive/interactive-mode.js +40 -0
  76. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  77. package/dist/modes/interactive/resource-display.d.ts +11 -0
  78. package/dist/modes/interactive/resource-display.d.ts.map +1 -1
  79. package/dist/modes/interactive/resource-display.js +20 -1
  80. package/dist/modes/interactive/resource-display.js.map +1 -1
  81. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  82. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  83. package/examples/extensions/sandbox/package.json +1 -1
  84. package/examples/extensions/with-deps/package.json +1 -1
  85. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAWH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AA6C5F,wBAAgB,WAAW,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAwJlD","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 * The two 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\";\n\n/** Canvas extensions are extensions, so they wear the extension glyph. */\nconst GLYPH = CATEGORY_GLYPH.extensions;\n\nimport type { CanvasInstance } from \"../../core/canvas/registry.js\";\nimport { type CanvasOverview, CanvasSession, parseCanvasRef } from \"../../core/canvas/session.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\"] 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(\"No canvas extensions found in .agents/extensions, .github/extensions, or ~/.copilot/extensions.\");\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) lines.push(` open ${describeInstance(instance)}`);\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\nexport function setupCanvas(pi: ExtensionAPI): 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: homedir(),\n\t\t\tagentDir: getAgentDir(),\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)) pi.registerTool(definition);\n\t};\n\n\tpi.registerCommand(\"canvas\", {\n\t\tdescription:\n\t\t\t\"Work with canvas extensions. /canvas list | /canvas open <extension>[:<canvas>] | /canvas close <instanceId>\",\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(\"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\t// Opening forks a process and binds a port, so it can be slow and must be\n\t\t\t// interruptible. The loader's signal is what makes Esc mean something: it reaches\n\t\t\t// the registry's abandon path, which tells the extension to release a port it may\n\t\t\t// already have bound. Outside a terminal (--print, RPC) there is nothing to draw\n\t\t\t// and nothing to press, so the open simply runs.\n\t\t\tconst outcome = ctx.hasUI\n\t\t\t\t? await ctx.ui.custom<OpenOutcome | undefined>((tui, theme, _keybindings, done) => {\n\t\t\t\t\t\tconst loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);\n\t\t\t\t\t\tloader.onAbort = () => done({ kind: \"cancelled\" });\n\t\t\t\t\t\tvoid canvas\n\t\t\t\t\t\t\t.open(ref, { signal: loader.signal })\n\t\t\t\t\t\t\t.then((instance) => done({ kind: \"opened\", instance }))\n\t\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\t// onAbort fires, and whichever lands first resolves `custom`. Deciding from\n\t\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\t// cancel instead of surfacing as an error.\n\t\t\t\t\t\t\t.catch((error: unknown) =>\n\t\t\t\t\t\t\t\tdone(\n\t\t\t\t\t\t\t\t\tloader.signal.aborted\n\t\t\t\t\t\t\t\t\t\t? { kind: \"cancelled\" }\n\t\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\t),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\treturn loader;\n\t\t\t\t\t})\n\t\t\t\t: await canvas\n\t\t\t\t\t\t.open(ref)\n\t\t\t\t\t\t.then((instance): OpenOutcome => ({ kind: \"opened\", instance }))\n\t\t\t\t\t\t.catch(\n\t\t\t\t\t\t\t(error: unknown): OpenOutcome => ({\n\t\t\t\t\t\t\t\tkind: \"failed\",\n\t\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t);\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// 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\tpi.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}
1
+ {"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAYH,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,EAAE,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAuapF","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 * `pi.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\";\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(pi: 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)) pi.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\tpi.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\tpi.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 pi.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),\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\tpi.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}
@@ -10,7 +10,12 @@
10
10
  * reaches the abandon path (§11.6) and the extension is told to release the port it
11
11
  * may already have bound, rather than the spinner merely disappearing.
12
12
  *
13
- * The two agent tools register on the first successful open and stay for the session:
13
+ * `/new-canvas` is registered here rather than beside `/new-skill` and friends
14
+ * because it is not a file-writing command any more: it opens what it scaffolds
15
+ * and hands the agent a brief to build it, which needs this file's session and
16
+ * `pi.sendUserMessage`. Its decisions live in `core/canvas/scaffold.ts`.
17
+ *
18
+ * The agent tools register on the first successful open and stay for the session:
14
19
  * `registerTool` has no counterpart to remove a tool. So a session that never opens a
15
20
  * canvas pays nothing for them, which is the case that matters (§11.5); after the
16
21
  * first open they cost ~235 tokens and answer honestly when nothing is open.
@@ -20,10 +25,14 @@ import { getAgentDir } from "../../config.js";
20
25
  import { CATEGORY_GLYPH } from "../../core/brand.js";
21
26
  /** Canvas extensions are extensions, so they wear the extension glyph. */
22
27
  const GLYPH = CATEGORY_GLYPH.extensions;
23
- import { CanvasSession, parseCanvasRef } from "../../core/canvas/session.js";
28
+ import { isCanvasRefusal } from "../../core/canvas/lifecycle.js";
29
+ import { CANVAS_HOMES, canvasBuildBrief, parseCanvasRequest, scaffoldCanvas } from "../../core/canvas/scaffold.js";
30
+ import { CanvasSession, parseCanvasRef, } from "../../core/canvas/session.js";
31
+ import { getWorkspacePlatforms } from "../../core/extensions/plugins/formats/platform-targets.js";
32
+ import { isWorkspaceTrusted, trustWorkspace } from "../../core/extensions/plugins/trust.js";
24
33
  import { createCanvasToolDefinitions } from "../../core/tools/canvas.js";
25
34
  import { BorderedLoader } from "../../modes/interactive/components/bordered-loader.js";
26
- const SUBCOMMANDS = ["list", "open", "close"];
35
+ const SUBCOMMANDS = ["list", "open", "close", "reload", "rename", "remove"];
27
36
  function describeInstance(instance) {
28
37
  const title = instance.title ?? instance.canvasId;
29
38
  return `${instance.instanceId} ${title}${instance.url ? ` ${instance.url}` : ""}`;
@@ -34,7 +43,7 @@ function renderOverview(overview) {
34
43
  lines.push(`Canvases are unavailable: ${overview.availability.reason}`, "");
35
44
  }
36
45
  if (overview.listings.length === 0) {
37
- lines.push("No canvas extensions found in .agents/extensions, .github/extensions, or ~/.copilot/extensions.");
46
+ lines.push("No canvas extensions found in .agents/extensions, .github/extensions, ~/.copilot/extensions,", "or any installed plugin. Create one with /new-canvas <what it should do>, or install one with /plugin.");
38
47
  return lines.join("\n");
39
48
  }
40
49
  for (const listing of overview.listings) {
@@ -45,15 +54,23 @@ function renderOverview(overview) {
45
54
  continue;
46
55
  }
47
56
  lines.push(`${GLYPH} ${name}${label} (${listing.scope})`);
48
- for (const instance of listing.open)
57
+ for (const instance of listing.open) {
49
58
  lines.push(` open ${describeInstance(instance)}`);
59
+ // What a canvas can do is otherwise visible only to the model, through
60
+ // `list_canvas_capabilities` — so the person driving the session could not
61
+ // see the surface they were being asked about. Only for open instances,
62
+ // because actions come from running the code (§5.1).
63
+ const actions = overview.actionsByInstance.get(instance.instanceId) ?? [];
64
+ if (actions.length > 0)
65
+ lines.push(` actions ${actions.join(", ")}`);
66
+ }
50
67
  }
51
68
  if (overview.withheldCount > 0) {
52
69
  lines.push("", `${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`);
53
70
  }
54
71
  return lines.join("\n");
55
72
  }
56
- export function setupCanvas(pi) {
73
+ export function setupCanvas(pi, overrides) {
57
74
  let session;
58
75
  let toolsRegistered = false;
59
76
  /**
@@ -68,8 +85,9 @@ export function setupCanvas(pi) {
68
85
  notify = (message, type) => ctx.ui.notify(message, type);
69
86
  session ??= new CanvasSession({
70
87
  cwd: ctx.cwd,
71
- homeDir: homedir(),
88
+ homeDir: overrides?.homeDir ?? homedir(),
72
89
  agentDir: getAgentDir(),
90
+ resolveRuntime: overrides?.resolveRuntime,
73
91
  // A canvas's own diagnostics are the user's business: a stray stdout line means
74
92
  // its author reached for console.log, and a possible leaked port is worth saying.
75
93
  onLog: (id, message) => notify(`[canvas ${id}] ${message}`, "info"),
@@ -88,8 +106,41 @@ export function setupCanvas(pi) {
88
106
  for (const definition of createCanvasToolDefinitions(registry))
89
107
  pi.registerTool(definition);
90
108
  };
109
+ /**
110
+ * Open a canvas behind a cancellable loader.
111
+ *
112
+ * Shared by `/canvas open` and `/new-canvas`, which want identical behaviour:
113
+ * opening forks a process and binds a port, so it can be slow and must be
114
+ * interruptible. The loader's signal is what makes Esc mean something — it
115
+ * reaches the registry's abandon path, which tells the extension to release a
116
+ * port it may already have bound. Outside a terminal (--print, RPC) there is
117
+ * nothing to draw and nothing to press, so the open simply runs.
118
+ */
119
+ const openWithLoader = async (canvas, ref, ctx) => ctx.hasUI
120
+ ? await ctx.ui.custom((tui, theme, _keybindings, done) => {
121
+ const loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);
122
+ loader.onAbort = () => done({ kind: "cancelled" });
123
+ void canvas
124
+ .open(ref, { signal: loader.signal })
125
+ .then((instance) => done({ kind: "opened", instance }))
126
+ // Cancelling races: the signal rejects the pending call at the same moment
127
+ // onAbort fires, and whichever lands first resolves `custom`. Deciding from
128
+ // the signal rather than from who won means a cancel always reads as a
129
+ // cancel instead of surfacing as an error.
130
+ .catch((error) => done(loader.signal.aborted
131
+ ? { kind: "cancelled" }
132
+ : { kind: "failed", message: error instanceof Error ? error.message : String(error) }));
133
+ return loader;
134
+ })
135
+ : await canvas
136
+ .open(ref)
137
+ .then((instance) => ({ kind: "opened", instance }))
138
+ .catch((error) => ({
139
+ kind: "failed",
140
+ message: error instanceof Error ? error.message : String(error),
141
+ }));
91
142
  pi.registerCommand("canvas", {
92
- description: "Work with canvas extensions. /canvas list | /canvas open <extension>[:<canvas>] | /canvas close <instanceId>",
143
+ description: "Work with canvas extensions. /canvas list | open <extension>[:<canvas>] | reload [extension] | close <instanceId> | rename <extension> <new-name> | remove <extension>",
93
144
  getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),
94
145
  handler: async (args, ctx) => {
95
146
  const trimmed = args.trim();
@@ -111,6 +162,126 @@ export function setupCanvas(pi) {
111
162
  ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, "info");
112
163
  return;
113
164
  }
165
+ if (trimmed.startsWith("reload")) {
166
+ // A reload re-forks the extension, so it can take as long as an open and is
167
+ // worth being able to abandon — but unlike an open there is nothing to
168
+ // abandon *to*: the registry keeps the old child serving until the new one
169
+ // answers, so a cancel here just stops waiting.
170
+ const requested = trimmed.slice("reload".length).trim();
171
+ const running = canvas.runningExtensionIds();
172
+ const extensionId = requested || (running.length === 1 ? running[0] : undefined);
173
+ if (!extensionId) {
174
+ ctx.ui.notify(running.length === 0
175
+ ? "Nothing is open, so there is nothing to reload. Open a canvas first with /canvas open <extension>."
176
+ : `Several extensions are open; name one: ${running.join(", ")}.`, "warning");
177
+ return;
178
+ }
179
+ try {
180
+ const result = await canvas.reload(extensionId);
181
+ const lines = [`Reloaded ${result.extensionId} from disk.`];
182
+ // Same reason the tool reports it: an edit to `actions: [...]` is
183
+ // otherwise invisible, and a typo there fails by the action simply
184
+ // not being there.
185
+ const { added, removed, changed } = result.actions;
186
+ if (added.length > 0)
187
+ lines.push(` + ${added.join(", ")}`);
188
+ if (removed.length > 0)
189
+ lines.push(` - ${removed.join(", ")}`);
190
+ if (changed.length > 0)
191
+ lines.push(` ~ ${changed.join(", ")} (description or schema)`);
192
+ for (const instance of result.reopened) {
193
+ // The url is the point of saying anything: the extension binds a new
194
+ // port and mints a new token on every open, so the tab the person has
195
+ // in front of them is now pointing at a closed port.
196
+ lines.push(` ${describeInstance(instance)}`);
197
+ }
198
+ if (result.reopened.length > 0)
199
+ lines.push("", "Open the new url(s) — the previous tab is dead.");
200
+ for (const drop of result.dropped) {
201
+ lines.push(` ${drop.canvasId} (${drop.instanceId}) did not come back: ${drop.reason}`);
202
+ }
203
+ ctx.ui.notify(lines.join("\n"), result.dropped.length > 0 ? "warning" : "info");
204
+ }
205
+ catch (error) {
206
+ // The registry only swaps children once the new one is ready, so the
207
+ // canvas the person is looking at survived this. Say so, or they will
208
+ // think they just lost it.
209
+ ctx.ui.notify(`Reloading ${extensionId} failed, so it is still running the code it was started with: ` +
210
+ `${error instanceof Error ? error.message : String(error)}`, "error");
211
+ }
212
+ return;
213
+ }
214
+ if (trimmed.startsWith("rename")) {
215
+ const [from, to, ...rest] = trimmed.slice("rename".length).trim().split(/\s+/).filter(Boolean);
216
+ if (!from || !to || rest.length > 0) {
217
+ ctx.ui.notify(`Usage: /canvas rename <extension> <new-name> (see /canvas list)`, "warning");
218
+ return;
219
+ }
220
+ // Renaming closes whatever the extension had open, because the directory
221
+ // is about to move out from under it. Say so before doing it, not after.
222
+ const wasOpen = canvas.instances().filter((instance) => instance.extensionId === from);
223
+ const result = await canvas.rename(from, to);
224
+ if (isCanvasRefusal(result)) {
225
+ ctx.ui.notify(`/canvas rename: ${result.detail}`, "warning");
226
+ return;
227
+ }
228
+ const lines = [`Renamed ${result.from} → ${result.to}.`, ` ${result.dir}`];
229
+ if (result.rewrites.length > 0) {
230
+ lines.push("", "Rewrote in extension.mjs:");
231
+ for (const rewrite of result.rewrites)
232
+ lines.push(` line ${rewrite.line}: ${rewrite.after.trim()}`);
233
+ }
234
+ if (result.leftovers.length > 0) {
235
+ // Prose is not identity, so it is reported rather than edited — a rename
236
+ // that silently rewrote a description would be worse than one that
237
+ // admits what it left.
238
+ lines.push("", `"${result.from}" still appears on line(s) ${result.leftovers.join(", ")}; those look like prose, so they were left alone.`);
239
+ }
240
+ if (wasOpen.length > 0)
241
+ lines.push("", `Closed ${wasOpen.length} open instance(s) to move the directory.`);
242
+ lines.push("", `Open it with /canvas open ${result.to}.`);
243
+ ctx.ui.notify(lines.join("\n"), "info");
244
+ return;
245
+ }
246
+ if (trimmed.startsWith("remove")) {
247
+ const target = trimmed.slice("remove".length).trim();
248
+ if (!target) {
249
+ ctx.ui.notify("Usage: /canvas remove <extension> (see /canvas list)", "warning");
250
+ return;
251
+ }
252
+ const known = canvas.knownExtensionIds();
253
+ if (!known.includes(target)) {
254
+ ctx.ui.notify(`No canvas extension "${target}" (found: ${known.join(", ") || "none"}).`, "warning");
255
+ return;
256
+ }
257
+ // Deleting source is not undoable from here, so it is confirmed — and
258
+ // outside a terminal there is nobody to ask, so it is refused rather than
259
+ // assumed. `--print` and RPC should not be able to delete a directory
260
+ // because a command happened to be piped in.
261
+ if (!ctx.hasUI) {
262
+ ctx.ui.notify(`/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.`, "warning");
263
+ return;
264
+ }
265
+ const confirmed = await ctx.ui.confirm(`Delete canvas "${target}"?`, "This deletes the extension directory and everything in it. It is not undoable from here.");
266
+ if (!confirmed) {
267
+ ctx.ui.notify(`Left ${target} alone.`, "info");
268
+ return;
269
+ }
270
+ const openCount = canvas.instances().filter((instance) => instance.extensionId === target).length;
271
+ const result = await canvas.remove(target);
272
+ if (isCanvasRefusal(result)) {
273
+ ctx.ui.notify(`/canvas remove: ${result.detail}`, "warning");
274
+ return;
275
+ }
276
+ ctx.ui.notify([
277
+ `Removed ${result.id}.`,
278
+ ` ${result.dir}`,
279
+ openCount > 0 ? `Closed ${openCount} open instance(s) first.` : "",
280
+ ]
281
+ .filter((line) => line.length > 0)
282
+ .join("\n"), "info");
283
+ return;
284
+ }
114
285
  if (!trimmed.startsWith("open")) {
115
286
  ctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(", ")}.`, "warning");
116
287
  return;
@@ -120,34 +291,7 @@ export function setupCanvas(pi) {
120
291
  ctx.ui.notify("Usage: /canvas open <extension>[:<canvas>] (see /canvas list)", "warning");
121
292
  return;
122
293
  }
123
- // Opening forks a process and binds a port, so it can be slow and must be
124
- // interruptible. The loader's signal is what makes Esc mean something: it reaches
125
- // the registry's abandon path, which tells the extension to release a port it may
126
- // already have bound. Outside a terminal (--print, RPC) there is nothing to draw
127
- // and nothing to press, so the open simply runs.
128
- const outcome = ctx.hasUI
129
- ? await ctx.ui.custom((tui, theme, _keybindings, done) => {
130
- const loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);
131
- loader.onAbort = () => done({ kind: "cancelled" });
132
- void canvas
133
- .open(ref, { signal: loader.signal })
134
- .then((instance) => done({ kind: "opened", instance }))
135
- // Cancelling races: the signal rejects the pending call at the same moment
136
- // onAbort fires, and whichever lands first resolves `custom`. Deciding from
137
- // the signal rather than from who won means a cancel always reads as a
138
- // cancel instead of surfacing as an error.
139
- .catch((error) => done(loader.signal.aborted
140
- ? { kind: "cancelled" }
141
- : { kind: "failed", message: error instanceof Error ? error.message : String(error) }));
142
- return loader;
143
- })
144
- : await canvas
145
- .open(ref)
146
- .then((instance) => ({ kind: "opened", instance }))
147
- .catch((error) => ({
148
- kind: "failed",
149
- message: error instanceof Error ? error.message : String(error),
150
- }));
294
+ const outcome = await openWithLoader(canvas, ref, ctx);
151
295
  // `custom` can also settle on its own when the overlay is dismissed, without
152
296
  // our `done` ever running — an escape that closes the surface leaves no
153
297
  // outcome. Treat that as the cancel it is rather than reading `.kind` off
@@ -179,6 +323,105 @@ export function setupCanvas(pi) {
179
323
  .join("\n"), "info");
180
324
  },
181
325
  });
326
+ // ── /new-canvas <name> | <description> ────────────────────────────────────
327
+ // Authoring, in Copilot's `/create-canvas` shape: describe what you want, the
328
+ // agent writes it, and it is already open while it does. Design:
329
+ // `docs/canvas-extensions-design.md` §9 Phase 3, §13.
330
+ //
331
+ // Unlike the other `/new-*` scaffolds this one needs no /reload: canvases are
332
+ // discovered when /canvas runs, not loaded at session start.
333
+ pi.registerCommand("new-canvas", {
334
+ description: "Create a canvas extension. Usage: /new-canvas <what it should do> | /new-canvas <name> | /new-canvas <name>: <what it should do>",
335
+ getArgumentCompletions: () => [],
336
+ handler: async (args, ctx) => {
337
+ const request = parseCanvasRequest(args);
338
+ if (typeof request === "string") {
339
+ ctx.ui.notify(`/new-canvas: ${request}`, "warning");
340
+ return;
341
+ }
342
+ // `--platform claude` is dropped rather than redirected: Claude has no
343
+ // canvas convention, and silently writing into someone else's marker
344
+ // directory would put an extension where that vendor will never look.
345
+ const requested = getWorkspacePlatforms() ?? ["agents"];
346
+ const targets = requested.filter((platform) => CANVAS_HOMES[platform] !== undefined);
347
+ if (targets.length === 0) {
348
+ ctx.ui.notify(`/new-canvas: no canvas home for platform "${requested.join(", ")}". ` +
349
+ "Canvas extensions exist under .agents/extensions (agents) and .github/extensions (github) only.", "warning");
350
+ return;
351
+ }
352
+ const { created, skipped } = scaffoldCanvas(ctx.cwd, request.name, targets);
353
+ // A canvas lands in the working tree, which is where the trust gate looks
354
+ // (`core/canvas/trust.ts`) — so without this the canvas you just asked for
355
+ // is withheld the moment you try to open it, and refused with "came with
356
+ // this repository", which is not true of a file created seconds ago.
357
+ //
358
+ // Granting is the same call `/plugin install --scope project` makes, for
359
+ // the same stated reason: a person typing this command in this directory
360
+ // is the human act workspace trust asks for. It is deliberately wider than
361
+ // this one canvas — it also lets plugins already committed here run their
362
+ // hooks and MCP servers — so it is said out loud and pointed at its
363
+ // reverse, never done silently.
364
+ let trustNote = "";
365
+ if (created.length > 0 && !isWorkspaceTrusted(ctx.cwd, getAgentDir())) {
366
+ trustWorkspace(ctx.cwd, getAgentDir());
367
+ trustNote =
368
+ `Trusted this workspace so the canvas can run. Plugins committed here may now run hooks ` +
369
+ `and MCP servers too; \`/plugin untrust\` reverses it.`;
370
+ }
371
+ if (created.length === 0) {
372
+ ctx.ui.notify([
373
+ "Nothing created — these already exist:",
374
+ ...skipped.map((file) => ` ${file}`),
375
+ "",
376
+ `Open the existing one with /canvas open ${request.name}, or pick another name.`,
377
+ ].join("\n"), "warning");
378
+ return;
379
+ }
380
+ // Open before saying anything. Copilot's `/create-canvas` puts the canvas
381
+ // in front of the person and *then* builds it, and the order is the point:
382
+ // a build brief that can name a live url and instance id is a different
383
+ // instruction than one that cannot, and the person watches it change.
384
+ const canvas = ensureSession(ctx);
385
+ const outcome = await openWithLoader(canvas, { extensionId: request.name }, ctx);
386
+ const opened = outcome?.kind === "opened" ? outcome.instance : undefined;
387
+ if (opened)
388
+ registerToolsOnce(canvas);
389
+ const lines = ["Canvas created:", ...created.map((file) => ` ${file}`)];
390
+ if (skipped.length > 0)
391
+ lines.push("Skipped (already exist):", ...skipped.map((file) => ` ${file}`));
392
+ lines.push("");
393
+ if (opened) {
394
+ lines.push(`Opened ${opened.canvasId} (${opened.instanceId}).`);
395
+ if (opened.url)
396
+ lines.push(`Open in a browser: ${opened.url}`);
397
+ }
398
+ else if (outcome?.kind === "failed") {
399
+ // Not fatal: the file is written and discoverable, so say what broke and
400
+ // leave them a way in rather than making it look like nothing happened.
401
+ lines.push(`Created, but opening it failed: ${outcome.message}`, `Retry with /canvas open ${request.name}.`);
402
+ }
403
+ else {
404
+ lines.push(`Opening cancelled. Open it when you want with /canvas open ${request.name}.`);
405
+ }
406
+ if (trustNote)
407
+ lines.push("", trustNote);
408
+ if (request.description) {
409
+ lines.push("", `Building it now from: "${request.description}"`, "Steer it like any other turn, or interrupt to take over the file yourself.");
410
+ }
411
+ else {
412
+ lines.push("", `Edit ${created[0]} and run /canvas reload ${request.name} to see each change.`);
413
+ }
414
+ ctx.ui.notify(lines.join("\n"), "info");
415
+ // The half that makes this `/create-canvas` rather than a scaffold. Queued
416
+ // as a follow-up so it lands on the next turn whether or not the agent is
417
+ // mid-stream, and only when a description was given: `/new-canvas my-board`
418
+ // still means "give me the template", and starting a build nobody asked for
419
+ // would burn a turn and overwrite the file they meant to edit.
420
+ if (request.description) {
421
+ await pi.sendUserMessage(canvasBuildBrief(request.name, request.description, created[0], opened ? { instanceId: opened.instanceId, url: opened.url } : undefined), { deliverAs: "followUp" });
422
+ }
423
+ },
424
+ });
182
425
  // Teardown on shutdown (§6): a browser tab gives no close signal, so without this
183
426
  // every child and loopback port outlives the session. `session_shutdown` is where
184
427
  // loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and