@kolisachint/hoocode-agent 0.5.25 → 0.5.27

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 (88) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/core/canvas/lifecycle.d.ts +93 -0
  3. package/dist/core/canvas/lifecycle.d.ts.map +1 -0
  4. package/dist/core/canvas/lifecycle.js +165 -0
  5. package/dist/core/canvas/lifecycle.js.map +1 -0
  6. package/dist/core/canvas/registry.d.ts +89 -0
  7. package/dist/core/canvas/registry.d.ts.map +1 -1
  8. package/dist/core/canvas/registry.js +205 -10
  9. package/dist/core/canvas/registry.js.map +1 -1
  10. package/dist/core/canvas/scaffold.d.ts +123 -0
  11. package/dist/core/canvas/scaffold.d.ts.map +1 -0
  12. package/dist/core/canvas/scaffold.js +376 -0
  13. package/dist/core/canvas/scaffold.js.map +1 -0
  14. package/dist/core/canvas/session.d.ts +39 -1
  15. package/dist/core/canvas/session.d.ts.map +1 -1
  16. package/dist/core/canvas/session.js +83 -1
  17. package/dist/core/canvas/session.js.map +1 -1
  18. package/dist/core/capabilities/lexical.d.ts +4 -0
  19. package/dist/core/capabilities/lexical.d.ts.map +1 -1
  20. package/dist/core/capabilities/lexical.js +104 -4
  21. package/dist/core/capabilities/lexical.js.map +1 -1
  22. package/dist/core/capabilities/registry.d.ts +3 -1
  23. package/dist/core/capabilities/registry.d.ts.map +1 -1
  24. package/dist/core/capabilities/registry.js.map +1 -1
  25. package/dist/core/self-docs.d.ts +103 -0
  26. package/dist/core/self-docs.d.ts.map +1 -0
  27. package/dist/core/self-docs.js +351 -0
  28. package/dist/core/self-docs.js.map +1 -0
  29. package/dist/core/system-prompt.d.ts +12 -0
  30. package/dist/core/system-prompt.d.ts.map +1 -1
  31. package/dist/core/system-prompt.js +11 -1
  32. package/dist/core/system-prompt.js.map +1 -1
  33. package/dist/core/tools/canvas.d.ts +23 -3
  34. package/dist/core/tools/canvas.d.ts.map +1 -1
  35. package/dist/core/tools/canvas.js +99 -4
  36. package/dist/core/tools/canvas.js.map +1 -1
  37. package/dist/extensions/core/canvas.d.ts +20 -2
  38. package/dist/extensions/core/canvas.d.ts.map +1 -1
  39. package/dist/extensions/core/canvas.js +279 -36
  40. package/dist/extensions/core/canvas.js.map +1 -1
  41. package/dist/extensions/core/hoo-core.d.ts +1 -0
  42. package/dist/extensions/core/hoo-core.d.ts.map +1 -1
  43. package/dist/extensions/core/hoo-core.js +3 -0
  44. package/dist/extensions/core/hoo-core.js.map +1 -1
  45. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  46. package/dist/extensions/core/mcp-loader.js +8 -2
  47. package/dist/extensions/core/mcp-loader.js.map +1 -1
  48. package/dist/extensions/core/scaffold.d.ts +7 -1
  49. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  50. package/dist/extensions/core/scaffold.js +7 -185
  51. package/dist/extensions/core/scaffold.js.map +1 -1
  52. package/dist/extensions/core/self-knowledge.d.ts +28 -0
  53. package/dist/extensions/core/self-knowledge.d.ts.map +1 -0
  54. package/dist/extensions/core/self-knowledge.js +199 -0
  55. package/dist/extensions/core/self-knowledge.js.map +1 -0
  56. package/docs/canvas.md +117 -0
  57. package/docs/compaction.md +4 -4
  58. package/docs/custom-provider.md +1 -1
  59. package/docs/development.md +1 -1
  60. package/docs/docs.json +27 -2
  61. package/docs/extensions.md +12 -12
  62. package/docs/index.md +8 -0
  63. package/docs/keybindings.md +2 -2
  64. package/docs/mcp.md +97 -0
  65. package/docs/models.md +1 -1
  66. package/docs/modes.md +87 -0
  67. package/docs/packages.md +4 -4
  68. package/docs/plugins.md +124 -0
  69. package/docs/prompt-templates.md +1 -1
  70. package/docs/providers.md +2 -2
  71. package/docs/quickstart.md +2 -2
  72. package/docs/rpc.md +5 -5
  73. package/docs/sdk.md +5 -5
  74. package/docs/session-format.md +3 -3
  75. package/docs/sessions.md +1 -1
  76. package/docs/settings.md +3 -3
  77. package/docs/shell-aliases.md +1 -1
  78. package/docs/skills.md +2 -2
  79. package/docs/terminal-setup.md +1 -1
  80. package/docs/termux.md +2 -2
  81. package/docs/themes.md +3 -3
  82. package/docs/usage.md +93 -4
  83. package/docs/windows.md +1 -1
  84. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  85. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  86. package/examples/extensions/sandbox/package.json +1 -1
  87. package/examples/extensions/with-deps/package.json +1 -1
  88. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,133 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.27] - 2026-08-22
4
+
5
+ ### Added
6
+
7
+ - hoocode can answer questions about itself. The startup banner already promised
8
+ "hoocode can explain its own features and look up its docs", but nothing put a
9
+ docs path into model context — `getDocsPath()`'s only caller printed paths to
10
+ the human on an auth failure. The agent could not find them on its own either:
11
+ its cwd is your project, so searching there turns up your docs, never
12
+ hoocode's.
13
+
14
+ The system prompt now lists the shipped docs (~215 tokens, filenames only),
15
+ and a `SearchHooCode` tool retrieves them at the heading level, returning a
16
+ file path and line number to read. It also indexes the session's skills,
17
+ slash commands, and subagents, so "what can you do?" has one place that
18
+ answers it. MCP tools remain with `ResolveMcpTools`.
19
+
20
+ - New documentation for features that had none: `plugins.md` (marketplaces,
21
+ the trust model, plugin formats), `mcp.md` (server config, transports,
22
+ deferred schemas), `modes.md` (ask/plan/build/debug and the plan → grill →
23
+ approve → goal workflow), and `canvas.md`. `usage.md` gains the 13 slash
24
+ commands that were shipping undocumented, including `/plugin`, `/mode`,
25
+ `/loop`, `/canvas`, `/cost`, and the `/new-*` scaffolds, plus the 12 CLI
26
+ flags that were missing from the reference — the optional tool bundles
27
+ (`--enable-todowrite`, `--enable-webtools`, `--enable-search-tool`,
28
+ `--enable-plugintools`), the subagent flags, `--light`,
29
+ `--print-token-surface`, `--platform`, `--team`, and `--disallowed-tools`.
30
+ Every slash command and CLI flag hoocode accepts is now documented.
31
+
32
+ ### Fixed
33
+
34
+ - Capability search could not match a plural: a query for "theme" missed a
35
+ heading reading "Themes". Singular forms are now indexed alongside the
36
+ originals, so exact tool names still match exactly.
37
+
38
+ - Capability search ranked on question filler. "how do I add a custom theme"
39
+ was won by a section matching "add" and "how" over the one titled "Creating a
40
+ Custom Theme". Function words are now dropped from queries.
41
+
42
+ - The dense capability index is a shared store keyed on a content hash, but the
43
+ MCP loader seeded it from only its own tools, evicting any other producer's
44
+ vectors. It now seeds from the full registered set.
45
+
46
+ - Corrected the global config directory throughout the docs. Paths were written
47
+ as `~/.pi/agent/` or `~/.hoocode/agent/`; the real directory is `~/.hoocode`,
48
+ with no `agent/` segment. Every global path — `settings.json`,
49
+ `keybindings.json`, `auth.json`, `sessions/`, `skills/`, `extensions/`,
50
+ `themes/` — pointed somewhere that does not exist.
51
+
52
+ ## [0.5.26] - 2026-08-21
53
+
54
+ ### Added
55
+
56
+ - `/new-canvas` now takes a description, not just a name, and builds what you
57
+ describe. `/new-canvas a kanban board for the release checklist` scaffolds the
58
+ extension, derives and reports a directory name, opens the canvas, and hands
59
+ the agent a brief to build it — you steer it like any other turn. The old
60
+ shapes still work: `/new-canvas my-board` gives you the template to edit by
61
+ hand and starts no build, and `/new-canvas my-board: <description>` lets you
62
+ name it yourself.
63
+
64
+ - `/canvas reload [extension]` and a `reload_canvas` tool, so a canvas can be
65
+ changed while you are looking at it. Editing an extension's code used to do
66
+ nothing at all while it was open — the running process was forked from the old
67
+ code, so neither the open page nor a newly opened second instance saw the
68
+ change, and the only way to see one was to restart the session. Now the agent
69
+ reloads after each edit and the new capability is immediately callable.
70
+
71
+ The new code is forked and asked for its declarations *before* the old process
72
+ is stopped, so an edit that does not run leaves the canvas you are looking at
73
+ exactly as it was and reports the error. Instances keep their ids and the input
74
+ they were opened with, but each gets a **new url** — the extension binds a new
75
+ port and mints a new token on every open — so the previous browser tab is dead
76
+ and the replacement url is printed.
77
+
78
+ - An arrow-key games canvas, hoocode's first canvas of its own, at
79
+ `.agents/extensions/arrow-key-games/`. Open it with
80
+ `/canvas open arrow-key-games`. Snake and a randomly carved Maze are
81
+ yours alone; **Duel** is a turn-based coin race you play against the model —
82
+ you move with the arrow keys, it moves through `invoke_canvas_action`, and the
83
+ board updates live for both of you. It was built by running
84
+ `/new-canvas create lightweight games that can be played with keyboard arrow keys`
85
+ and iterating with `reload_canvas`, which is what the two entries above are for.
86
+
87
+ - `/canvas rename <extension> <new-name>` and `/canvas remove <extension>`. A
88
+ canvas's name lives in four places — the directory (which *is* the extension
89
+ id), the canvas's own `id`, its `displayName`, and its header comment — and
90
+ getting the `id` wrong by hand drops the canvas you are looking at on the next
91
+ reload. Rename does all of it at once, closes what was open first, and prints
92
+ every line it rewrote. It only touches a string that is *entirely* the old
93
+ name, so a sentence mentioning the canvas is reported rather than rewritten.
94
+ Remove asks before deleting, refuses when there is no surface to ask on, and
95
+ both refuse a canvas that came from a plugin, pointing at `/plugin` instead.
96
+
97
+ - Reloading now reports which actions the edit **added, removed or changed**, so
98
+ writing a new action tells you whether the host can see it. Previously a typo
99
+ inside `actions: [...]` failed silently — the action just was not there.
100
+ "Nothing changed" is reported too, since silence reads as success.
101
+
102
+ - `/canvas list` names the actions of each open canvas. They were visible only to
103
+ the model, so the person steering the session could not see the surface they
104
+ were being asked about.
105
+
106
+ ### Changed
107
+
108
+ - `/new-canvas` moved from the `/new-*` scaffold family to the canvas surface. It
109
+ is no longer a file-writing command: it opens what it creates and drives the
110
+ agent loop.
111
+
112
+ - `/new-canvas` derives a much better name from a description. It was naming the
113
+ *request* rather than the thing — `create lightweight games…` became
114
+ `create-lightweight-games`, `help me compare two benchmark runs` became
115
+ `help-compare-two` — because opening words like "create", "build", "show" and
116
+ "help me" landed in the directory name, and `-ing`/`-ed` words crowded out the
117
+ nouns. On twelve realistic descriptions, seven were wrong; all of them now read
118
+ as names for the thing: `lightweight-games-keyboard`, `dashboard-flaky-tests`,
119
+ `compare-benchmark-runs`.
120
+
121
+ - The scaffolded template names itself once, in `ID` and `NAME` at the top,
122
+ instead of repeating the name in six places. Existing canvases are unaffected —
123
+ rename handles both shapes.
124
+
125
+ - The build brief now tells the model to leave the canvas's `id` alone and rename
126
+ `displayName` instead. Renaming the id drops the instance the person is
127
+ watching on the next reload — correct behaviour, since the canvas the instance
128
+ was opened against no longer exists, but a trap the model walks into because
129
+ the scaffold names the canvas after a directory derived from a sentence.
130
+
3
131
  ## [0.5.25] - 2026-08-20
4
132
 
5
133
  ### Added
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Renaming and removing a canvas extension.
3
+ *
4
+ * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas
5
+ * has more identity than a file does, and it is spread across four places: the
6
+ * directory name (which *is* the extension id, since discovery keys off
7
+ * position), the `id` a canvas declares, its `displayName`, and whatever the
8
+ * header comment tells the reader to type. Renaming by hand means getting all
9
+ * four right, and getting the `id` wrong is not a typo — it drops the canvas the
10
+ * person is looking at on the next reload, because the instance was opened
11
+ * against a canvas that no longer exists.
12
+ *
13
+ * So the rewriting is deliberately narrow. It changes the three places the
14
+ * scaffold puts the name and nothing else, then **reports every other line the
15
+ * old name still appears on** rather than guessing at prose. A rename that
16
+ * silently edited a description would be worse than one that admits what it left
17
+ * behind.
18
+ */
19
+ import type { CanvasSearchRoot, DiscoveredCanvasExtension } from "./discovery.js";
20
+ /** One line the rename changed. */
21
+ export interface CanvasRewrite {
22
+ /** 1-based, so it can be read straight off an editor gutter. */
23
+ line: number;
24
+ before: string;
25
+ after: string;
26
+ }
27
+ /** What {@link renameCanvasExtension} did. */
28
+ export interface CanvasRenameResult {
29
+ from: string;
30
+ to: string;
31
+ /** Workspace-relative where possible, for a message a person can act on. */
32
+ dir: string;
33
+ rewrites: CanvasRewrite[];
34
+ /**
35
+ * Lines where the old name survives, because they are prose rather than
36
+ * identity. Surfaced, never silently edited.
37
+ */
38
+ leftovers: number[];
39
+ }
40
+ /**
41
+ * Why a canvas cannot be renamed or removed in place.
42
+ *
43
+ * `packaged` is the interesting one: a plugin's canvases are named by its
44
+ * manifest rather than by where they sit, so moving the directory would either
45
+ * do nothing or break the plugin. That is `/plugin`'s job, not this one's.
46
+ */
47
+ export type CanvasLifecycleRefusal = {
48
+ reason: "invalid-name";
49
+ detail: string;
50
+ } | {
51
+ reason: "exists";
52
+ detail: string;
53
+ } | {
54
+ reason: "packaged";
55
+ detail: string;
56
+ } | {
57
+ reason: "unwritable";
58
+ detail: string;
59
+ };
60
+ /**
61
+ * Whether this extension is one we may move or delete.
62
+ *
63
+ * The test is positional and deliberately so: an extension is ours to edit when
64
+ * its directory sits *directly* inside one of the search roots, which is exactly
65
+ * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a
66
+ * package — resolved through a manifest by `plugin-canvases.ts` — and its
67
+ * location is that package's business.
68
+ */
69
+ export declare function canvasHomeRoot(extension: DiscoveredCanvasExtension, roots: readonly CanvasSearchRoot[]): CanvasSearchRoot | undefined;
70
+ /**
71
+ * Rename a canvas extension: its directory, and the name inside its code.
72
+ *
73
+ * The caller is responsible for closing open instances first. This does not do
74
+ * it, because closing is the *session's* concern and this module has no session —
75
+ * but skipping it leaves an instance pointing at a directory that has moved.
76
+ */
77
+ export declare function renameCanvasExtension(extension: DiscoveredCanvasExtension, to: string, roots: readonly CanvasSearchRoot[]): CanvasRenameResult | CanvasLifecycleRefusal;
78
+ /** What {@link removeCanvasExtension} deleted. */
79
+ export interface CanvasRemoveResult {
80
+ id: string;
81
+ dir: string;
82
+ }
83
+ /**
84
+ * Delete a canvas extension's directory.
85
+ *
86
+ * As with rename, the caller closes open instances first — a deleted directory
87
+ * whose child is still forked leaves a process serving code that no longer
88
+ * exists on disk, which is the most confusing state of all.
89
+ */
90
+ export declare function removeCanvasExtension(extension: DiscoveredCanvasExtension, roots: readonly CanvasSearchRoot[]): CanvasRemoveResult | CanvasLifecycleRefusal;
91
+ /** Narrow a lifecycle return value to its refusal case. */
92
+ export declare function isCanvasRefusal(value: object): value is CanvasLifecycleRefusal;
93
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAIlF,mCAAmC;AACnC,MAAM,WAAW,aAAa;IAC7B,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,8CAA8C;AAC9C,MAAM,WAAW,kBAAkB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B;;;OAGG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,sBAAsB,GAC/B;IAAE,MAAM,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC7B,SAAS,EAAE,yBAAyB,EACpC,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,gBAAgB,GAAG,SAAS,CAG9B;AAwDD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACpC,SAAS,EAAE,yBAAyB,EACpC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,kBAAkB,GAAG,sBAAsB,CA4C7C;AAED,kDAAkD;AAClD,MAAM,WAAW,kBAAkB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACpC,SAAS,EAAE,yBAAyB,EACpC,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,kBAAkB,GAAG,sBAAsB,CAc7C;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,sBAAsB,CAE9E","sourcesContent":["/**\n * Renaming and removing a canvas extension.\n *\n * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas\n * has more identity than a file does, and it is spread across four places: the\n * directory name (which *is* the extension id, since discovery keys off\n * position), the `id` a canvas declares, its `displayName`, and whatever the\n * header comment tells the reader to type. Renaming by hand means getting all\n * four right, and getting the `id` wrong is not a typo — it drops the canvas the\n * person is looking at on the next reload, because the instance was opened\n * against a canvas that no longer exists.\n *\n * So the rewriting is deliberately narrow. It changes the three places the\n * scaffold puts the name and nothing else, then **reports every other line the\n * old name still appears on** rather than guessing at prose. A rename that\n * silently edited a description would be worse than one that admits what it left\n * behind.\n */\n\nimport { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { CanvasSearchRoot, DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { CANVAS_ENTRY_FILE } from \"./discovery.js\";\nimport { validateCanvasName } from \"./scaffold.js\";\n\n/** One line the rename changed. */\nexport interface CanvasRewrite {\n\t/** 1-based, so it can be read straight off an editor gutter. */\n\tline: number;\n\tbefore: string;\n\tafter: string;\n}\n\n/** What {@link renameCanvasExtension} did. */\nexport interface CanvasRenameResult {\n\tfrom: string;\n\tto: string;\n\t/** Workspace-relative where possible, for a message a person can act on. */\n\tdir: string;\n\trewrites: CanvasRewrite[];\n\t/**\n\t * Lines where the old name survives, because they are prose rather than\n\t * identity. Surfaced, never silently edited.\n\t */\n\tleftovers: number[];\n}\n\n/**\n * Why a canvas cannot be renamed or removed in place.\n *\n * `packaged` is the interesting one: a plugin's canvases are named by its\n * manifest rather than by where they sit, so moving the directory would either\n * do nothing or break the plugin. That is `/plugin`'s job, not this one's.\n */\nexport type CanvasLifecycleRefusal =\n\t| { reason: \"invalid-name\"; detail: string }\n\t| { reason: \"exists\"; detail: string }\n\t| { reason: \"packaged\"; detail: string }\n\t| { reason: \"unwritable\"; detail: string };\n\n/**\n * Whether this extension is one we may move or delete.\n *\n * The test is positional and deliberately so: an extension is ours to edit when\n * its directory sits *directly* inside one of the search roots, which is exactly\n * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a\n * package — resolved through a manifest by `plugin-canvases.ts` — and its\n * location is that package's business.\n */\nexport function canvasHomeRoot(\n\textension: DiscoveredCanvasExtension,\n\troots: readonly CanvasSearchRoot[],\n): CanvasSearchRoot | undefined {\n\tconst parent = path.dirname(path.resolve(extension.dir));\n\treturn roots.find((root) => path.resolve(root.dir) === parent);\n}\n\n/**\n * Swap the old name for the new one where it is *identity* rather than prose.\n *\n * Two rules, and the first is the interesting one: **a string literal whose\n * entire content is the old name**. That covers `id`, `displayName`, `title` and\n * the scaffold's `const ID = \"…\"` without knowing any of their names, and it\n * does not touch a sentence that merely mentions the canvas — `\"the board is the\n * point of the board\"` is not the string `\"board\"`. A whole-word replacement\n * would have rewritten that sentence, which is worse than leaving it.\n *\n * The second rule keeps the instructions in a comment honest: `/canvas open\n * <old>` would otherwise tell the next reader to type a name that no longer\n * resolves.\n *\n * Anything else is left and reported. The scaffold names itself in exactly three\n * places, all covered here, so a canvas that has not been renamed by hand comes\n * through with nothing left over.\n */\nfunction rewriteEntrySource(source: string, from: string, to: string): { source: string; rewrites: CanvasRewrite[] } {\n\tconst name = escapeForRegExp(from);\n\tconst substitutions: [RegExp, string][] = [\n\t\t// A quoted string that is *only* the old name. Backticks included, but only\n\t\t// without interpolation — `${x}-${from}` is a computed value, not a literal.\n\t\t[new RegExp(`([\"'])${name}\\\\1`, \"g\"), `$1${to}$1`],\n\t\t[new RegExp(\"`\" + name + \"`\", \"g\"), `\\`${to}\\``],\n\t\t// Instructions in a comment: /canvas open <old>, /canvas reload <old>, …\n\t\t[new RegExp(`(/canvas\\\\s+(?:open|reload|rename|remove|close)\\\\s+)${name}\\\\b`, \"g\"), `$1${to}`],\n\t];\n\n\tconst before = source.split(\"\\n\");\n\tconst after = before.map((line) =>\n\t\tsubstitutions.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), line),\n\t);\n\n\tconst rewrites: CanvasRewrite[] = [];\n\tafter.forEach((line, index) => {\n\t\tif (line !== before[index]) rewrites.push({ line: index + 1, before: before[index] as string, after: line });\n\t});\n\treturn { source: after.join(\"\\n\"), rewrites };\n}\n\n/** Lines still mentioning `name` after the rewrite. */\nfunction remainingMentions(source: string, name: string): number[] {\n\tconst lines: number[] = [];\n\tsource.split(\"\\n\").forEach((line, index) => {\n\t\tif (line.includes(name)) lines.push(index + 1);\n\t});\n\treturn lines;\n}\n\nfunction escapeForRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Rename a canvas extension: its directory, and the name inside its code.\n *\n * The caller is responsible for closing open instances first. This does not do\n * it, because closing is the *session's* concern and this module has no session —\n * but skipping it leaves an instance pointing at a directory that has moved.\n */\nexport function renameCanvasExtension(\n\textension: DiscoveredCanvasExtension,\n\tto: string,\n\troots: readonly CanvasSearchRoot[],\n): CanvasRenameResult | CanvasLifecycleRefusal {\n\tconst invalid = validateCanvasName(to);\n\tif (invalid) return { reason: \"invalid-name\", detail: invalid };\n\tif (to === extension.id) return { reason: \"invalid-name\", detail: `it is already called \"${to}\"` };\n\n\tconst root = canvasHomeRoot(extension, roots);\n\tif (!root) {\n\t\treturn {\n\t\t\treason: \"packaged\",\n\t\t\tdetail: `\"${extension.id}\" came from an installed plugin, which names its canvases in its own manifest. Renaming it here would break the plugin; use /plugin instead.`,\n\t\t};\n\t}\n\n\tconst target = path.join(root.dir, to);\n\tif (existsSync(target)) return { reason: \"exists\", detail: `${target} already exists` };\n\n\tconst entry = path.join(extension.dir, CANVAS_ENTRY_FILE);\n\tlet source: string;\n\ttry {\n\t\tsource = readFileSync(entry, \"utf8\");\n\t} catch (cause) {\n\t\treturn {\n\t\t\treason: \"unwritable\",\n\t\t\tdetail: `could not read ${entry}: ${cause instanceof Error ? cause.message : String(cause)}`,\n\t\t};\n\t}\n\n\tconst rewritten = rewriteEntrySource(source, extension.id, to);\n\ttry {\n\t\t// Write before moving: if the write fails the directory has not moved and\n\t\t// nothing is half-renamed.\n\t\tif (rewritten.source !== source) writeFileSync(entry, rewritten.source, \"utf8\");\n\t\trenameSync(extension.dir, target);\n\t} catch (cause) {\n\t\treturn { reason: \"unwritable\", detail: cause instanceof Error ? cause.message : String(cause) };\n\t}\n\n\treturn {\n\t\tfrom: extension.id,\n\t\tto,\n\t\tdir: target,\n\t\trewrites: rewritten.rewrites,\n\t\tleftovers: remainingMentions(rewritten.source, extension.id),\n\t};\n}\n\n/** What {@link removeCanvasExtension} deleted. */\nexport interface CanvasRemoveResult {\n\tid: string;\n\tdir: string;\n}\n\n/**\n * Delete a canvas extension's directory.\n *\n * As with rename, the caller closes open instances first — a deleted directory\n * whose child is still forked leaves a process serving code that no longer\n * exists on disk, which is the most confusing state of all.\n */\nexport function removeCanvasExtension(\n\textension: DiscoveredCanvasExtension,\n\troots: readonly CanvasSearchRoot[],\n): CanvasRemoveResult | CanvasLifecycleRefusal {\n\tconst root = canvasHomeRoot(extension, roots);\n\tif (!root) {\n\t\treturn {\n\t\t\treason: \"packaged\",\n\t\t\tdetail: `\"${extension.id}\" came from an installed plugin. Deleting its directory would leave the plugin broken rather than uninstalled; use /plugin instead.`,\n\t\t};\n\t}\n\ttry {\n\t\trmSync(extension.dir, { recursive: true, force: true });\n\t} catch (cause) {\n\t\treturn { reason: \"unwritable\", detail: cause instanceof Error ? cause.message : String(cause) };\n\t}\n\treturn { id: extension.id, dir: extension.dir };\n}\n\n/** Narrow a lifecycle return value to its refusal case. */\nexport function isCanvasRefusal(value: object): value is CanvasLifecycleRefusal {\n\treturn \"reason\" in value;\n}\n"]}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Renaming and removing a canvas extension.
3
+ *
4
+ * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas
5
+ * has more identity than a file does, and it is spread across four places: the
6
+ * directory name (which *is* the extension id, since discovery keys off
7
+ * position), the `id` a canvas declares, its `displayName`, and whatever the
8
+ * header comment tells the reader to type. Renaming by hand means getting all
9
+ * four right, and getting the `id` wrong is not a typo — it drops the canvas the
10
+ * person is looking at on the next reload, because the instance was opened
11
+ * against a canvas that no longer exists.
12
+ *
13
+ * So the rewriting is deliberately narrow. It changes the three places the
14
+ * scaffold puts the name and nothing else, then **reports every other line the
15
+ * old name still appears on** rather than guessing at prose. A rename that
16
+ * silently edited a description would be worse than one that admits what it left
17
+ * behind.
18
+ */
19
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
20
+ import * as path from "node:path";
21
+ import { CANVAS_ENTRY_FILE } from "./discovery.js";
22
+ import { validateCanvasName } from "./scaffold.js";
23
+ /**
24
+ * Whether this extension is one we may move or delete.
25
+ *
26
+ * The test is positional and deliberately so: an extension is ours to edit when
27
+ * its directory sits *directly* inside one of the search roots, which is exactly
28
+ * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a
29
+ * package — resolved through a manifest by `plugin-canvases.ts` — and its
30
+ * location is that package's business.
31
+ */
32
+ export function canvasHomeRoot(extension, roots) {
33
+ const parent = path.dirname(path.resolve(extension.dir));
34
+ return roots.find((root) => path.resolve(root.dir) === parent);
35
+ }
36
+ /**
37
+ * Swap the old name for the new one where it is *identity* rather than prose.
38
+ *
39
+ * Two rules, and the first is the interesting one: **a string literal whose
40
+ * entire content is the old name**. That covers `id`, `displayName`, `title` and
41
+ * the scaffold's `const ID = "…"` without knowing any of their names, and it
42
+ * does not touch a sentence that merely mentions the canvas — `"the board is the
43
+ * point of the board"` is not the string `"board"`. A whole-word replacement
44
+ * would have rewritten that sentence, which is worse than leaving it.
45
+ *
46
+ * The second rule keeps the instructions in a comment honest: `/canvas open
47
+ * <old>` would otherwise tell the next reader to type a name that no longer
48
+ * resolves.
49
+ *
50
+ * Anything else is left and reported. The scaffold names itself in exactly three
51
+ * places, all covered here, so a canvas that has not been renamed by hand comes
52
+ * through with nothing left over.
53
+ */
54
+ function rewriteEntrySource(source, from, to) {
55
+ const name = escapeForRegExp(from);
56
+ const substitutions = [
57
+ // A quoted string that is *only* the old name. Backticks included, but only
58
+ // without interpolation — `${x}-${from}` is a computed value, not a literal.
59
+ [new RegExp(`(["'])${name}\\1`, "g"), `$1${to}$1`],
60
+ [new RegExp("`" + name + "`", "g"), `\`${to}\``],
61
+ // Instructions in a comment: /canvas open <old>, /canvas reload <old>, …
62
+ [new RegExp(`(/canvas\\s+(?:open|reload|rename|remove|close)\\s+)${name}\\b`, "g"), `$1${to}`],
63
+ ];
64
+ const before = source.split("\n");
65
+ const after = before.map((line) => substitutions.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), line));
66
+ const rewrites = [];
67
+ after.forEach((line, index) => {
68
+ if (line !== before[index])
69
+ rewrites.push({ line: index + 1, before: before[index], after: line });
70
+ });
71
+ return { source: after.join("\n"), rewrites };
72
+ }
73
+ /** Lines still mentioning `name` after the rewrite. */
74
+ function remainingMentions(source, name) {
75
+ const lines = [];
76
+ source.split("\n").forEach((line, index) => {
77
+ if (line.includes(name))
78
+ lines.push(index + 1);
79
+ });
80
+ return lines;
81
+ }
82
+ function escapeForRegExp(value) {
83
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+ }
85
+ /**
86
+ * Rename a canvas extension: its directory, and the name inside its code.
87
+ *
88
+ * The caller is responsible for closing open instances first. This does not do
89
+ * it, because closing is the *session's* concern and this module has no session —
90
+ * but skipping it leaves an instance pointing at a directory that has moved.
91
+ */
92
+ export function renameCanvasExtension(extension, to, roots) {
93
+ const invalid = validateCanvasName(to);
94
+ if (invalid)
95
+ return { reason: "invalid-name", detail: invalid };
96
+ if (to === extension.id)
97
+ return { reason: "invalid-name", detail: `it is already called "${to}"` };
98
+ const root = canvasHomeRoot(extension, roots);
99
+ if (!root) {
100
+ return {
101
+ reason: "packaged",
102
+ detail: `"${extension.id}" came from an installed plugin, which names its canvases in its own manifest. Renaming it here would break the plugin; use /plugin instead.`,
103
+ };
104
+ }
105
+ const target = path.join(root.dir, to);
106
+ if (existsSync(target))
107
+ return { reason: "exists", detail: `${target} already exists` };
108
+ const entry = path.join(extension.dir, CANVAS_ENTRY_FILE);
109
+ let source;
110
+ try {
111
+ source = readFileSync(entry, "utf8");
112
+ }
113
+ catch (cause) {
114
+ return {
115
+ reason: "unwritable",
116
+ detail: `could not read ${entry}: ${cause instanceof Error ? cause.message : String(cause)}`,
117
+ };
118
+ }
119
+ const rewritten = rewriteEntrySource(source, extension.id, to);
120
+ try {
121
+ // Write before moving: if the write fails the directory has not moved and
122
+ // nothing is half-renamed.
123
+ if (rewritten.source !== source)
124
+ writeFileSync(entry, rewritten.source, "utf8");
125
+ renameSync(extension.dir, target);
126
+ }
127
+ catch (cause) {
128
+ return { reason: "unwritable", detail: cause instanceof Error ? cause.message : String(cause) };
129
+ }
130
+ return {
131
+ from: extension.id,
132
+ to,
133
+ dir: target,
134
+ rewrites: rewritten.rewrites,
135
+ leftovers: remainingMentions(rewritten.source, extension.id),
136
+ };
137
+ }
138
+ /**
139
+ * Delete a canvas extension's directory.
140
+ *
141
+ * As with rename, the caller closes open instances first — a deleted directory
142
+ * whose child is still forked leaves a process serving code that no longer
143
+ * exists on disk, which is the most confusing state of all.
144
+ */
145
+ export function removeCanvasExtension(extension, roots) {
146
+ const root = canvasHomeRoot(extension, roots);
147
+ if (!root) {
148
+ return {
149
+ reason: "packaged",
150
+ detail: `"${extension.id}" came from an installed plugin. Deleting its directory would leave the plugin broken rather than uninstalled; use /plugin instead.`,
151
+ };
152
+ }
153
+ try {
154
+ rmSync(extension.dir, { recursive: true, force: true });
155
+ }
156
+ catch (cause) {
157
+ return { reason: "unwritable", detail: cause instanceof Error ? cause.message : String(cause) };
158
+ }
159
+ return { id: extension.id, dir: extension.dir };
160
+ }
161
+ /** Narrow a lifecycle return value to its refusal case. */
162
+ export function isCanvasRefusal(value) {
163
+ return "reason" in value;
164
+ }
165
+ //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../../../src/core/canvas/lifecycle.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Renaming and removing a canvas extension.\n *\n * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas\n * has more identity than a file does, and it is spread across four places: the\n * directory name (which *is* the extension id, since discovery keys off\n * position), the `id` a canvas declares, its `displayName`, and whatever the\n * header comment tells the reader to type. Renaming by hand means getting all\n * four right, and getting the `id` wrong is not a typo — it drops the canvas the\n * person is looking at on the next reload, because the instance was opened\n * against a canvas that no longer exists.\n *\n * So the rewriting is deliberately narrow. It changes the three places the\n * scaffold puts the name and nothing else, then **reports every other line the\n * old name still appears on** rather than guessing at prose. A rename that\n * silently edited a description would be worse than one that admits what it left\n * behind.\n */\n\nimport { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { CanvasSearchRoot, DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { CANVAS_ENTRY_FILE } from \"./discovery.js\";\nimport { validateCanvasName } from \"./scaffold.js\";\n\n/** One line the rename changed. */\nexport interface CanvasRewrite {\n\t/** 1-based, so it can be read straight off an editor gutter. */\n\tline: number;\n\tbefore: string;\n\tafter: string;\n}\n\n/** What {@link renameCanvasExtension} did. */\nexport interface CanvasRenameResult {\n\tfrom: string;\n\tto: string;\n\t/** Workspace-relative where possible, for a message a person can act on. */\n\tdir: string;\n\trewrites: CanvasRewrite[];\n\t/**\n\t * Lines where the old name survives, because they are prose rather than\n\t * identity. Surfaced, never silently edited.\n\t */\n\tleftovers: number[];\n}\n\n/**\n * Why a canvas cannot be renamed or removed in place.\n *\n * `packaged` is the interesting one: a plugin's canvases are named by its\n * manifest rather than by where they sit, so moving the directory would either\n * do nothing or break the plugin. That is `/plugin`'s job, not this one's.\n */\nexport type CanvasLifecycleRefusal =\n\t| { reason: \"invalid-name\"; detail: string }\n\t| { reason: \"exists\"; detail: string }\n\t| { reason: \"packaged\"; detail: string }\n\t| { reason: \"unwritable\"; detail: string };\n\n/**\n * Whether this extension is one we may move or delete.\n *\n * The test is positional and deliberately so: an extension is ours to edit when\n * its directory sits *directly* inside one of the search roots, which is exactly\n * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a\n * package — resolved through a manifest by `plugin-canvases.ts` — and its\n * location is that package's business.\n */\nexport function canvasHomeRoot(\n\textension: DiscoveredCanvasExtension,\n\troots: readonly CanvasSearchRoot[],\n): CanvasSearchRoot | undefined {\n\tconst parent = path.dirname(path.resolve(extension.dir));\n\treturn roots.find((root) => path.resolve(root.dir) === parent);\n}\n\n/**\n * Swap the old name for the new one where it is *identity* rather than prose.\n *\n * Two rules, and the first is the interesting one: **a string literal whose\n * entire content is the old name**. That covers `id`, `displayName`, `title` and\n * the scaffold's `const ID = \"…\"` without knowing any of their names, and it\n * does not touch a sentence that merely mentions the canvas — `\"the board is the\n * point of the board\"` is not the string `\"board\"`. A whole-word replacement\n * would have rewritten that sentence, which is worse than leaving it.\n *\n * The second rule keeps the instructions in a comment honest: `/canvas open\n * <old>` would otherwise tell the next reader to type a name that no longer\n * resolves.\n *\n * Anything else is left and reported. The scaffold names itself in exactly three\n * places, all covered here, so a canvas that has not been renamed by hand comes\n * through with nothing left over.\n */\nfunction rewriteEntrySource(source: string, from: string, to: string): { source: string; rewrites: CanvasRewrite[] } {\n\tconst name = escapeForRegExp(from);\n\tconst substitutions: [RegExp, string][] = [\n\t\t// A quoted string that is *only* the old name. Backticks included, but only\n\t\t// without interpolation — `${x}-${from}` is a computed value, not a literal.\n\t\t[new RegExp(`([\"'])${name}\\\\1`, \"g\"), `$1${to}$1`],\n\t\t[new RegExp(\"`\" + name + \"`\", \"g\"), `\\`${to}\\``],\n\t\t// Instructions in a comment: /canvas open <old>, /canvas reload <old>, …\n\t\t[new RegExp(`(/canvas\\\\s+(?:open|reload|rename|remove|close)\\\\s+)${name}\\\\b`, \"g\"), `$1${to}`],\n\t];\n\n\tconst before = source.split(\"\\n\");\n\tconst after = before.map((line) =>\n\t\tsubstitutions.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), line),\n\t);\n\n\tconst rewrites: CanvasRewrite[] = [];\n\tafter.forEach((line, index) => {\n\t\tif (line !== before[index]) rewrites.push({ line: index + 1, before: before[index] as string, after: line });\n\t});\n\treturn { source: after.join(\"\\n\"), rewrites };\n}\n\n/** Lines still mentioning `name` after the rewrite. */\nfunction remainingMentions(source: string, name: string): number[] {\n\tconst lines: number[] = [];\n\tsource.split(\"\\n\").forEach((line, index) => {\n\t\tif (line.includes(name)) lines.push(index + 1);\n\t});\n\treturn lines;\n}\n\nfunction escapeForRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Rename a canvas extension: its directory, and the name inside its code.\n *\n * The caller is responsible for closing open instances first. This does not do\n * it, because closing is the *session's* concern and this module has no session —\n * but skipping it leaves an instance pointing at a directory that has moved.\n */\nexport function renameCanvasExtension(\n\textension: DiscoveredCanvasExtension,\n\tto: string,\n\troots: readonly CanvasSearchRoot[],\n): CanvasRenameResult | CanvasLifecycleRefusal {\n\tconst invalid = validateCanvasName(to);\n\tif (invalid) return { reason: \"invalid-name\", detail: invalid };\n\tif (to === extension.id) return { reason: \"invalid-name\", detail: `it is already called \"${to}\"` };\n\n\tconst root = canvasHomeRoot(extension, roots);\n\tif (!root) {\n\t\treturn {\n\t\t\treason: \"packaged\",\n\t\t\tdetail: `\"${extension.id}\" came from an installed plugin, which names its canvases in its own manifest. Renaming it here would break the plugin; use /plugin instead.`,\n\t\t};\n\t}\n\n\tconst target = path.join(root.dir, to);\n\tif (existsSync(target)) return { reason: \"exists\", detail: `${target} already exists` };\n\n\tconst entry = path.join(extension.dir, CANVAS_ENTRY_FILE);\n\tlet source: string;\n\ttry {\n\t\tsource = readFileSync(entry, \"utf8\");\n\t} catch (cause) {\n\t\treturn {\n\t\t\treason: \"unwritable\",\n\t\t\tdetail: `could not read ${entry}: ${cause instanceof Error ? cause.message : String(cause)}`,\n\t\t};\n\t}\n\n\tconst rewritten = rewriteEntrySource(source, extension.id, to);\n\ttry {\n\t\t// Write before moving: if the write fails the directory has not moved and\n\t\t// nothing is half-renamed.\n\t\tif (rewritten.source !== source) writeFileSync(entry, rewritten.source, \"utf8\");\n\t\trenameSync(extension.dir, target);\n\t} catch (cause) {\n\t\treturn { reason: \"unwritable\", detail: cause instanceof Error ? cause.message : String(cause) };\n\t}\n\n\treturn {\n\t\tfrom: extension.id,\n\t\tto,\n\t\tdir: target,\n\t\trewrites: rewritten.rewrites,\n\t\tleftovers: remainingMentions(rewritten.source, extension.id),\n\t};\n}\n\n/** What {@link removeCanvasExtension} deleted. */\nexport interface CanvasRemoveResult {\n\tid: string;\n\tdir: string;\n}\n\n/**\n * Delete a canvas extension's directory.\n *\n * As with rename, the caller closes open instances first — a deleted directory\n * whose child is still forked leaves a process serving code that no longer\n * exists on disk, which is the most confusing state of all.\n */\nexport function removeCanvasExtension(\n\textension: DiscoveredCanvasExtension,\n\troots: readonly CanvasSearchRoot[],\n): CanvasRemoveResult | CanvasLifecycleRefusal {\n\tconst root = canvasHomeRoot(extension, roots);\n\tif (!root) {\n\t\treturn {\n\t\t\treason: \"packaged\",\n\t\t\tdetail: `\"${extension.id}\" came from an installed plugin. Deleting its directory would leave the plugin broken rather than uninstalled; use /plugin instead.`,\n\t\t};\n\t}\n\ttry {\n\t\trmSync(extension.dir, { recursive: true, force: true });\n\t} catch (cause) {\n\t\treturn { reason: \"unwritable\", detail: cause instanceof Error ? cause.message : String(cause) };\n\t}\n\treturn { id: extension.id, dir: extension.dir };\n}\n\n/** Narrow a lifecycle return value to its refusal case. */\nexport function isCanvasRefusal(value: object): value is CanvasLifecycleRefusal {\n\treturn \"reason\" in value;\n}\n"]}
@@ -48,6 +48,13 @@ export interface CanvasInstance extends CanvasInstanceKey {
48
48
  status: string | undefined;
49
49
  /** When hoocode last opened this instance or invoked one of its actions. */
50
50
  lastTouchedAt: number;
51
+ /**
52
+ * The `input` this instance was opened with, kept so {@link CanvasRegistry.reload}
53
+ * can re-open it the same way. `canvas.open` is the only place a canvas is told
54
+ * what it is opening *onto*, so replaying it is what makes a reload a reload
55
+ * rather than a fresh, emptier canvas.
56
+ */
57
+ openInput: JsonValue | undefined;
51
58
  }
52
59
  /**
53
60
  * One agent-callable action on an open instance. This is the input the future
@@ -57,6 +64,50 @@ export interface CanvasInstance extends CanvasInstanceKey {
57
64
  export interface CanvasActionBinding extends CanvasInstanceKey {
58
65
  action: CanvasActionDeclaration;
59
66
  }
67
+ /** An instance that did not survive a {@link CanvasRegistry.reload}, and why. */
68
+ export interface CanvasReloadDrop {
69
+ instanceId: string;
70
+ canvasId: string;
71
+ reason: string;
72
+ }
73
+ /**
74
+ * How a reload changed what the agent can call.
75
+ *
76
+ * Reported because editing a canvas's actions is otherwise invisible. A reload
77
+ * that only said which canvases exist leaves the one question an author actually
78
+ * has unanswered — *did the host see the action I just wrote?* — and the answer
79
+ * matters: a typo in `actions: [...]`, a handler that throws at declaration time,
80
+ * or an action defined on the wrong canvas all fail by the action simply not
81
+ * being there.
82
+ *
83
+ * `changed` means same name, different declaration — a reworded description or a
84
+ * reshaped `inputSchema`. That is worth separating from added and removed
85
+ * because it is the case where a stale `list_canvas_capabilities` result in the
86
+ * model's context is now wrong rather than merely incomplete.
87
+ */
88
+ export interface CanvasActionDelta {
89
+ /** `canvasId.actionName`, so a multi-canvas extension stays unambiguous. */
90
+ added: string[];
91
+ removed: string[];
92
+ changed: string[];
93
+ /** Everything the extension declares now, in the same form. */
94
+ current: string[];
95
+ }
96
+ /** What a {@link CanvasRegistry.reload} did. */
97
+ export interface CanvasReloadResult {
98
+ extensionId: string;
99
+ /**
100
+ * Instances that came back, with their **new** urls — the old ones are dead
101
+ * ports. Instance ids are unchanged.
102
+ */
103
+ reopened: CanvasInstance[];
104
+ /** Instances that could not be re-opened. */
105
+ dropped: CanvasReloadDrop[];
106
+ /** Canvas ids the reloaded extension declares, which the edit may have changed. */
107
+ canvases: string[];
108
+ /** What the edit did to the action inventory. */
109
+ actions: CanvasActionDelta;
110
+ }
60
111
  /** Diagnostics the registry emits. The host decides how to surface them. */
61
112
  export interface CanvasRegistryEvents {
62
113
  /** A `session.log` call from an extension. */
@@ -112,6 +163,43 @@ export declare class CanvasRegistry {
112
163
  invokeAction(key: CanvasInstanceKey, actionName: string, input?: JsonValue, options?: CanvasCallOptions): Promise<JsonValue>;
113
164
  /** Close one instance. Unknown keys are a no-op, so close is idempotent. */
114
165
  close(key: CanvasInstanceKey): Promise<void>;
166
+ /**
167
+ * Re-fork a running extension from disk and put its open instances back.
168
+ *
169
+ * This is what makes a canvas *iterable*. A canvas has no passive half — its
170
+ * id, its actions and its UI all come from running its code — so editing
171
+ * `extension.mjs` changes nothing at all while the child that was forked from
172
+ * the old bytes is still serving: not the open page, and not even a freshly
173
+ * opened second instance, because {@link child} hands back the child already in
174
+ * the table. Without a reload the only way to see an edit is to end the session.
175
+ *
176
+ * The order is deliberate. The new child is forked and asked for its
177
+ * declarations **before** the old one is touched, so an edit that does not run —
178
+ * a syntax error, a throw at module scope, a `joinSession` that never resolves —
179
+ * leaves the person looking at exactly the canvas they had, and the error is
180
+ * reported instead of being paid for with their open surface.
181
+ *
182
+ * Instance ids are preserved, so an `instanceId` the model already holds keeps
183
+ * working across a reload. **URLs are not**: the extension binds a fresh
184
+ * ephemeral port and mints a fresh capability token in `open()`, and the host
185
+ * has no way to make it reuse either. So a reload always hands back new URLs,
186
+ * and the caller must show them — an already-open browser tab is pointing at a
187
+ * port that is now closed.
188
+ *
189
+ * The `input` each instance was opened with is replayed, so a reload restores
190
+ * the canvas rather than a blank one. Everything the *extension* kept in memory
191
+ * is gone, which is the honest meaning of restarting a process.
192
+ */
193
+ reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult>;
194
+ /**
195
+ * Stop an extension's child now, rather than at the end of its linger period.
196
+ *
197
+ * The reaper's grace period is right for an extension nobody is using and wrong
198
+ * for one whose directory is about to be moved or deleted: a child outliving
199
+ * its own source is the most confusing state a canvas can be in, because it
200
+ * keeps serving code that is no longer anywhere on disk.
201
+ */
202
+ stopChild(extensionId: string): Promise<boolean>;
115
203
  /** Every open instance. */
116
204
  listInstances(): CanvasInstance[];
117
205
  /**
@@ -133,6 +221,7 @@ export declare class CanvasRegistry {
133
221
  private abandon;
134
222
  private instancesOf;
135
223
  private child;
224
+ private spawn;
136
225
  /** Drop a dead child and its instances, so a crash cannot leave stale entries. */
137
226
  private forget;
138
227
  }