@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
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoring a canvas: the request a person types, the files it writes, and the
|
|
3
|
+
* brief the model builds from.
|
|
4
|
+
*
|
|
5
|
+
* Design: `docs/canvas-extensions-design.md` §9 Phase 3, §13.
|
|
6
|
+
*
|
|
7
|
+
* This lives in `core/` rather than beside the other `/new-*` scaffolds because
|
|
8
|
+
* `/new-canvas` stopped being a file-writing command. Copilot's `/create-canvas`
|
|
9
|
+
* takes a sentence, has the agent write the extension, and opens it in a panel to
|
|
10
|
+
* iterate on; matching that means the command has to reach the canvas session to
|
|
11
|
+
* open, and the agent loop to build. Every decision that does not need either —
|
|
12
|
+
* what the name is, where the file goes, what goes in it, what the model is told —
|
|
13
|
+
* is here, testable without a terminal, a fork or a model.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { CANVAS_ENTRY_FILE } from "./discovery.js";
|
|
18
|
+
/**
|
|
19
|
+
* Where a canvas extension lives, per platform.
|
|
20
|
+
*
|
|
21
|
+
* This does not go through `WorkspaceLayout` like the other scaffolds, and the
|
|
22
|
+
* reason is that a canvas has no Claude convention to emit into: the surface
|
|
23
|
+
* exists in Copilot and in hoocode's own `.agents/` tree and nowhere else. A
|
|
24
|
+
* layout method returning nothing for one adapter would be a worse lie than
|
|
25
|
+
* naming the two real homes here — these are exactly the roots
|
|
26
|
+
* `core/canvas/discovery.ts` searches, which is what makes a scaffold live on the
|
|
27
|
+
* next `/canvas`.
|
|
28
|
+
*/
|
|
29
|
+
export const CANVAS_HOMES = {
|
|
30
|
+
agents: [".agents", "extensions"],
|
|
31
|
+
github: [".github", "extensions"],
|
|
32
|
+
};
|
|
33
|
+
/** Validates a canvas name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */
|
|
34
|
+
export function validateCanvasName(name) {
|
|
35
|
+
if (!name)
|
|
36
|
+
return "name is required";
|
|
37
|
+
if (!/^[a-z0-9-]+$/.test(name))
|
|
38
|
+
return "name must be lowercase a-z, 0-9, and hyphens only";
|
|
39
|
+
if (name.startsWith("-") || name.endsWith("-"))
|
|
40
|
+
return "name must not start or end with a hyphen";
|
|
41
|
+
if (name.includes("--"))
|
|
42
|
+
return "name must not contain consecutive hyphens";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Words dropped wherever they appear, because they carry no subject.
|
|
47
|
+
*
|
|
48
|
+
* Only grammar, never subject matter: a list that reached further would start
|
|
49
|
+
* deciding which of someone's nouns mattered.
|
|
50
|
+
*/
|
|
51
|
+
const FILLER = new Set([
|
|
52
|
+
"a",
|
|
53
|
+
"about",
|
|
54
|
+
"across",
|
|
55
|
+
"an",
|
|
56
|
+
"and",
|
|
57
|
+
"are",
|
|
58
|
+
"as",
|
|
59
|
+
"at",
|
|
60
|
+
"by",
|
|
61
|
+
"be",
|
|
62
|
+
"can",
|
|
63
|
+
"for",
|
|
64
|
+
"from",
|
|
65
|
+
"i",
|
|
66
|
+
"in",
|
|
67
|
+
"is",
|
|
68
|
+
"into",
|
|
69
|
+
"it",
|
|
70
|
+
"its",
|
|
71
|
+
"me",
|
|
72
|
+
"my",
|
|
73
|
+
"of",
|
|
74
|
+
"on",
|
|
75
|
+
"one",
|
|
76
|
+
"or",
|
|
77
|
+
"our",
|
|
78
|
+
"over",
|
|
79
|
+
"so",
|
|
80
|
+
"that",
|
|
81
|
+
"the",
|
|
82
|
+
"their",
|
|
83
|
+
"them",
|
|
84
|
+
"then",
|
|
85
|
+
"this",
|
|
86
|
+
"to",
|
|
87
|
+
"us",
|
|
88
|
+
"we",
|
|
89
|
+
"which",
|
|
90
|
+
"via",
|
|
91
|
+
"when",
|
|
92
|
+
"where",
|
|
93
|
+
"while",
|
|
94
|
+
"with",
|
|
95
|
+
"you",
|
|
96
|
+
"your",
|
|
97
|
+
]);
|
|
98
|
+
/**
|
|
99
|
+
* Words that open a request rather than describe one.
|
|
100
|
+
*
|
|
101
|
+
* People type `/new-canvas` as an instruction — "create a…", "build me a…",
|
|
102
|
+
* "help me…", "I want to…" — and the opening clause was landing in the directory
|
|
103
|
+
* name: `create-lightweight-games`, `help-compare-two`, `want-review-pull`. On a
|
|
104
|
+
* spread of twelve realistic descriptions, seven produced a name that named the
|
|
105
|
+
* request instead of the thing.
|
|
106
|
+
*
|
|
107
|
+
* These are dropped anywhere, not only at the front, because "add a canvas that
|
|
108
|
+
* shows X" buries one in the middle.
|
|
109
|
+
*/
|
|
110
|
+
const REQUEST_WORDS = new Set([
|
|
111
|
+
"add",
|
|
112
|
+
"build",
|
|
113
|
+
"could",
|
|
114
|
+
"create",
|
|
115
|
+
"design",
|
|
116
|
+
"generate",
|
|
117
|
+
"give",
|
|
118
|
+
"help",
|
|
119
|
+
"let",
|
|
120
|
+
"make",
|
|
121
|
+
"need",
|
|
122
|
+
"new",
|
|
123
|
+
"please",
|
|
124
|
+
"produce",
|
|
125
|
+
"set",
|
|
126
|
+
"show",
|
|
127
|
+
"something",
|
|
128
|
+
"up",
|
|
129
|
+
"want",
|
|
130
|
+
"would",
|
|
131
|
+
]);
|
|
132
|
+
/** Counting words: "compare two benchmark runs" is about the runs. */
|
|
133
|
+
const CARDINALS = new Set(["two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]);
|
|
134
|
+
/** How many words a derived name keeps. Enough to be recognisable, short enough to type. */
|
|
135
|
+
const DERIVED_NAME_WORDS = 3;
|
|
136
|
+
/**
|
|
137
|
+
* Whether a word is probably a verb form rather than the thing being described.
|
|
138
|
+
*
|
|
139
|
+
* Crude on purpose — no part-of-speech tagger for a directory name. It exists
|
|
140
|
+
* because a participle sits between the request and its subject and pushes the
|
|
141
|
+
* subject out of a three-word name: "a dashboard **showing** flaky tests" became
|
|
142
|
+
* `dashboard-showing-flaky`, and "a spreadsheet for **tracking** API latency"
|
|
143
|
+
* became `spreadsheet-tracking-api`. The length floor spares short words where
|
|
144
|
+
* the ending is a coincidence (`feed`, `used`, `ring`).
|
|
145
|
+
*/
|
|
146
|
+
function looksLikeVerbForm(word) {
|
|
147
|
+
return word.length > 4 && (word.endsWith("ing") || word.endsWith("ed"));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Turn a sentence into a directory name.
|
|
151
|
+
*
|
|
152
|
+
* Returns undefined when nothing usable survives — an all-punctuation request, or
|
|
153
|
+
* a sentence of nothing but filler — because inventing `canvas-1` would hide from
|
|
154
|
+
* the person that we did not understand them.
|
|
155
|
+
*/
|
|
156
|
+
export function canvasNameFromDescription(description) {
|
|
157
|
+
const words = description
|
|
158
|
+
.toLowerCase()
|
|
159
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
160
|
+
.trim()
|
|
161
|
+
.split(" ")
|
|
162
|
+
.filter((word) => word.length > 0);
|
|
163
|
+
const content = words.filter((word) => !FILLER.has(word) && !REQUEST_WORDS.has(word) && !CARDINALS.has(word));
|
|
164
|
+
// "canvas" is dropped only when something else remains: "/new-canvas a canvas"
|
|
165
|
+
// should still produce `canvas` rather than nothing.
|
|
166
|
+
const named = content.filter((word) => word !== "canvas");
|
|
167
|
+
let kept = named.length > 0 ? named : content;
|
|
168
|
+
// Prefer nouns — but only while enough of them remain. Where they do not, the
|
|
169
|
+
// participle *is* the subject ("a canvas for onboarding") and dropping it would
|
|
170
|
+
// leave nothing worth naming.
|
|
171
|
+
const nouns = kept.filter((word) => !looksLikeVerbForm(word));
|
|
172
|
+
if (nouns.length >= 2)
|
|
173
|
+
kept = nouns;
|
|
174
|
+
const name = kept.slice(0, DERIVED_NAME_WORDS).join("-");
|
|
175
|
+
return validateCanvasName(name) === null ? name : undefined;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Parse `/new-canvas`'s argument.
|
|
179
|
+
*
|
|
180
|
+
* Three shapes, in the order they are tested:
|
|
181
|
+
*
|
|
182
|
+
* - `my-board` — a bare name. Unchanged from before descriptions existed, and
|
|
183
|
+
* tested first so it can never be re-read as a one-word description.
|
|
184
|
+
* - `my-board: a kanban board for the release checklist` — both, when someone
|
|
185
|
+
* cares what the directory is called. A colon rather than a flag because the
|
|
186
|
+
* rest of the line is prose and a flag parser would have to guess where it ends.
|
|
187
|
+
* - `a kanban board for the release checklist` — a description, the shape
|
|
188
|
+
* `/create-canvas` uses. The name is derived and reported.
|
|
189
|
+
*
|
|
190
|
+
* Returns a string when the input cannot become a canvas; the caller shows it.
|
|
191
|
+
*/
|
|
192
|
+
export function parseCanvasRequest(input) {
|
|
193
|
+
const trimmed = input.trim();
|
|
194
|
+
if (trimmed.length === 0)
|
|
195
|
+
return "name or description is required";
|
|
196
|
+
if (validateCanvasName(trimmed) === null)
|
|
197
|
+
return { name: trimmed, description: undefined };
|
|
198
|
+
const colon = trimmed.indexOf(":");
|
|
199
|
+
if (colon > 0) {
|
|
200
|
+
const name = trimmed.slice(0, colon).trim();
|
|
201
|
+
const description = trimmed.slice(colon + 1).trim();
|
|
202
|
+
if (validateCanvasName(name) === null && description.length > 0)
|
|
203
|
+
return { name, description };
|
|
204
|
+
}
|
|
205
|
+
const derived = canvasNameFromDescription(trimmed);
|
|
206
|
+
if (!derived) {
|
|
207
|
+
return `could not derive a directory name from that. Give one explicitly: /new-canvas <name>: ${trimmed}`;
|
|
208
|
+
}
|
|
209
|
+
return { name: derived, description: trimmed };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* A working single-canvas extension.
|
|
213
|
+
*
|
|
214
|
+
* Deliberately complete rather than a stub: a canvas has no passive half — its
|
|
215
|
+
* name, its actions and its UI all come from running its code — so a scaffold
|
|
216
|
+
* that does not run teaches nothing and cannot be checked with `/canvas open`.
|
|
217
|
+
* This one opens, serves a page, answers an action, and closes cleanly, which is
|
|
218
|
+
* the whole contract; everything past that is the author's.
|
|
219
|
+
*
|
|
220
|
+
* Shaped like the catalog extensions hoocode already hosts: the only import is
|
|
221
|
+
* `@github/copilot-sdk/extension` (host-resolved — never installed, see
|
|
222
|
+
* `docs/canvas-extensions-design.md` §4.1) plus `node:` builtins, and the UI is
|
|
223
|
+
* served from an ephemeral loopback port behind a per-instance token so nothing
|
|
224
|
+
* else on the machine can read it.
|
|
225
|
+
*/
|
|
226
|
+
export const CANVAS_ENTRY_TEMPLATE = (name) => `\
|
|
227
|
+
// A canvas extension. Run it with: /canvas open ${name}
|
|
228
|
+
//
|
|
229
|
+
// The "@github/copilot-sdk/extension" import is resolved by the host at fork
|
|
230
|
+
// time. Do not install it, and do not add a node_modules here.
|
|
231
|
+
import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension";
|
|
232
|
+
import { randomBytes } from "node:crypto";
|
|
233
|
+
import { createServer } from "node:http";
|
|
234
|
+
|
|
235
|
+
// The canvas's identity, in one place. An open instance is bound to this id, so
|
|
236
|
+
// changing it drops the canvas anyone is currently looking at — rename the
|
|
237
|
+
// canvas with \`/canvas rename\`, or change NAME alone if you only want a
|
|
238
|
+
// different label on the page.
|
|
239
|
+
const ID = "${name}";
|
|
240
|
+
const NAME = "${name}";
|
|
241
|
+
|
|
242
|
+
/** Per-instance state. A canvas can be opened more than once at a time. */
|
|
243
|
+
const instances = new Map();
|
|
244
|
+
|
|
245
|
+
const session = await joinSession({
|
|
246
|
+
canvases: [
|
|
247
|
+
createCanvas({
|
|
248
|
+
id: ID,
|
|
249
|
+
displayName: NAME,
|
|
250
|
+
description: "TODO: one sentence — the agent reads this to decide whether to open it.",
|
|
251
|
+
actions: [
|
|
252
|
+
{
|
|
253
|
+
name: "add_note",
|
|
254
|
+
description: "TODO: describe what the agent can do to this canvas.",
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: { text: { type: "string" } },
|
|
258
|
+
required: ["text"],
|
|
259
|
+
},
|
|
260
|
+
handler: (ctx) => {
|
|
261
|
+
const entry = instances.get(ctx.instanceId);
|
|
262
|
+
// CanvasError carries a code the host shows verbatim; a bare
|
|
263
|
+
// throw arrives as an opaque internal error instead.
|
|
264
|
+
if (!entry) throw new CanvasError("no_instance", \`Instance "\${ctx.instanceId}" is not open.\`);
|
|
265
|
+
entry.notes.push(ctx.input.text);
|
|
266
|
+
return { notes: entry.notes.length };
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
open: async (ctx) => {
|
|
271
|
+
const token = randomBytes(32).toString("base64url");
|
|
272
|
+
const notes = [];
|
|
273
|
+
const server = createServer((req, res) => {
|
|
274
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
275
|
+
if (url.searchParams.get("token") !== token) {
|
|
276
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
277
|
+
res.end("forbidden");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
281
|
+
res.end(
|
|
282
|
+
\`<!doctype html><meta charset="utf-8"><title>\${NAME}</title>\` +
|
|
283
|
+
\`<p>\${notes.length} note(s). TODO: build the UI.\`,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
// Port 0 on 127.0.0.1: an ephemeral port, reachable only from this machine.
|
|
287
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
288
|
+
const { port } = server.address();
|
|
289
|
+
instances.set(ctx.instanceId, { server, notes });
|
|
290
|
+
return {
|
|
291
|
+
url: \`http://127.0.0.1:\${port}/?token=\${token}\`,
|
|
292
|
+
title: NAME,
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
onClose: async (ctx) => {
|
|
296
|
+
const entry = instances.get(ctx.instanceId);
|
|
297
|
+
// Called for an abandoned open too, so the instance may be unknown.
|
|
298
|
+
if (!entry) return;
|
|
299
|
+
instances.delete(ctx.instanceId);
|
|
300
|
+
await new Promise((resolve) => entry.server.close(resolve));
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// stdout is the protocol channel. Use session.log, never console.log.
|
|
307
|
+
await session.log(\`\${ID} ready\`);
|
|
308
|
+
`;
|
|
309
|
+
/**
|
|
310
|
+
* Write the template into every platform target that has a canvas home.
|
|
311
|
+
*
|
|
312
|
+
* Existing files are never clobbered — they are reported and skipped, so running
|
|
313
|
+
* `/new-canvas` twice on a canvas you have been editing cannot lose it.
|
|
314
|
+
*/
|
|
315
|
+
export function scaffoldCanvas(cwd, name, platforms) {
|
|
316
|
+
const created = [];
|
|
317
|
+
const skipped = [];
|
|
318
|
+
for (const platform of platforms) {
|
|
319
|
+
const home = CANVAS_HOMES[platform];
|
|
320
|
+
if (!home)
|
|
321
|
+
continue;
|
|
322
|
+
const relative = join(...home, name, CANVAS_ENTRY_FILE);
|
|
323
|
+
const absolute = join(cwd, relative);
|
|
324
|
+
if (existsSync(absolute)) {
|
|
325
|
+
skipped.push(relative);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
329
|
+
writeFileSync(absolute, CANVAS_ENTRY_TEMPLATE(name), "utf8");
|
|
330
|
+
created.push(relative);
|
|
331
|
+
}
|
|
332
|
+
return { created, skipped };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The message the model builds from — hoocode's half of `/create-canvas`.
|
|
336
|
+
*
|
|
337
|
+
* A scaffold plus a sentence is not a canvas, and the gap between them is the
|
|
338
|
+
* agent's work. This is what turns "a kanban board for the release checklist"
|
|
339
|
+
* into a build task with the contract attached, so the model does not have to
|
|
340
|
+
* infer the rules of a surface it cannot see from a template it has not read.
|
|
341
|
+
*
|
|
342
|
+
* Four of those rules are stated because getting them wrong fails in ways whose
|
|
343
|
+
* symptom does not name the cause: an installed dependency (the resolver already
|
|
344
|
+
* provides the SDK, and a `node_modules` here is a §4.1 violation), a
|
|
345
|
+
* `console.log` (corrupts the JSON-RPC channel and surfaces as "non-protocol
|
|
346
|
+
* stdout"), a write without a reload (changes nothing at all, because the running
|
|
347
|
+
* child was forked from the old bytes), and a renamed canvas id.
|
|
348
|
+
*
|
|
349
|
+
* That last one is the newest and was found the hard way, by building a canvas
|
|
350
|
+
* with this command: the scaffold names the canvas after the directory, a model
|
|
351
|
+
* that thinks of a better name renames the `id`, and the next reload drops the
|
|
352
|
+
* instance the person is looking at — correctly, since the canvas it was opened
|
|
353
|
+
* against no longer exists. `displayName` is the half they actually see, so it
|
|
354
|
+
* is the half to change.
|
|
355
|
+
*/
|
|
356
|
+
export function canvasBuildBrief(name, description, entryPath, target) {
|
|
357
|
+
const lines = [
|
|
358
|
+
`/new-canvas: build a canvas extension named "${name}".`,
|
|
359
|
+
"",
|
|
360
|
+
"What the person asked for, in their words:",
|
|
361
|
+
` ${description}`,
|
|
362
|
+
"",
|
|
363
|
+
`A working template is already at ${entryPath}. Edit it until it does what was asked.`,
|
|
364
|
+
];
|
|
365
|
+
if (target) {
|
|
366
|
+
lines.push("", target.url
|
|
367
|
+
? `It is already open at ${target.url} (instance ${target.instanceId}), so the person is watching it as you work.`
|
|
368
|
+
: `It is already open as instance ${target.instanceId}.`, `After every edit, call reload_canvas with extensionId "${name}". A write to the file changes nothing on its own — the running process was forked from the old code. Reloading hands back a NEW url; give it to the person, because the tab they have open dies with the old process.`);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
lines.push("", `It is not open — the person will run /canvas open ${name} when they want to look. Build it anyway; you can check it runs by reading it carefully rather than by opening it yourself.`);
|
|
372
|
+
}
|
|
373
|
+
lines.push("", "The contract this surface runs under, which is not yours to change:", '- The only non-`node:` import allowed is "@github/copilot-sdk/extension". The host resolves it when it forks the extension. Do not install anything, and do not add a package.json or node_modules in the extension directory.', "- stdout is the JSON-RPC channel. Use `session.log(...)`; a `console.log` corrupts the protocol.", "- Do not change the canvas's `id` (the template holds it in `ID`). An open instance is bound to it, so renaming it drops the canvas the person is watching on your next reload. Change `NAME` for a nicer label — that is the one they see — and if the directory name itself is wrong, say so and let them run `/canvas rename`, which moves everything at once.", "- After a reload, read the action list it reports back. That is the host telling you which of your actions it can actually see, and it is the only confirmation an action you just wrote is really callable.", "- Serve the UI from the loopback server the template already starts. Keep the per-instance token check, and keep `onClose` shutting the server down — that is what stops a port outliving the session.", "- Everything in `actions: [...]` becomes callable by you through invoke_canvas_action once it is open, so give each action a real description and inputSchema. That list is how you drive the canvas; the person drives the same state through the page.", "", "Build the thing that was asked for, not a stub: leave no TODO behind, and make the page render real state rather than a placeholder.");
|
|
374
|
+
return lines.join("\n");
|
|
375
|
+
}
|
|
376
|
+
//# sourceMappingURL=scaffold.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scaffold.js","sourceRoot":"","sources":["../../../src/core/canvas/scaffold.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Authoring a canvas: the request a person types, the files it writes, and the\n * brief the model builds from.\n *\n * Design: `docs/canvas-extensions-design.md` §9 Phase 3, §13.\n *\n * This lives in `core/` rather than beside the other `/new-*` scaffolds because\n * `/new-canvas` stopped being a file-writing command. Copilot's `/create-canvas`\n * takes a sentence, has the agent write the extension, and opens it in a panel to\n * iterate on; matching that means the command has to reach the canvas session to\n * open, and the agent loop to build. Every decision that does not need either —\n * what the name is, where the file goes, what goes in it, what the model is told —\n * is here, testable without a terminal, a fork or a model.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport type { MarketplacePlatform } from \"../extensions/plugins/formats/types.js\";\nimport { CANVAS_ENTRY_FILE } from \"./discovery.js\";\n\n/**\n * Where a canvas extension lives, per platform.\n *\n * This does not go through `WorkspaceLayout` like the other scaffolds, and the\n * reason is that a canvas has no Claude convention to emit into: the surface\n * exists in Copilot and in hoocode's own `.agents/` tree and nowhere else. A\n * layout method returning nothing for one adapter would be a worse lie than\n * naming the two real homes here — these are exactly the roots\n * `core/canvas/discovery.ts` searches, which is what makes a scaffold live on the\n * next `/canvas`.\n */\nexport const CANVAS_HOMES: Partial<Record<MarketplacePlatform, string[]>> = {\n\tagents: [\".agents\", \"extensions\"],\n\tgithub: [\".github\", \"extensions\"],\n};\n\n/** Validates a canvas name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */\nexport function validateCanvasName(name: string): string | null {\n\tif (!name) return \"name is required\";\n\tif (!/^[a-z0-9-]+$/.test(name)) return \"name must be lowercase a-z, 0-9, and hyphens only\";\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) return \"name must not start or end with a hyphen\";\n\tif (name.includes(\"--\")) return \"name must not contain consecutive hyphens\";\n\treturn null;\n}\n\n/**\n * Words dropped wherever they appear, because they carry no subject.\n *\n * Only grammar, never subject matter: a list that reached further would start\n * deciding which of someone's nouns mattered.\n */\nconst FILLER = new Set([\n\t\"a\",\n\t\"about\",\n\t\"across\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"by\",\n\t\"be\",\n\t\"can\",\n\t\"for\",\n\t\"from\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"into\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"one\",\n\t\"or\",\n\t\"our\",\n\t\"over\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"their\",\n\t\"them\",\n\t\"then\",\n\t\"this\",\n\t\"to\",\n\t\"us\",\n\t\"we\",\n\t\"which\",\n\t\"via\",\n\t\"when\",\n\t\"where\",\n\t\"while\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\n]);\n\n/**\n * Words that open a request rather than describe one.\n *\n * People type `/new-canvas` as an instruction — \"create a…\", \"build me a…\",\n * \"help me…\", \"I want to…\" — and the opening clause was landing in the directory\n * name: `create-lightweight-games`, `help-compare-two`, `want-review-pull`. On a\n * spread of twelve realistic descriptions, seven produced a name that named the\n * request instead of the thing.\n *\n * These are dropped anywhere, not only at the front, because \"add a canvas that\n * shows X\" buries one in the middle.\n */\nconst REQUEST_WORDS = new Set([\n\t\"add\",\n\t\"build\",\n\t\"could\",\n\t\"create\",\n\t\"design\",\n\t\"generate\",\n\t\"give\",\n\t\"help\",\n\t\"let\",\n\t\"make\",\n\t\"need\",\n\t\"new\",\n\t\"please\",\n\t\"produce\",\n\t\"set\",\n\t\"show\",\n\t\"something\",\n\t\"up\",\n\t\"want\",\n\t\"would\",\n]);\n\n/** Counting words: \"compare two benchmark runs\" is about the runs. */\nconst CARDINALS = new Set([\"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\", \"ten\"]);\n\n/** How many words a derived name keeps. Enough to be recognisable, short enough to type. */\nconst DERIVED_NAME_WORDS = 3;\n\n/**\n * Whether a word is probably a verb form rather than the thing being described.\n *\n * Crude on purpose — no part-of-speech tagger for a directory name. It exists\n * because a participle sits between the request and its subject and pushes the\n * subject out of a three-word name: \"a dashboard **showing** flaky tests\" became\n * `dashboard-showing-flaky`, and \"a spreadsheet for **tracking** API latency\"\n * became `spreadsheet-tracking-api`. The length floor spares short words where\n * the ending is a coincidence (`feed`, `used`, `ring`).\n */\nfunction looksLikeVerbForm(word: string): boolean {\n\treturn word.length > 4 && (word.endsWith(\"ing\") || word.endsWith(\"ed\"));\n}\n\n/**\n * Turn a sentence into a directory name.\n *\n * Returns undefined when nothing usable survives — an all-punctuation request, or\n * a sentence of nothing but filler — because inventing `canvas-1` would hide from\n * the person that we did not understand them.\n */\nexport function canvasNameFromDescription(description: string): string | undefined {\n\tconst words = description\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \" \")\n\t\t.trim()\n\t\t.split(\" \")\n\t\t.filter((word) => word.length > 0);\n\n\tconst content = words.filter((word) => !FILLER.has(word) && !REQUEST_WORDS.has(word) && !CARDINALS.has(word));\n\t// \"canvas\" is dropped only when something else remains: \"/new-canvas a canvas\"\n\t// should still produce `canvas` rather than nothing.\n\tconst named = content.filter((word) => word !== \"canvas\");\n\tlet kept = named.length > 0 ? named : content;\n\n\t// Prefer nouns — but only while enough of them remain. Where they do not, the\n\t// participle *is* the subject (\"a canvas for onboarding\") and dropping it would\n\t// leave nothing worth naming.\n\tconst nouns = kept.filter((word) => !looksLikeVerbForm(word));\n\tif (nouns.length >= 2) kept = nouns;\n\n\tconst name = kept.slice(0, DERIVED_NAME_WORDS).join(\"-\");\n\treturn validateCanvasName(name) === null ? name : undefined;\n}\n\n/** What a person asked `/new-canvas` for. */\nexport interface CanvasRequest {\n\t/** Directory and default canvas id. */\n\tname: string;\n\t/**\n\t * What they want it to do, in their words, or undefined when they only named\n\t * one. Present means the model is expected to build it (Copilot's\n\t * `/create-canvas` shape); absent means they want the template to edit by hand.\n\t */\n\tdescription: string | undefined;\n}\n\n/**\n * Parse `/new-canvas`'s argument.\n *\n * Three shapes, in the order they are tested:\n *\n * - `my-board` — a bare name. Unchanged from before descriptions existed, and\n * tested first so it can never be re-read as a one-word description.\n * - `my-board: a kanban board for the release checklist` — both, when someone\n * cares what the directory is called. A colon rather than a flag because the\n * rest of the line is prose and a flag parser would have to guess where it ends.\n * - `a kanban board for the release checklist` — a description, the shape\n * `/create-canvas` uses. The name is derived and reported.\n *\n * Returns a string when the input cannot become a canvas; the caller shows it.\n */\nexport function parseCanvasRequest(input: string): CanvasRequest | string {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return \"name or description is required\";\n\n\tif (validateCanvasName(trimmed) === null) return { name: trimmed, description: undefined };\n\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon > 0) {\n\t\tconst name = trimmed.slice(0, colon).trim();\n\t\tconst description = trimmed.slice(colon + 1).trim();\n\t\tif (validateCanvasName(name) === null && description.length > 0) return { name, description };\n\t}\n\n\tconst derived = canvasNameFromDescription(trimmed);\n\tif (!derived) {\n\t\treturn `could not derive a directory name from that. Give one explicitly: /new-canvas <name>: ${trimmed}`;\n\t}\n\treturn { name: derived, description: trimmed };\n}\n\n/**\n * A working single-canvas extension.\n *\n * Deliberately complete rather than a stub: a canvas has no passive half — its\n * name, its actions and its UI all come from running its code — so a scaffold\n * that does not run teaches nothing and cannot be checked with `/canvas open`.\n * This one opens, serves a page, answers an action, and closes cleanly, which is\n * the whole contract; everything past that is the author's.\n *\n * Shaped like the catalog extensions hoocode already hosts: the only import is\n * `@github/copilot-sdk/extension` (host-resolved — never installed, see\n * `docs/canvas-extensions-design.md` §4.1) plus `node:` builtins, and the UI is\n * served from an ephemeral loopback port behind a per-instance token so nothing\n * else on the machine can read it.\n */\nexport const CANVAS_ENTRY_TEMPLATE = (name: string): string => `\\\n// A canvas extension. Run it with: /canvas open ${name}\n//\n// The \"@github/copilot-sdk/extension\" import is resolved by the host at fork\n// time. Do not install it, and do not add a node_modules here.\nimport { createCanvas, CanvasError, joinSession } from \"@github/copilot-sdk/extension\";\nimport { randomBytes } from \"node:crypto\";\nimport { createServer } from \"node:http\";\n\n// The canvas's identity, in one place. An open instance is bound to this id, so\n// changing it drops the canvas anyone is currently looking at — rename the\n// canvas with \\`/canvas rename\\`, or change NAME alone if you only want a\n// different label on the page.\nconst ID = \"${name}\";\nconst NAME = \"${name}\";\n\n/** Per-instance state. A canvas can be opened more than once at a time. */\nconst instances = new Map();\n\nconst session = await joinSession({\n\tcanvases: [\n\t\tcreateCanvas({\n\t\t\tid: ID,\n\t\t\tdisplayName: NAME,\n\t\t\tdescription: \"TODO: one sentence — the agent reads this to decide whether to open it.\",\n\t\t\tactions: [\n\t\t\t\t{\n\t\t\t\t\tname: \"add_note\",\n\t\t\t\t\tdescription: \"TODO: describe what the agent can do to this canvas.\",\n\t\t\t\t\tinputSchema: {\n\t\t\t\t\t\ttype: \"object\",\n\t\t\t\t\t\tproperties: { text: { type: \"string\" } },\n\t\t\t\t\t\trequired: [\"text\"],\n\t\t\t\t\t},\n\t\t\t\t\thandler: (ctx) => {\n\t\t\t\t\t\tconst entry = instances.get(ctx.instanceId);\n\t\t\t\t\t\t// CanvasError carries a code the host shows verbatim; a bare\n\t\t\t\t\t\t// throw arrives as an opaque internal error instead.\n\t\t\t\t\t\tif (!entry) throw new CanvasError(\"no_instance\", \\`Instance \"\\${ctx.instanceId}\" is not open.\\`);\n\t\t\t\t\t\tentry.notes.push(ctx.input.text);\n\t\t\t\t\t\treturn { notes: entry.notes.length };\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\topen: async (ctx) => {\n\t\t\t\tconst token = randomBytes(32).toString(\"base64url\");\n\t\t\t\tconst notes = [];\n\t\t\t\tconst server = createServer((req, res) => {\n\t\t\t\t\tconst url = new URL(req.url ?? \"/\", \"http://127.0.0.1\");\n\t\t\t\t\tif (url.searchParams.get(\"token\") !== token) {\n\t\t\t\t\t\tres.writeHead(403, { \"Content-Type\": \"text/plain\" });\n\t\t\t\t\t\tres.end(\"forbidden\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tres.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(\n\t\t\t\t\t\t\\`<!doctype html><meta charset=\"utf-8\"><title>\\${NAME}</title>\\` +\n\t\t\t\t\t\t\t\\`<p>\\${notes.length} note(s). TODO: build the UI.\\`,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t\t// Port 0 on 127.0.0.1: an ephemeral port, reachable only from this machine.\n\t\t\t\tawait new Promise((resolve) => server.listen(0, \"127.0.0.1\", resolve));\n\t\t\t\tconst { port } = server.address();\n\t\t\t\tinstances.set(ctx.instanceId, { server, notes });\n\t\t\t\treturn {\n\t\t\t\t\turl: \\`http://127.0.0.1:\\${port}/?token=\\${token}\\`,\n\t\t\t\t\ttitle: NAME,\n\t\t\t\t};\n\t\t\t},\n\t\t\tonClose: async (ctx) => {\n\t\t\t\tconst entry = instances.get(ctx.instanceId);\n\t\t\t\t// Called for an abandoned open too, so the instance may be unknown.\n\t\t\t\tif (!entry) return;\n\t\t\t\tinstances.delete(ctx.instanceId);\n\t\t\t\tawait new Promise((resolve) => entry.server.close(resolve));\n\t\t\t},\n\t\t}),\n\t],\n});\n\n// stdout is the protocol channel. Use session.log, never console.log.\nawait session.log(\\`\\${ID} ready\\`);\n`;\n\n/** What {@link scaffoldCanvas} wrote. */\nexport interface CanvasScaffoldResult {\n\t/** Workspace-relative entry files created, one per platform target. */\n\tcreated: string[];\n\t/** Workspace-relative entry files that already existed and were left alone. */\n\tskipped: string[];\n}\n\n/**\n * Write the template into every platform target that has a canvas home.\n *\n * Existing files are never clobbered — they are reported and skipped, so running\n * `/new-canvas` twice on a canvas you have been editing cannot lose it.\n */\nexport function scaffoldCanvas(cwd: string, name: string, platforms: MarketplacePlatform[]): CanvasScaffoldResult {\n\tconst created: string[] = [];\n\tconst skipped: string[] = [];\n\tfor (const platform of platforms) {\n\t\tconst home = CANVAS_HOMES[platform];\n\t\tif (!home) continue;\n\t\tconst relative = join(...home, name, CANVAS_ENTRY_FILE);\n\t\tconst absolute = join(cwd, relative);\n\t\tif (existsSync(absolute)) {\n\t\t\tskipped.push(relative);\n\t\t\tcontinue;\n\t\t}\n\t\tmkdirSync(dirname(absolute), { recursive: true });\n\t\twriteFileSync(absolute, CANVAS_ENTRY_TEMPLATE(name), \"utf8\");\n\t\tcreated.push(relative);\n\t}\n\treturn { created, skipped };\n}\n\n/** Where a build brief's canvas is, if opening it worked. */\nexport interface CanvasBriefTarget {\n\tinstanceId: string;\n\turl: string | undefined;\n}\n\n/**\n * The message the model builds from — hoocode's half of `/create-canvas`.\n *\n * A scaffold plus a sentence is not a canvas, and the gap between them is the\n * agent's work. This is what turns \"a kanban board for the release checklist\"\n * into a build task with the contract attached, so the model does not have to\n * infer the rules of a surface it cannot see from a template it has not read.\n *\n * Four of those rules are stated because getting them wrong fails in ways whose\n * symptom does not name the cause: an installed dependency (the resolver already\n * provides the SDK, and a `node_modules` here is a §4.1 violation), a\n * `console.log` (corrupts the JSON-RPC channel and surfaces as \"non-protocol\n * stdout\"), a write without a reload (changes nothing at all, because the running\n * child was forked from the old bytes), and a renamed canvas id.\n *\n * That last one is the newest and was found the hard way, by building a canvas\n * with this command: the scaffold names the canvas after the directory, a model\n * that thinks of a better name renames the `id`, and the next reload drops the\n * instance the person is looking at — correctly, since the canvas it was opened\n * against no longer exists. `displayName` is the half they actually see, so it\n * is the half to change.\n */\nexport function canvasBuildBrief(\n\tname: string,\n\tdescription: string,\n\tentryPath: string,\n\ttarget: CanvasBriefTarget | undefined,\n): string {\n\tconst lines = [\n\t\t`/new-canvas: build a canvas extension named \"${name}\".`,\n\t\t\"\",\n\t\t\"What the person asked for, in their words:\",\n\t\t` ${description}`,\n\t\t\"\",\n\t\t`A working template is already at ${entryPath}. Edit it until it does what was asked.`,\n\t];\n\n\tif (target) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\ttarget.url\n\t\t\t\t? `It is already open at ${target.url} (instance ${target.instanceId}), so the person is watching it as you work.`\n\t\t\t\t: `It is already open as instance ${target.instanceId}.`,\n\t\t\t`After every edit, call reload_canvas with extensionId \"${name}\". A write to the file changes nothing on its own — the running process was forked from the old code. Reloading hands back a NEW url; give it to the person, because the tab they have open dies with the old process.`,\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`It is not open — the person will run /canvas open ${name} when they want to look. Build it anyway; you can check it runs by reading it carefully rather than by opening it yourself.`,\n\t\t);\n\t}\n\n\tlines.push(\n\t\t\"\",\n\t\t\"The contract this surface runs under, which is not yours to change:\",\n\t\t'- The only non-`node:` import allowed is \"@github/copilot-sdk/extension\". The host resolves it when it forks the extension. Do not install anything, and do not add a package.json or node_modules in the extension directory.',\n\t\t\"- stdout is the JSON-RPC channel. Use `session.log(...)`; a `console.log` corrupts the protocol.\",\n\t\t\"- Do not change the canvas's `id` (the template holds it in `ID`). An open instance is bound to it, so renaming it drops the canvas the person is watching on your next reload. Change `NAME` for a nicer label — that is the one they see — and if the directory name itself is wrong, say so and let them run `/canvas rename`, which moves everything at once.\",\n\t\t\"- After a reload, read the action list it reports back. That is the host telling you which of your actions it can actually see, and it is the only confirmation an action you just wrote is really callable.\",\n\t\t\"- Serve the UI from the loopback server the template already starts. Keep the per-instance token check, and keep `onClose` shutting the server down — that is what stops a port outliving the session.\",\n\t\t\"- Everything in `actions: [...]` becomes callable by you through invoke_canvas_action once it is open, so give each action a real description and inputSchema. That list is how you drive the canvas; the person drives the same state through the page.\",\n\t\t\"\",\n\t\t\"Build the thing that was asked for, not a stub: leave no TODO behind, and make the page render real state rather than a placeholder.\",\n\t);\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import type { DiscoveredCanvasExtension } from "./discovery.js";
|
|
17
17
|
import { type CanvasSearchRoot } from "./discovery.js";
|
|
18
18
|
import { type CanvasAvailability } from "./launch.js";
|
|
19
|
-
import { type
|
|
19
|
+
import { type CanvasLifecycleRefusal, type CanvasRemoveResult, type CanvasRenameResult } from "./lifecycle.js";
|
|
20
|
+
import { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents, type CanvasReloadResult } from "./registry.js";
|
|
20
21
|
import type { CanvasCallOptions } from "./runner.js";
|
|
21
22
|
/** One canvas a person could open, or has open. */
|
|
22
23
|
export interface CanvasListing {
|
|
@@ -37,6 +38,15 @@ export interface CanvasOverview {
|
|
|
37
38
|
listings: CanvasListing[];
|
|
38
39
|
/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */
|
|
39
40
|
withheldCount: number;
|
|
41
|
+
/**
|
|
42
|
+
* Action names per open instance.
|
|
43
|
+
*
|
|
44
|
+
* Beside the listings rather than inside them because an action belongs to a
|
|
45
|
+
* running instance, not to a canvas on disk: a listing exists for extensions
|
|
46
|
+
* that have never been forked, and those have no actions to report — not zero
|
|
47
|
+
* of them, none knowable.
|
|
48
|
+
*/
|
|
49
|
+
actionsByInstance: Map<string, string[]>;
|
|
40
50
|
}
|
|
41
51
|
/** Configuration for a session's canvas facade. */
|
|
42
52
|
export interface CanvasSessionOptions extends CanvasRegistryEvents {
|
|
@@ -100,6 +110,34 @@ export declare class CanvasSession {
|
|
|
100
110
|
open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance>;
|
|
101
111
|
/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */
|
|
102
112
|
close(instanceId: string): Promise<CanvasInstance | undefined>;
|
|
113
|
+
/**
|
|
114
|
+
* Re-fork an open extension so an edit to its code takes effect.
|
|
115
|
+
*
|
|
116
|
+
* Reached by extension id rather than instance id because a reload restarts the
|
|
117
|
+
* *process*, and one child serves every instance of every canvas the extension
|
|
118
|
+
* declares — pretending it could reload one instance would be a lie about what
|
|
119
|
+
* happens. {@link CanvasRegistry.reload} carries the open instances across.
|
|
120
|
+
*/
|
|
121
|
+
reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult>;
|
|
122
|
+
/**
|
|
123
|
+
* Rename a canvas extension, closing anything it has open first.
|
|
124
|
+
*
|
|
125
|
+
* Closing is not politeness: the directory is about to move, and an instance
|
|
126
|
+
* left open would be serving from a path that no longer exists while the
|
|
127
|
+
* registry still believed it was there. The closed instance ids are returned so
|
|
128
|
+
* the caller can say what it cost.
|
|
129
|
+
*/
|
|
130
|
+
rename(extensionId: string, to: string): Promise<CanvasRenameResult | CanvasLifecycleRefusal>;
|
|
131
|
+
/** Delete a canvas extension, closing anything it has open first. */
|
|
132
|
+
remove(extensionId: string): Promise<CanvasRemoveResult | CanvasLifecycleRefusal>;
|
|
133
|
+
private closeAllOf;
|
|
134
|
+
/** The discovered extension with this id, runnable or withheld. */
|
|
135
|
+
private find;
|
|
136
|
+
private notFound;
|
|
137
|
+
/** Everything that could be renamed or removed, for completions and messages. */
|
|
138
|
+
knownExtensionIds(): string[];
|
|
139
|
+
/** The extension ids with at least one open instance — what {@link reload} accepts. */
|
|
140
|
+
runningExtensionIds(): string[];
|
|
103
141
|
/** Every open instance. */
|
|
104
142
|
instances(): CanvasInstance[];
|
|
105
143
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport { pluginCanvasExtensions } from \"./plugin-canvases.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/**\n\t * Override how plugin-shipped canvases are found; defaults to\n\t * {@link pluginCanvasExtensions}. Pass `() => []` to look at the search roots\n\t * and nothing else.\n\t */\n\tpluginExtensions?: () => DiscoveredCanvasExtension[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\tprivate readonly pluginExtensions: () => DiscoveredCanvasExtension[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t\t// Re-read on every discover rather than cached: /plugin install can add a\n\t\t// canvas mid-session, and a listing that cannot see it is the bug this\n\t\t// exists to fix.\n\t\tthis.pluginExtensions =\n\t\t\toptions.pluginExtensions ?? (() => pluginCanvasExtensions(options.cwd, options.agentDir ?? getAgentDir()));\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots, this.pluginExtensions()),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport {\n\ttype CanvasLifecycleRefusal,\n\ttype CanvasRemoveResult,\n\ttype CanvasRenameResult,\n\tremoveCanvasExtension,\n\trenameCanvasExtension,\n} from \"./lifecycle.js\";\nimport { pluginCanvasExtensions } from \"./plugin-canvases.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents, type CanvasReloadResult } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n\t/**\n\t * Action names per open instance.\n\t *\n\t * Beside the listings rather than inside them because an action belongs to a\n\t * running instance, not to a canvas on disk: a listing exists for extensions\n\t * that have never been forked, and those have no actions to report — not zero\n\t * of them, none knowable.\n\t */\n\tactionsByInstance: Map<string, string[]>;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/**\n\t * Override how plugin-shipped canvases are found; defaults to\n\t * {@link pluginCanvasExtensions}. Pass `() => []` to look at the search roots\n\t * and nothing else.\n\t */\n\tpluginExtensions?: () => DiscoveredCanvasExtension[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\tprivate readonly pluginExtensions: () => DiscoveredCanvasExtension[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t\t// Re-read on every discover rather than cached: /plugin install can add a\n\t\t// canvas mid-session, and a listing that cannot see it is the bug this\n\t\t// exists to fix.\n\t\tthis.pluginExtensions =\n\t\t\toptions.pluginExtensions ?? (() => pluginCanvasExtensions(options.cwd, options.agentDir ?? getAgentDir()));\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots, this.pluginExtensions()),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\tconst actionsByInstance = new Map<string, string[]>();\n\t\tfor (const binding of this.registryOrUndefined()?.activeActions() ?? []) {\n\t\t\tconst names = actionsByInstance.get(binding.instanceId) ?? [];\n\t\t\tnames.push(binding.action.name);\n\t\t\tactionsByInstance.set(binding.instanceId, names);\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length, actionsByInstance };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/**\n\t * Re-fork an open extension so an edit to its code takes effect.\n\t *\n\t * Reached by extension id rather than instance id because a reload restarts the\n\t * *process*, and one child serves every instance of every canvas the extension\n\t * declares — pretending it could reload one instance would be a lie about what\n\t * happens. {@link CanvasRegistry.reload} carries the open instances across.\n\t */\n\tasync reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tif (!registry) {\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\t\treturn registry.reload(extensionId, options);\n\t}\n\n\t/**\n\t * Rename a canvas extension, closing anything it has open first.\n\t *\n\t * Closing is not politeness: the directory is about to move, and an instance\n\t * left open would be serving from a path that no longer exists while the\n\t * registry still believed it was there. The closed instance ids are returned so\n\t * the caller can say what it cost.\n\t */\n\tasync rename(extensionId: string, to: string): Promise<CanvasRenameResult | CanvasLifecycleRefusal> {\n\t\tconst extension = this.find(extensionId);\n\t\tif (!extension) return { reason: \"unwritable\", detail: this.notFound(extensionId) };\n\t\tawait this.closeAllOf(extensionId);\n\t\treturn renameCanvasExtension(extension, to, this.roots);\n\t}\n\n\t/** Delete a canvas extension, closing anything it has open first. */\n\tasync remove(extensionId: string): Promise<CanvasRemoveResult | CanvasLifecycleRefusal> {\n\t\tconst extension = this.find(extensionId);\n\t\tif (!extension) return { reason: \"unwritable\", detail: this.notFound(extensionId) };\n\t\tawait this.closeAllOf(extensionId);\n\t\treturn removeCanvasExtension(extension, this.roots);\n\t}\n\n\t/**\n\t * Close every instance of an extension and stop its child.\n\t *\n\t * `close` alone would leave the process alive for its linger period, still\n\t * holding the code we are about to move or delete. Rename and remove both need\n\t * it actually gone.\n\t */\n\tprivate async closeAllOf(extensionId: string): Promise<string[]> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tif (!registry) return [];\n\t\tconst open = registry.listInstances().filter((instance) => instance.extensionId === extensionId);\n\t\tfor (const instance of open) await registry.close(instance);\n\t\tawait registry.stopChild(extensionId);\n\t\treturn open.map((instance) => instance.instanceId);\n\t}\n\n\t/** The discovered extension with this id, runnable or withheld. */\n\tprivate find(extensionId: string): DiscoveredCanvasExtension | undefined {\n\t\tconst { runnable, withheld } = this.discover();\n\t\treturn [...runnable, ...withheld].find((candidate) => candidate.id === extensionId);\n\t}\n\n\tprivate notFound(extensionId: string): string {\n\t\tconst known =\n\t\t\tthis.discover()\n\t\t\t\t.runnable.map((candidate) => candidate.id)\n\t\t\t\t.join(\", \") || \"none\";\n\t\treturn `No canvas extension \"${extensionId}\" (found: ${known}).`;\n\t}\n\n\t/** Everything that could be renamed or removed, for completions and messages. */\n\tknownExtensionIds(): string[] {\n\t\tconst { runnable, withheld } = this.discover();\n\t\treturn [...runnable, ...withheld].map((candidate) => candidate.id).sort();\n\t}\n\n\t/** The extension ids with at least one open instance — what {@link reload} accepts. */\n\trunningExtensionIds(): string[] {\n\t\treturn [...new Set(this.instances().map((instance) => instance.extensionId))];\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
|