@kolisachint/hoocode-agent 0.5.25 → 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.
- package/CHANGELOG.md +79 -0
- package/dist/core/canvas/lifecycle.d.ts +93 -0
- package/dist/core/canvas/lifecycle.d.ts.map +1 -0
- package/dist/core/canvas/lifecycle.js +165 -0
- package/dist/core/canvas/lifecycle.js.map +1 -0
- package/dist/core/canvas/registry.d.ts +89 -0
- package/dist/core/canvas/registry.d.ts.map +1 -1
- package/dist/core/canvas/registry.js +205 -10
- package/dist/core/canvas/registry.js.map +1 -1
- package/dist/core/canvas/scaffold.d.ts +123 -0
- package/dist/core/canvas/scaffold.d.ts.map +1 -0
- package/dist/core/canvas/scaffold.js +376 -0
- package/dist/core/canvas/scaffold.js.map +1 -0
- package/dist/core/canvas/session.d.ts +39 -1
- package/dist/core/canvas/session.d.ts.map +1 -1
- package/dist/core/canvas/session.js +83 -1
- package/dist/core/canvas/session.js.map +1 -1
- package/dist/core/tools/canvas.d.ts +23 -3
- package/dist/core/tools/canvas.d.ts.map +1 -1
- package/dist/core/tools/canvas.js +99 -4
- package/dist/core/tools/canvas.js.map +1 -1
- package/dist/extensions/core/canvas.d.ts +20 -2
- package/dist/extensions/core/canvas.d.ts.map +1 -1
- package/dist/extensions/core/canvas.js +279 -36
- package/dist/extensions/core/canvas.js.map +1 -1
- package/dist/extensions/core/scaffold.d.ts +7 -1
- package/dist/extensions/core/scaffold.d.ts.map +1 -1
- package/dist/extensions/core/scaffold.js +7 -185
- package/dist/extensions/core/scaffold.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.26] - 2026-08-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `/new-canvas` now takes a description, not just a name, and builds what you
|
|
8
|
+
describe. `/new-canvas a kanban board for the release checklist` scaffolds the
|
|
9
|
+
extension, derives and reports a directory name, opens the canvas, and hands
|
|
10
|
+
the agent a brief to build it — you steer it like any other turn. The old
|
|
11
|
+
shapes still work: `/new-canvas my-board` gives you the template to edit by
|
|
12
|
+
hand and starts no build, and `/new-canvas my-board: <description>` lets you
|
|
13
|
+
name it yourself.
|
|
14
|
+
|
|
15
|
+
- `/canvas reload [extension]` and a `reload_canvas` tool, so a canvas can be
|
|
16
|
+
changed while you are looking at it. Editing an extension's code used to do
|
|
17
|
+
nothing at all while it was open — the running process was forked from the old
|
|
18
|
+
code, so neither the open page nor a newly opened second instance saw the
|
|
19
|
+
change, and the only way to see one was to restart the session. Now the agent
|
|
20
|
+
reloads after each edit and the new capability is immediately callable.
|
|
21
|
+
|
|
22
|
+
The new code is forked and asked for its declarations *before* the old process
|
|
23
|
+
is stopped, so an edit that does not run leaves the canvas you are looking at
|
|
24
|
+
exactly as it was and reports the error. Instances keep their ids and the input
|
|
25
|
+
they were opened with, but each gets a **new url** — the extension binds a new
|
|
26
|
+
port and mints a new token on every open — so the previous browser tab is dead
|
|
27
|
+
and the replacement url is printed.
|
|
28
|
+
|
|
29
|
+
- An arrow-key games canvas, hoocode's first canvas of its own, at
|
|
30
|
+
`.agents/extensions/arrow-key-games/`. Open it with
|
|
31
|
+
`/canvas open arrow-key-games`. Snake and a randomly carved Maze are
|
|
32
|
+
yours alone; **Duel** is a turn-based coin race you play against the model —
|
|
33
|
+
you move with the arrow keys, it moves through `invoke_canvas_action`, and the
|
|
34
|
+
board updates live for both of you. It was built by running
|
|
35
|
+
`/new-canvas create lightweight games that can be played with keyboard arrow keys`
|
|
36
|
+
and iterating with `reload_canvas`, which is what the two entries above are for.
|
|
37
|
+
|
|
38
|
+
- `/canvas rename <extension> <new-name>` and `/canvas remove <extension>`. A
|
|
39
|
+
canvas's name lives in four places — the directory (which *is* the extension
|
|
40
|
+
id), the canvas's own `id`, its `displayName`, and its header comment — and
|
|
41
|
+
getting the `id` wrong by hand drops the canvas you are looking at on the next
|
|
42
|
+
reload. Rename does all of it at once, closes what was open first, and prints
|
|
43
|
+
every line it rewrote. It only touches a string that is *entirely* the old
|
|
44
|
+
name, so a sentence mentioning the canvas is reported rather than rewritten.
|
|
45
|
+
Remove asks before deleting, refuses when there is no surface to ask on, and
|
|
46
|
+
both refuse a canvas that came from a plugin, pointing at `/plugin` instead.
|
|
47
|
+
|
|
48
|
+
- Reloading now reports which actions the edit **added, removed or changed**, so
|
|
49
|
+
writing a new action tells you whether the host can see it. Previously a typo
|
|
50
|
+
inside `actions: [...]` failed silently — the action just was not there.
|
|
51
|
+
"Nothing changed" is reported too, since silence reads as success.
|
|
52
|
+
|
|
53
|
+
- `/canvas list` names the actions of each open canvas. They were visible only to
|
|
54
|
+
the model, so the person steering the session could not see the surface they
|
|
55
|
+
were being asked about.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- `/new-canvas` moved from the `/new-*` scaffold family to the canvas surface. It
|
|
60
|
+
is no longer a file-writing command: it opens what it creates and drives the
|
|
61
|
+
agent loop.
|
|
62
|
+
|
|
63
|
+
- `/new-canvas` derives a much better name from a description. It was naming the
|
|
64
|
+
*request* rather than the thing — `create lightweight games…` became
|
|
65
|
+
`create-lightweight-games`, `help me compare two benchmark runs` became
|
|
66
|
+
`help-compare-two` — because opening words like "create", "build", "show" and
|
|
67
|
+
"help me" landed in the directory name, and `-ing`/`-ed` words crowded out the
|
|
68
|
+
nouns. On twelve realistic descriptions, seven were wrong; all of them now read
|
|
69
|
+
as names for the thing: `lightweight-games-keyboard`, `dashboard-flaky-tests`,
|
|
70
|
+
`compare-benchmark-runs`.
|
|
71
|
+
|
|
72
|
+
- The scaffolded template names itself once, in `ID` and `NAME` at the top,
|
|
73
|
+
instead of repeating the name in six places. Existing canvases are unaffected —
|
|
74
|
+
rename handles both shapes.
|
|
75
|
+
|
|
76
|
+
- The build brief now tells the model to leave the canvas's `id` alone and rename
|
|
77
|
+
`displayName` instead. Renaming the id drops the instance the person is
|
|
78
|
+
watching on the next reload — correct behaviour, since the canvas the instance
|
|
79
|
+
was opened against no longer exists, but a trap the model walks into because
|
|
80
|
+
the scaffold names the canvas after a directory derived from a sentence.
|
|
81
|
+
|
|
3
82
|
## [0.5.25] - 2026-08-20
|
|
4
83
|
|
|
5
84
|
### 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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Canvas instance registry — owns children, instances, and reaping.\n *\n * Design: `docs/canvas-extensions-design.md` §4, §6, §7. One child process per\n * extension, many instances per child, keyed by `(extensionId, canvasId,\n * instanceId)` because `joinSession({ canvases: [...] })` takes an array and each\n * canvas can be opened more than once.\n *\n * **Correction to the design doc's §6.** That section called for an \"SSE-liveness\n * heartbeat — an instance with no connected client for N seconds is idle\". That is\n * not implementable. The SSE endpoint and its client set live inside the\n * extension's own HTTP server (`entry.sseClients` in `pr-artifact-explorer`'s\n * `server.mjs`); the host never sees them. Learning otherwise would take either\n * proxying the canvas URL — which breaks the token, origin and CSP model the\n * extension built — or adding a liveness call to the contract, which breaks tier-2\n * portability. Neither is worth it for a reaper.\n *\n * So idleness here means something narrower and honest: **time since hoocode last\n * touched the instance** (opened it, or invoked an action on it). A person reading\n * a canvas in a browser tab is invisible to us, so a generous timeout is the point\n * rather than a limitation, and `reapIdle` is advisory cleanup — not a claim about\n * whether anybody is watching.\n *\n * The registry starts no timers. `reapIdle()` is driven by the caller and `now` is\n * injectable, so lifetime policy belongs to whoever owns the session clock and the\n * tests do not sleep.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport * as path from \"node:path\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport type { CanvasActionDeclaration, CanvasDeclaration, CanvasProviderOpenResult, JsonValue } from \"./protocol.js\";\nimport {\n\ttype CanvasCallOptions,\n\ttype CanvasExtensionProcess,\n\ttype CanvasRunnerOptions,\n\ttype CanvasRuntime,\n\tspawnCanvasExtension,\n} from \"./runner.js\";\nimport { CanvasTrustError, shouldWithholdCanvas } from \"./trust.js\";\n\n/** Default idle ceiling before an untouched instance is reaped. */\nexport const CANVAS_INSTANCE_IDLE_MS = 30 * 60 * 1_000;\n\n/** Default grace period a child is kept alive after its last instance closes. */\nexport const CANVAS_CHILD_LINGER_MS = 60 * 1_000;\n\n/** Default cap on concurrent instances of a single canvas. */\nexport const CANVAS_MAX_INSTANCES_PER_CANVAS = 8;\n\n/** Stable identity of one open instance. */\nexport interface CanvasInstanceKey {\n\textensionId: string;\n\tcanvasId: string;\n\tinstanceId: string;\n}\n\n/** An open canvas instance. */\nexport interface CanvasInstance extends CanvasInstanceKey {\n\t/** URL the host hands to a browser. */\n\turl: string | undefined;\n\ttitle: string | undefined;\n\tstatus: string | undefined;\n\t/** When hoocode last opened this instance or invoked one of its actions. */\n\tlastTouchedAt: number;\n}\n\n/**\n * One agent-callable action on an open instance. This is the input the future\n * tool bridge consumes; nothing registers it as a tool yet, deliberately — that\n * makes canvases reachable by the agent and must follow the trust gate (§5).\n */\nexport interface CanvasActionBinding extends CanvasInstanceKey {\n\taction: CanvasActionDeclaration;\n}\n\n/** Diagnostics the registry emits. The host decides how to surface them. */\nexport interface CanvasRegistryEvents {\n\t/** A `session.log` call from an extension. */\n\tonLog?: (extensionId: string, message: string, level: string | undefined) => void;\n\t/** A non-protocol stdout line — almost always a stray `console.log`. */\n\tonStray?: (extensionId: string, line: string) => void;\n\t/** The child's stderr. */\n\tonStderr?: (extensionId: string, chunk: string) => void;\n\t/** Something the host should tell the user about once. */\n\tonDiagnostic?: (extensionId: string, message: string) => void;\n}\n\n/** Registry configuration. */\nexport interface CanvasRegistryOptions extends CanvasRegistryEvents {\n\truntime: CanvasRuntime;\n\t/**\n\t * Working directory the trust gate is evaluated against (§5). Required: forking\n\t * a canvas that arrived in a clone is exactly what the gate exists to prevent,\n\t * so there is no sensible default to fall back to.\n\t */\n\tcwd: string;\n\t/** Trust-store location. Defaults to the agent dir; injectable for tests. */\n\tagentDir?: string;\n\t/** Clock, injectable so idle policy is testable without sleeping. */\n\tnow?: () => number;\n\tidleTimeoutMs?: number;\n\tchildLingerMs?: number;\n\t/**\n\t * Per-method provider-call ceilings, merged over the runner's defaults.\n\t *\n\t * Plumbed through because the registry is the entry point everything real goes\n\t * via: without this the ceilings in `runner.ts` were only reachable by calling\n\t * `spawnCanvasExtension` directly, which nothing does.\n\t */\n\trequestTimeoutMs?: CanvasRunnerOptions[\"requestTimeoutMs\"];\n\tmaxInstancesPerCanvas?: number;\n\t/** Instance id generator, injectable for deterministic tests. */\n\tnewInstanceId?: () => string;\n}\n\ninterface ChildEntry {\n\tprocess: CanvasExtensionProcess;\n\tdeclarations: Map<string, CanvasDeclaration>;\n\t/** When the child's instance count last dropped to zero; undefined while in use. */\n\tidleSince: number | undefined;\n}\n\n/** Render a key as a stable string, for maps and messages. */\nexport function canvasInstanceKeyOf(key: CanvasInstanceKey): string {\n\treturn `${key.extensionId}::${key.canvasId}::${key.instanceId}`;\n}\n\nexport class CanvasRegistry {\n\tprivate readonly children = new Map<string, ChildEntry>();\n\tprivate readonly instances = new Map<string, CanvasInstance>();\n\tprivate readonly options: CanvasRegistryOptions;\n\tprivate readonly now: () => number;\n\tprivate readonly newInstanceId: () => string;\n\n\tconstructor(options: CanvasRegistryOptions) {\n\t\tthis.options = options;\n\t\tthis.now = options.now ?? Date.now;\n\t\tthis.newInstanceId = options.newInstanceId ?? randomUUID;\n\t}\n\n\t/** Canvases an extension declares, forking it if it is not already running. */\n\tasync declarations(extension: DiscoveredCanvasExtension): Promise<CanvasDeclaration[]> {\n\t\tconst child = await this.child(extension);\n\t\treturn [...child.declarations.values()];\n\t}\n\n\t/** Open a canvas instance and return what the host needs to render it. */\n\tasync open(\n\t\textension: DiscoveredCanvasExtension,\n\t\tcanvasId: string,\n\t\tinput?: JsonValue,\n\t\toptions?: CanvasCallOptions,\n\t): Promise<CanvasInstance> {\n\t\tconst child = await this.child(extension);\n\t\tif (!child.declarations.has(canvasId)) {\n\t\t\tconst known = [...child.declarations.keys()].join(\", \") || \"none\";\n\t\t\tthrow new Error(`Extension \"${extension.id}\" declares no canvas \"${canvasId}\" (declares: ${known}).`);\n\t\t}\n\n\t\tconst limit = this.options.maxInstancesPerCanvas ?? CANVAS_MAX_INSTANCES_PER_CANVAS;\n\t\tconst open = this.listInstances().filter(\n\t\t\t(instance) => instance.extensionId === extension.id && instance.canvasId === canvasId,\n\t\t);\n\t\tif (open.length >= limit) {\n\t\t\tthrow new Error(`Canvas \"${canvasId}\" already has ${open.length} open instances (limit ${limit}).`);\n\t\t}\n\n\t\tconst instanceId = this.newInstanceId();\n\t\tlet result: CanvasProviderOpenResult | null;\n\t\ttry {\n\t\t\tresult = (await child.process.open(\n\t\t\t\t{ sessionId: extension.id, extensionId: extension.id, canvasId, instanceId, input },\n\t\t\t\toptions,\n\t\t\t)) as CanvasProviderOpenResult | null;\n\t\t} catch (cause) {\n\t\t\tawait this.abandon(extension.id, canvasId, instanceId);\n\t\t\tthrow cause;\n\t\t}\n\n\t\tconst instance: CanvasInstance = {\n\t\t\textensionId: extension.id,\n\t\t\tcanvasId,\n\t\t\tinstanceId,\n\t\t\turl: result?.url,\n\t\t\ttitle: result?.title,\n\t\t\tstatus: result?.status,\n\t\t\tlastTouchedAt: this.now(),\n\t\t};\n\t\tthis.instances.set(canvasInstanceKeyOf(instance), instance);\n\t\tchild.idleSince = undefined;\n\t\treturn instance;\n\t}\n\n\t/** Invoke an action on an open instance. */\n\tasync invokeAction(\n\t\tkey: CanvasInstanceKey,\n\t\tactionName: string,\n\t\tinput?: JsonValue,\n\t\toptions?: CanvasCallOptions,\n\t): Promise<JsonValue> {\n\t\tconst instance = this.instances.get(canvasInstanceKeyOf(key));\n\t\tif (!instance) throw new Error(`No open canvas instance ${canvasInstanceKeyOf(key)}.`);\n\t\tconst child = this.children.get(key.extensionId);\n\t\tif (!child) throw new Error(`Canvas extension \"${key.extensionId}\" is not running.`);\n\n\t\tconst result = await child.process.invokeAction(\n\t\t\t{\n\t\t\t\tsessionId: key.extensionId,\n\t\t\t\textensionId: key.extensionId,\n\t\t\t\tcanvasId: key.canvasId,\n\t\t\t\tinstanceId: key.instanceId,\n\t\t\t\tactionName,\n\t\t\t\tinput,\n\t\t\t},\n\t\t\toptions,\n\t\t);\n\t\tinstance.lastTouchedAt = this.now();\n\t\treturn result;\n\t}\n\n\t/** Close one instance. Unknown keys are a no-op, so close is idempotent. */\n\tasync close(key: CanvasInstanceKey): Promise<void> {\n\t\tconst id = canvasInstanceKeyOf(key);\n\t\tif (!this.instances.delete(id)) return;\n\t\tconst child = this.children.get(key.extensionId);\n\t\tif (!child) return;\n\t\ttry {\n\t\t\tawait child.process.close({\n\t\t\t\tsessionId: key.extensionId,\n\t\t\t\textensionId: key.extensionId,\n\t\t\t\tcanvasId: key.canvasId,\n\t\t\t\tinstanceId: key.instanceId,\n\t\t\t});\n\t\t} catch (cause) {\n\t\t\t// onClose is fire-and-forget in the SDK contract, so a failure here must not\n\t\t\t// leave the instance half-closed in our table.\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\tkey.extensionId,\n\t\t\t\t`Closing canvas instance ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,\n\t\t\t);\n\t\t}\n\t\tif (this.instancesOf(key.extensionId).length === 0) child.idleSince = this.now();\n\t}\n\n\t/** Every open instance. */\n\tlistInstances(): CanvasInstance[] {\n\t\treturn [...this.instances.values()];\n\t}\n\n\t/**\n\t * Actions currently invocable, one entry per open instance per declared action.\n\t * Empty when nothing is open — which is the point: a canvas that is not open\n\t * costs the prompt nothing (§7).\n\t */\n\tactiveActions(): CanvasActionBinding[] {\n\t\tconst bindings: CanvasActionBinding[] = [];\n\t\tfor (const instance of this.instances.values()) {\n\t\t\tconst declaration = this.children.get(instance.extensionId)?.declarations.get(instance.canvasId);\n\t\t\tfor (const action of declaration?.actions ?? []) {\n\t\t\t\tbindings.push({\n\t\t\t\t\textensionId: instance.extensionId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\taction,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\treturn bindings;\n\t}\n\n\t/**\n\t * Close instances hoocode has not touched within the idle timeout, then reap\n\t * children that have had no instances for the linger period. Advisory cleanup:\n\t * see the module header on what \"idle\" can and cannot mean here.\n\t *\n\t * @returns The instance keys that were closed.\n\t */\n\tasync reapIdle(): Promise<string[]> {\n\t\tconst idleTimeout = this.options.idleTimeoutMs ?? CANVAS_INSTANCE_IDLE_MS;\n\t\tconst linger = this.options.childLingerMs ?? CANVAS_CHILD_LINGER_MS;\n\t\tconst now = this.now();\n\n\t\tconst expired = this.listInstances().filter((instance) => now - instance.lastTouchedAt >= idleTimeout);\n\t\tfor (const instance of expired) await this.close(instance);\n\n\t\tfor (const [extensionId, child] of [...this.children.entries()]) {\n\t\t\tconst unused = this.instancesOf(extensionId).length === 0;\n\t\t\tif (!unused) continue;\n\t\t\tconst since = child.idleSince ?? now;\n\t\t\tchild.idleSince = since;\n\t\t\tif (now - since >= linger) {\n\t\t\t\tthis.children.delete(extensionId);\n\t\t\t\tawait child.process.terminate();\n\t\t\t}\n\t\t}\n\n\t\treturn expired.map((instance) => canvasInstanceKeyOf(instance));\n\t}\n\n\t/** Close everything and terminate every child. Safe to call twice. */\n\tasync shutdown(): Promise<void> {\n\t\tfor (const instance of this.listInstances()) await this.close(instance);\n\t\tconst children = [...this.children.values()];\n\t\tthis.children.clear();\n\t\tawait Promise.all(children.map((child) => child.process.terminate()));\n\t}\n\n\t/**\n\t * Reconcile an instance we asked to open but never saw open — because a person\n\t * cancelled, or the call timed out. One path serves both.\n\t *\n\t * The provider protocol has no cancel verb, so the child may have finished opening\n\t * and be holding a port. `canvas.close` is the only way to tell it to let go, and\n\t * it can be sent because the instance id was generated before the open call.\n\t *\n\t * If the close itself goes unanswered the child is wedged, and the only remaining\n\t * lever is terminating it — but that kills every instance of that extension, so it\n\t * is done only when no other instance is live. When siblings exist the child is left\n\t * alone and the leak is reported, rather than paid for by someone else's open canvas.\n\t */\n\tprivate async abandon(extensionId: string, canvasId: string, instanceId: string): Promise<void> {\n\t\tconst child = this.children.get(extensionId);\n\t\tif (!child) return;\n\t\ttry {\n\t\t\tawait child.process.close({ sessionId: extensionId, extensionId, canvasId, instanceId });\n\t\t\treturn;\n\t\t} catch (cause) {\n\t\t\tconst detail = cause instanceof Error ? cause.message : String(cause);\n\t\t\tif (this.instancesOf(extensionId).length > 0) {\n\t\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\t\textensionId,\n\t\t\t\t\t`Stopped opening canvas \"${canvasId}\" but the extension did not confirm the close (${detail}). ` +\n\t\t\t\t\t\t\"It has other canvases open, so it was left running; a port may stay bound until it exits.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.children.delete(extensionId);\n\t\t\tawait child.process.terminate();\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\textensionId,\n\t\t\t\t`Stopped opening canvas \"${canvasId}\" and the extension did not confirm the close (${detail}); it was stopped.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate instancesOf(extensionId: string): CanvasInstance[] {\n\t\treturn this.listInstances().filter((instance) => instance.extensionId === extensionId);\n\t}\n\n\tprivate async child(extension: DiscoveredCanvasExtension): Promise<ChildEntry> {\n\t\t// The single choke point: every path that could start a process comes through\n\t\t// here, so the gate is enforced once and cannot be bypassed by a caller that\n\t\t// forgot to filter. Callers should still filter with `gateCanvasExtensions`\n\t\t// so they can explain the refusal; this is the backstop, not the UI.\n\t\tif (shouldWithholdCanvas(extension, this.options.cwd, this.options.agentDir)) {\n\t\t\tthrow new CanvasTrustError(extension.id, this.options.cwd);\n\t\t}\n\n\t\tconst existing = this.children.get(extension.id);\n\t\tif (existing?.process.running) return existing;\n\t\tif (existing) this.children.delete(extension.id);\n\n\t\tconst process = spawnCanvasExtension({\n\t\t\textensionId: extension.id,\n\t\t\tentry: extension.entry,\n\t\t\truntime: this.options.runtime,\n\t\t\trequestTimeoutMs: this.options.requestTimeoutMs,\n\t\t\tcwd: path.dirname(extension.dir),\n\t\t\tonLog: (message, level) => this.options.onLog?.(extension.id, message, level),\n\t\t\tonStray: (line) => this.options.onStray?.(extension.id, line),\n\t\t\tonStderr: (chunk) => this.options.onStderr?.(extension.id, chunk),\n\t\t\tonExit: () => this.forget(extension.id),\n\t\t});\n\n\t\tconst ready = await process.ready;\n\t\tif (ready.unsupported && ready.unsupported.length > 0) {\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\textension.id,\n\t\t\t\t`Canvas extension \"${extension.id}\" declares ${ready.unsupported.join(\", \")}, which hoocode does not support; those surfaces are ignored.`,\n\t\t\t);\n\t\t}\n\n\t\tconst entry: ChildEntry = {\n\t\t\tprocess,\n\t\t\tdeclarations: new Map(ready.canvases.map((declaration) => [declaration.id, declaration])),\n\t\t\tidleSince: this.now(),\n\t\t};\n\t\tthis.children.set(extension.id, entry);\n\t\treturn entry;\n\t}\n\n\t/** Drop a dead child and its instances, so a crash cannot leave stale entries. */\n\tprivate forget(extensionId: string): void {\n\t\tthis.children.delete(extensionId);\n\t\tfor (const [id, instance] of [...this.instances.entries()]) {\n\t\t\tif (instance.extensionId === extensionId) this.instances.delete(id);\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Canvas instance registry — owns children, instances, and reaping.\n *\n * Design: `docs/canvas-extensions-design.md` §4, §6, §7. One child process per\n * extension, many instances per child, keyed by `(extensionId, canvasId,\n * instanceId)` because `joinSession({ canvases: [...] })` takes an array and each\n * canvas can be opened more than once.\n *\n * **Correction to the design doc's §6.** That section called for an \"SSE-liveness\n * heartbeat — an instance with no connected client for N seconds is idle\". That is\n * not implementable. The SSE endpoint and its client set live inside the\n * extension's own HTTP server (`entry.sseClients` in `pr-artifact-explorer`'s\n * `server.mjs`); the host never sees them. Learning otherwise would take either\n * proxying the canvas URL — which breaks the token, origin and CSP model the\n * extension built — or adding a liveness call to the contract, which breaks tier-2\n * portability. Neither is worth it for a reaper.\n *\n * So idleness here means something narrower and honest: **time since hoocode last\n * touched the instance** (opened it, or invoked an action on it). A person reading\n * a canvas in a browser tab is invisible to us, so a generous timeout is the point\n * rather than a limitation, and `reapIdle` is advisory cleanup — not a claim about\n * whether anybody is watching.\n *\n * The registry starts no timers. `reapIdle()` is driven by the caller and `now` is\n * injectable, so lifetime policy belongs to whoever owns the session clock and the\n * tests do not sleep.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport * as path from \"node:path\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport type {\n\tCanvasActionDeclaration,\n\tCanvasDeclaration,\n\tCanvasProviderOpenResult,\n\tCanvasReadyMessage,\n\tJsonValue,\n} from \"./protocol.js\";\nimport {\n\ttype CanvasCallOptions,\n\ttype CanvasExtensionProcess,\n\ttype CanvasRunnerOptions,\n\ttype CanvasRuntime,\n\tspawnCanvasExtension,\n} from \"./runner.js\";\nimport { CanvasTrustError, shouldWithholdCanvas } from \"./trust.js\";\n\n/** Default idle ceiling before an untouched instance is reaped. */\nexport const CANVAS_INSTANCE_IDLE_MS = 30 * 60 * 1_000;\n\n/** Default grace period a child is kept alive after its last instance closes. */\nexport const CANVAS_CHILD_LINGER_MS = 60 * 1_000;\n\n/** Default cap on concurrent instances of a single canvas. */\nexport const CANVAS_MAX_INSTANCES_PER_CANVAS = 8;\n\n/** Stable identity of one open instance. */\nexport interface CanvasInstanceKey {\n\textensionId: string;\n\tcanvasId: string;\n\tinstanceId: string;\n}\n\n/** An open canvas instance. */\nexport interface CanvasInstance extends CanvasInstanceKey {\n\t/** URL the host hands to a browser. */\n\turl: string | undefined;\n\ttitle: string | undefined;\n\tstatus: string | undefined;\n\t/** When hoocode last opened this instance or invoked one of its actions. */\n\tlastTouchedAt: number;\n\t/**\n\t * The `input` this instance was opened with, kept so {@link CanvasRegistry.reload}\n\t * can re-open it the same way. `canvas.open` is the only place a canvas is told\n\t * what it is opening *onto*, so replaying it is what makes a reload a reload\n\t * rather than a fresh, emptier canvas.\n\t */\n\topenInput: JsonValue | undefined;\n}\n\n/**\n * One agent-callable action on an open instance. This is the input the future\n * tool bridge consumes; nothing registers it as a tool yet, deliberately — that\n * makes canvases reachable by the agent and must follow the trust gate (§5).\n */\nexport interface CanvasActionBinding extends CanvasInstanceKey {\n\taction: CanvasActionDeclaration;\n}\n\n/** An instance that did not survive a {@link CanvasRegistry.reload}, and why. */\nexport interface CanvasReloadDrop {\n\tinstanceId: string;\n\tcanvasId: string;\n\treason: string;\n}\n\n/**\n * How a reload changed what the agent can call.\n *\n * Reported because editing a canvas's actions is otherwise invisible. A reload\n * that only said which canvases exist leaves the one question an author actually\n * has unanswered — *did the host see the action I just wrote?* — and the answer\n * matters: a typo in `actions: [...]`, a handler that throws at declaration time,\n * or an action defined on the wrong canvas all fail by the action simply not\n * being there.\n *\n * `changed` means same name, different declaration — a reworded description or a\n * reshaped `inputSchema`. That is worth separating from added and removed\n * because it is the case where a stale `list_canvas_capabilities` result in the\n * model's context is now wrong rather than merely incomplete.\n */\nexport interface CanvasActionDelta {\n\t/** `canvasId.actionName`, so a multi-canvas extension stays unambiguous. */\n\tadded: string[];\n\tremoved: string[];\n\tchanged: string[];\n\t/** Everything the extension declares now, in the same form. */\n\tcurrent: string[];\n}\n\n/** What a {@link CanvasRegistry.reload} did. */\nexport interface CanvasReloadResult {\n\textensionId: string;\n\t/**\n\t * Instances that came back, with their **new** urls — the old ones are dead\n\t * ports. Instance ids are unchanged.\n\t */\n\treopened: CanvasInstance[];\n\t/** Instances that could not be re-opened. */\n\tdropped: CanvasReloadDrop[];\n\t/** Canvas ids the reloaded extension declares, which the edit may have changed. */\n\tcanvases: string[];\n\t/** What the edit did to the action inventory. */\n\tactions: CanvasActionDelta;\n}\n\n/**\n * Index an extension's actions by `canvasId.actionName`, against a stable\n * serialization of the declaration.\n *\n * `JSON.stringify` of the whole declaration is the comparison, which makes it\n * sensitive to key order — but both sides come from the same code path in the\n * same shim, so a reordering here means the author reordered the source, and\n * reporting that as \"changed\" is closer to true than missing a reshaped schema.\n */\nfunction indexActions(declarations: Map<string, CanvasDeclaration>): Map<string, string> {\n\tconst index = new Map<string, string>();\n\tfor (const [canvasId, declaration] of declarations) {\n\t\tfor (const action of declaration.actions ?? []) {\n\t\t\tindex.set(`${canvasId}.${action.name}`, JSON.stringify(action));\n\t\t}\n\t}\n\treturn index;\n}\n\n/** Compare two action inventories. */\nfunction diffActions(before: Map<string, string>, after: Map<string, string>): CanvasActionDelta {\n\tconst added: string[] = [];\n\tconst removed: string[] = [];\n\tconst changed: string[] = [];\n\tfor (const [key, shape] of after) {\n\t\tif (!before.has(key)) added.push(key);\n\t\telse if (before.get(key) !== shape) changed.push(key);\n\t}\n\tfor (const key of before.keys()) if (!after.has(key)) removed.push(key);\n\treturn { added: added.sort(), removed: removed.sort(), changed: changed.sort(), current: [...after.keys()].sort() };\n}\n\n/** Diagnostics the registry emits. The host decides how to surface them. */\nexport interface CanvasRegistryEvents {\n\t/** A `session.log` call from an extension. */\n\tonLog?: (extensionId: string, message: string, level: string | undefined) => void;\n\t/** A non-protocol stdout line — almost always a stray `console.log`. */\n\tonStray?: (extensionId: string, line: string) => void;\n\t/** The child's stderr. */\n\tonStderr?: (extensionId: string, chunk: string) => void;\n\t/** Something the host should tell the user about once. */\n\tonDiagnostic?: (extensionId: string, message: string) => void;\n}\n\n/** Registry configuration. */\nexport interface CanvasRegistryOptions extends CanvasRegistryEvents {\n\truntime: CanvasRuntime;\n\t/**\n\t * Working directory the trust gate is evaluated against (§5). Required: forking\n\t * a canvas that arrived in a clone is exactly what the gate exists to prevent,\n\t * so there is no sensible default to fall back to.\n\t */\n\tcwd: string;\n\t/** Trust-store location. Defaults to the agent dir; injectable for tests. */\n\tagentDir?: string;\n\t/** Clock, injectable so idle policy is testable without sleeping. */\n\tnow?: () => number;\n\tidleTimeoutMs?: number;\n\tchildLingerMs?: number;\n\t/**\n\t * Per-method provider-call ceilings, merged over the runner's defaults.\n\t *\n\t * Plumbed through because the registry is the entry point everything real goes\n\t * via: without this the ceilings in `runner.ts` were only reachable by calling\n\t * `spawnCanvasExtension` directly, which nothing does.\n\t */\n\trequestTimeoutMs?: CanvasRunnerOptions[\"requestTimeoutMs\"];\n\tmaxInstancesPerCanvas?: number;\n\t/** Instance id generator, injectable for deterministic tests. */\n\tnewInstanceId?: () => string;\n}\n\ninterface ChildEntry {\n\tprocess: CanvasExtensionProcess;\n\tdeclarations: Map<string, CanvasDeclaration>;\n\t/** When the child's instance count last dropped to zero; undefined while in use. */\n\tidleSince: number | undefined;\n\t/**\n\t * The descriptor this child was forked from.\n\t *\n\t * Kept because {@link CanvasRegistry.reload} is reached by extension id — from a\n\t * tool call, or from `/canvas reload` — and re-forking needs the entry file and\n\t * the scope the trust gate reads. Re-discovering it here would duplicate the\n\t * search roots and could silently resolve a *different* extension than the one\n\t * that is running.\n\t */\n\textension: DiscoveredCanvasExtension;\n}\n\n/** Render a key as a stable string, for maps and messages. */\nexport function canvasInstanceKeyOf(key: CanvasInstanceKey): string {\n\treturn `${key.extensionId}::${key.canvasId}::${key.instanceId}`;\n}\n\nexport class CanvasRegistry {\n\tprivate readonly children = new Map<string, ChildEntry>();\n\tprivate readonly instances = new Map<string, CanvasInstance>();\n\tprivate readonly options: CanvasRegistryOptions;\n\tprivate readonly now: () => number;\n\tprivate readonly newInstanceId: () => string;\n\n\tconstructor(options: CanvasRegistryOptions) {\n\t\tthis.options = options;\n\t\tthis.now = options.now ?? Date.now;\n\t\tthis.newInstanceId = options.newInstanceId ?? randomUUID;\n\t}\n\n\t/** Canvases an extension declares, forking it if it is not already running. */\n\tasync declarations(extension: DiscoveredCanvasExtension): Promise<CanvasDeclaration[]> {\n\t\tconst child = await this.child(extension);\n\t\treturn [...child.declarations.values()];\n\t}\n\n\t/** Open a canvas instance and return what the host needs to render it. */\n\tasync open(\n\t\textension: DiscoveredCanvasExtension,\n\t\tcanvasId: string,\n\t\tinput?: JsonValue,\n\t\toptions?: CanvasCallOptions,\n\t): Promise<CanvasInstance> {\n\t\tconst child = await this.child(extension);\n\t\tif (!child.declarations.has(canvasId)) {\n\t\t\tconst known = [...child.declarations.keys()].join(\", \") || \"none\";\n\t\t\tthrow new Error(`Extension \"${extension.id}\" declares no canvas \"${canvasId}\" (declares: ${known}).`);\n\t\t}\n\n\t\tconst limit = this.options.maxInstancesPerCanvas ?? CANVAS_MAX_INSTANCES_PER_CANVAS;\n\t\tconst open = this.listInstances().filter(\n\t\t\t(instance) => instance.extensionId === extension.id && instance.canvasId === canvasId,\n\t\t);\n\t\tif (open.length >= limit) {\n\t\t\tthrow new Error(`Canvas \"${canvasId}\" already has ${open.length} open instances (limit ${limit}).`);\n\t\t}\n\n\t\tconst instanceId = this.newInstanceId();\n\t\tlet result: CanvasProviderOpenResult | null;\n\t\ttry {\n\t\t\tresult = (await child.process.open(\n\t\t\t\t{ sessionId: extension.id, extensionId: extension.id, canvasId, instanceId, input },\n\t\t\t\toptions,\n\t\t\t)) as CanvasProviderOpenResult | null;\n\t\t} catch (cause) {\n\t\t\tawait this.abandon(extension.id, canvasId, instanceId);\n\t\t\tthrow cause;\n\t\t}\n\n\t\tconst instance: CanvasInstance = {\n\t\t\textensionId: extension.id,\n\t\t\tcanvasId,\n\t\t\tinstanceId,\n\t\t\turl: result?.url,\n\t\t\ttitle: result?.title,\n\t\t\tstatus: result?.status,\n\t\t\tlastTouchedAt: this.now(),\n\t\t\topenInput: input,\n\t\t};\n\t\tthis.instances.set(canvasInstanceKeyOf(instance), instance);\n\t\tchild.idleSince = undefined;\n\t\treturn instance;\n\t}\n\n\t/** Invoke an action on an open instance. */\n\tasync invokeAction(\n\t\tkey: CanvasInstanceKey,\n\t\tactionName: string,\n\t\tinput?: JsonValue,\n\t\toptions?: CanvasCallOptions,\n\t): Promise<JsonValue> {\n\t\tconst instance = this.instances.get(canvasInstanceKeyOf(key));\n\t\tif (!instance) throw new Error(`No open canvas instance ${canvasInstanceKeyOf(key)}.`);\n\t\tconst child = this.children.get(key.extensionId);\n\t\tif (!child) throw new Error(`Canvas extension \"${key.extensionId}\" is not running.`);\n\n\t\tconst result = await child.process.invokeAction(\n\t\t\t{\n\t\t\t\tsessionId: key.extensionId,\n\t\t\t\textensionId: key.extensionId,\n\t\t\t\tcanvasId: key.canvasId,\n\t\t\t\tinstanceId: key.instanceId,\n\t\t\t\tactionName,\n\t\t\t\tinput,\n\t\t\t},\n\t\t\toptions,\n\t\t);\n\t\tinstance.lastTouchedAt = this.now();\n\t\treturn result;\n\t}\n\n\t/** Close one instance. Unknown keys are a no-op, so close is idempotent. */\n\tasync close(key: CanvasInstanceKey): Promise<void> {\n\t\tconst id = canvasInstanceKeyOf(key);\n\t\tif (!this.instances.delete(id)) return;\n\t\tconst child = this.children.get(key.extensionId);\n\t\tif (!child) return;\n\t\ttry {\n\t\t\tawait child.process.close({\n\t\t\t\tsessionId: key.extensionId,\n\t\t\t\textensionId: key.extensionId,\n\t\t\t\tcanvasId: key.canvasId,\n\t\t\t\tinstanceId: key.instanceId,\n\t\t\t});\n\t\t} catch (cause) {\n\t\t\t// onClose is fire-and-forget in the SDK contract, so a failure here must not\n\t\t\t// leave the instance half-closed in our table.\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\tkey.extensionId,\n\t\t\t\t`Closing canvas instance ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,\n\t\t\t);\n\t\t}\n\t\tif (this.instancesOf(key.extensionId).length === 0) child.idleSince = this.now();\n\t}\n\n\t/**\n\t * Re-fork a running extension from disk and put its open instances back.\n\t *\n\t * This is what makes a canvas *iterable*. A canvas has no passive half — its\n\t * id, its actions and its UI all come from running its code — so editing\n\t * `extension.mjs` changes nothing at all while the child that was forked from\n\t * the old bytes is still serving: not the open page, and not even a freshly\n\t * opened second instance, because {@link child} hands back the child already in\n\t * the table. Without a reload the only way to see an edit is to end the session.\n\t *\n\t * The order is deliberate. The new child is forked and asked for its\n\t * declarations **before** the old one is touched, so an edit that does not run —\n\t * a syntax error, a throw at module scope, a `joinSession` that never resolves —\n\t * leaves the person looking at exactly the canvas they had, and the error is\n\t * reported instead of being paid for with their open surface.\n\t *\n\t * Instance ids are preserved, so an `instanceId` the model already holds keeps\n\t * working across a reload. **URLs are not**: the extension binds a fresh\n\t * ephemeral port and mints a fresh capability token in `open()`, and the host\n\t * has no way to make it reuse either. So a reload always hands back new URLs,\n\t * and the caller must show them — an already-open browser tab is pointing at a\n\t * port that is now closed.\n\t *\n\t * The `input` each instance was opened with is replayed, so a reload restores\n\t * the canvas rather than a blank one. Everything the *extension* kept in memory\n\t * is gone, which is the honest meaning of restarting a process.\n\t */\n\tasync reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult> {\n\t\tconst previous = this.children.get(extensionId);\n\t\tif (!previous) {\n\t\t\tthrow new Error(\n\t\t\t\t`Canvas extension \"${extensionId}\" is not running, so there is nothing to reload. Open it first.`,\n\t\t\t);\n\t\t}\n\n\t\t// Snapshot before anything moves: `close` mutates the instance table, and the\n\t\t// old child's declarations go with it when it is terminated.\n\t\tconst carried = this.instancesOf(extensionId);\n\t\tconst actionsBefore = indexActions(previous.declarations);\n\n\t\t// Fork the edited code first. If it does not come up, the old child is still\n\t\t// registered and still serving, and this throws without costing anything.\n\t\tconst next = await this.spawn(previous.extension);\n\n\t\t// The swap. Registering the new child before stopping the old one is what\n\t\t// makes the old one's `onExit` a no-op rather than a table-clearing race.\n\t\tthis.children.set(extensionId, next);\n\t\tfor (const instance of carried) this.instances.delete(canvasInstanceKeyOf(instance));\n\t\tfor (const instance of carried) {\n\t\t\ttry {\n\t\t\t\tawait previous.process.close({\n\t\t\t\t\tsessionId: extensionId,\n\t\t\t\t\textensionId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// The old child is about to be killed, so a refused close costs nothing:\n\t\t\t\t// its ports go with the process. Reporting it would be noise on a path\n\t\t\t\t// the person asked for.\n\t\t\t}\n\t\t}\n\t\tawait previous.process.terminate();\n\n\t\tconst reopened: CanvasInstance[] = [];\n\t\tconst dropped: CanvasReloadDrop[] = [];\n\t\tfor (const instance of carried) {\n\t\t\t// The edit may have renamed or removed the canvas. That is a legitimate\n\t\t\t// thing for an author to do mid-iteration, so it is reported rather than\n\t\t\t// thrown — the other instances still come back.\n\t\t\tif (!next.declarations.has(instance.canvasId)) {\n\t\t\t\tdropped.push({\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\treason: `the reloaded extension no longer declares canvas \"${instance.canvasId}\" (declares: ${[...next.declarations.keys()].join(\", \") || \"none\"})`,\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst result = (await next.process.open(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: extensionId,\n\t\t\t\t\t\textensionId,\n\t\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\t\tinput: instance.openInput,\n\t\t\t\t\t},\n\t\t\t\t\toptions,\n\t\t\t\t)) as CanvasProviderOpenResult | null;\n\t\t\t\tconst fresh: CanvasInstance = {\n\t\t\t\t\t...instance,\n\t\t\t\t\turl: result?.url,\n\t\t\t\t\ttitle: result?.title,\n\t\t\t\t\tstatus: result?.status,\n\t\t\t\t\tlastTouchedAt: this.now(),\n\t\t\t\t};\n\t\t\t\tthis.instances.set(canvasInstanceKeyOf(fresh), fresh);\n\t\t\t\treopened.push(fresh);\n\t\t\t} catch (cause) {\n\t\t\t\tawait this.abandon(extensionId, instance.canvasId, instance.instanceId);\n\t\t\t\tdropped.push({\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\treason: cause instanceof Error ? cause.message : String(cause),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tnext.idleSince = reopened.length === 0 ? this.now() : undefined;\n\n\t\treturn {\n\t\t\textensionId,\n\t\t\treopened,\n\t\t\tdropped,\n\t\t\tcanvases: [...next.declarations.keys()],\n\t\t\tactions: diffActions(actionsBefore, indexActions(next.declarations)),\n\t\t};\n\t}\n\n\t/**\n\t * Stop an extension's child now, rather than at the end of its linger period.\n\t *\n\t * The reaper's grace period is right for an extension nobody is using and wrong\n\t * for one whose directory is about to be moved or deleted: a child outliving\n\t * its own source is the most confusing state a canvas can be in, because it\n\t * keeps serving code that is no longer anywhere on disk.\n\t */\n\tasync stopChild(extensionId: string): Promise<boolean> {\n\t\tconst child = this.children.get(extensionId);\n\t\tif (!child) return false;\n\t\tfor (const instance of this.instancesOf(extensionId)) await this.close(instance);\n\t\tthis.children.delete(extensionId);\n\t\tawait child.process.terminate();\n\t\treturn true;\n\t}\n\n\t/** Every open instance. */\n\tlistInstances(): CanvasInstance[] {\n\t\treturn [...this.instances.values()];\n\t}\n\n\t/**\n\t * Actions currently invocable, one entry per open instance per declared action.\n\t * Empty when nothing is open — which is the point: a canvas that is not open\n\t * costs the prompt nothing (§7).\n\t */\n\tactiveActions(): CanvasActionBinding[] {\n\t\tconst bindings: CanvasActionBinding[] = [];\n\t\tfor (const instance of this.instances.values()) {\n\t\t\tconst declaration = this.children.get(instance.extensionId)?.declarations.get(instance.canvasId);\n\t\t\tfor (const action of declaration?.actions ?? []) {\n\t\t\t\tbindings.push({\n\t\t\t\t\textensionId: instance.extensionId,\n\t\t\t\t\tcanvasId: instance.canvasId,\n\t\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\t\taction,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\treturn bindings;\n\t}\n\n\t/**\n\t * Close instances hoocode has not touched within the idle timeout, then reap\n\t * children that have had no instances for the linger period. Advisory cleanup:\n\t * see the module header on what \"idle\" can and cannot mean here.\n\t *\n\t * @returns The instance keys that were closed.\n\t */\n\tasync reapIdle(): Promise<string[]> {\n\t\tconst idleTimeout = this.options.idleTimeoutMs ?? CANVAS_INSTANCE_IDLE_MS;\n\t\tconst linger = this.options.childLingerMs ?? CANVAS_CHILD_LINGER_MS;\n\t\tconst now = this.now();\n\n\t\tconst expired = this.listInstances().filter((instance) => now - instance.lastTouchedAt >= idleTimeout);\n\t\tfor (const instance of expired) await this.close(instance);\n\n\t\tfor (const [extensionId, child] of [...this.children.entries()]) {\n\t\t\tconst unused = this.instancesOf(extensionId).length === 0;\n\t\t\tif (!unused) continue;\n\t\t\tconst since = child.idleSince ?? now;\n\t\t\tchild.idleSince = since;\n\t\t\tif (now - since >= linger) {\n\t\t\t\tthis.children.delete(extensionId);\n\t\t\t\tawait child.process.terminate();\n\t\t\t}\n\t\t}\n\n\t\treturn expired.map((instance) => canvasInstanceKeyOf(instance));\n\t}\n\n\t/** Close everything and terminate every child. Safe to call twice. */\n\tasync shutdown(): Promise<void> {\n\t\tfor (const instance of this.listInstances()) await this.close(instance);\n\t\tconst children = [...this.children.values()];\n\t\tthis.children.clear();\n\t\tawait Promise.all(children.map((child) => child.process.terminate()));\n\t}\n\n\t/**\n\t * Reconcile an instance we asked to open but never saw open — because a person\n\t * cancelled, or the call timed out. One path serves both.\n\t *\n\t * The provider protocol has no cancel verb, so the child may have finished opening\n\t * and be holding a port. `canvas.close` is the only way to tell it to let go, and\n\t * it can be sent because the instance id was generated before the open call.\n\t *\n\t * If the close itself goes unanswered the child is wedged, and the only remaining\n\t * lever is terminating it — but that kills every instance of that extension, so it\n\t * is done only when no other instance is live. When siblings exist the child is left\n\t * alone and the leak is reported, rather than paid for by someone else's open canvas.\n\t */\n\tprivate async abandon(extensionId: string, canvasId: string, instanceId: string): Promise<void> {\n\t\tconst child = this.children.get(extensionId);\n\t\tif (!child) return;\n\t\ttry {\n\t\t\tawait child.process.close({ sessionId: extensionId, extensionId, canvasId, instanceId });\n\t\t\treturn;\n\t\t} catch (cause) {\n\t\t\tconst detail = cause instanceof Error ? cause.message : String(cause);\n\t\t\tif (this.instancesOf(extensionId).length > 0) {\n\t\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\t\textensionId,\n\t\t\t\t\t`Stopped opening canvas \"${canvasId}\" but the extension did not confirm the close (${detail}). ` +\n\t\t\t\t\t\t\"It has other canvases open, so it was left running; a port may stay bound until it exits.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.children.delete(extensionId);\n\t\t\tawait child.process.terminate();\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\textensionId,\n\t\t\t\t`Stopped opening canvas \"${canvasId}\" and the extension did not confirm the close (${detail}); it was stopped.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate instancesOf(extensionId: string): CanvasInstance[] {\n\t\treturn this.listInstances().filter((instance) => instance.extensionId === extensionId);\n\t}\n\n\tprivate async child(extension: DiscoveredCanvasExtension): Promise<ChildEntry> {\n\t\tconst existing = this.children.get(extension.id);\n\t\tif (existing?.process.running) return existing;\n\t\tif (existing) this.children.delete(extension.id);\n\n\t\tconst entry = await this.spawn(extension);\n\t\tthis.children.set(extension.id, entry);\n\t\treturn entry;\n\t}\n\n\t/**\n\t * Fork one extension and wait for its declarations, without registering it.\n\t *\n\t * Separate from {@link child} because {@link reload} needs to fork a *second*\n\t * child while the first is still serving the person's open canvas: if the edit\n\t * that prompted the reload does not run, the old child is still there and\n\t * nothing was lost. Registering is therefore the caller's step, taken only once\n\t * the new child has answered.\n\t */\n\tprivate async spawn(extension: DiscoveredCanvasExtension): Promise<ChildEntry> {\n\t\t// The single choke point: every path that could start a process comes through\n\t\t// here, so the gate is enforced once and cannot be bypassed by a caller that\n\t\t// forgot to filter. Callers should still filter with `gateCanvasExtensions`\n\t\t// so they can explain the refusal; this is the backstop, not the UI.\n\t\tif (shouldWithholdCanvas(extension, this.options.cwd, this.options.agentDir)) {\n\t\t\tthrow new CanvasTrustError(extension.id, this.options.cwd);\n\t\t}\n\n\t\tlet spawned: CanvasExtensionProcess | undefined;\n\t\tconst process = spawnCanvasExtension({\n\t\t\textensionId: extension.id,\n\t\t\tentry: extension.entry,\n\t\t\truntime: this.options.runtime,\n\t\t\trequestTimeoutMs: this.options.requestTimeoutMs,\n\t\t\tcwd: path.dirname(extension.dir),\n\t\t\tonLog: (message, level) => this.options.onLog?.(extension.id, message, level),\n\t\t\tonStray: (line) => this.options.onStray?.(extension.id, line),\n\t\t\tonStderr: (chunk) => this.options.onStderr?.(extension.id, chunk),\n\t\t\t// Only the child that is *currently registered* may clear the table. A\n\t\t\t// reload's probe dying before it is adopted must not take the live child's\n\t\t\t// instances with it, and the old child's own exit — which reload causes on\n\t\t\t// purpose, after the new one is registered — must not undo the swap.\n\t\t\tonExit: () => {\n\t\t\t\tconst registered = this.children.get(extension.id);\n\t\t\t\tif (spawned && registered?.process === spawned) this.forget(extension.id);\n\t\t\t},\n\t\t});\n\t\tspawned = process;\n\n\t\tlet ready: CanvasReadyMessage;\n\t\ttry {\n\t\t\tready = await process.ready;\n\t\t} catch (cause) {\n\t\t\t// `ready` rejects when the child exits first, but a child that answered\n\t\t\t// nothing and stayed up would otherwise be orphaned by the throw.\n\t\t\tawait process.terminate();\n\t\t\tthrow cause;\n\t\t}\n\n\t\tif (ready.unsupported && ready.unsupported.length > 0) {\n\t\t\tthis.options.onDiagnostic?.(\n\t\t\t\textension.id,\n\t\t\t\t`Canvas extension \"${extension.id}\" declares ${ready.unsupported.join(\", \")}, which hoocode does not support; those surfaces are ignored.`,\n\t\t\t);\n\t\t}\n\n\t\treturn {\n\t\t\tprocess,\n\t\t\tdeclarations: new Map(ready.canvases.map((declaration) => [declaration.id, declaration])),\n\t\t\tidleSince: this.now(),\n\t\t\textension,\n\t\t};\n\t}\n\n\t/** Drop a dead child and its instances, so a crash cannot leave stale entries. */\n\tprivate forget(extensionId: string): void {\n\t\tthis.children.delete(extensionId);\n\t\tfor (const [id, instance] of [...this.instances.entries()]) {\n\t\t\tif (instance.extensionId === extensionId) this.instances.delete(id);\n\t\t}\n\t}\n}\n"]}
|