@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/core/canvas/lifecycle.d.ts +93 -0
  3. package/dist/core/canvas/lifecycle.d.ts.map +1 -0
  4. package/dist/core/canvas/lifecycle.js +165 -0
  5. package/dist/core/canvas/lifecycle.js.map +1 -0
  6. package/dist/core/canvas/registry.d.ts +89 -0
  7. package/dist/core/canvas/registry.d.ts.map +1 -1
  8. package/dist/core/canvas/registry.js +205 -10
  9. package/dist/core/canvas/registry.js.map +1 -1
  10. package/dist/core/canvas/scaffold.d.ts +123 -0
  11. package/dist/core/canvas/scaffold.d.ts.map +1 -0
  12. package/dist/core/canvas/scaffold.js +376 -0
  13. package/dist/core/canvas/scaffold.js.map +1 -0
  14. package/dist/core/canvas/session.d.ts +39 -1
  15. package/dist/core/canvas/session.d.ts.map +1 -1
  16. package/dist/core/canvas/session.js +83 -1
  17. package/dist/core/canvas/session.js.map +1 -1
  18. package/dist/core/tools/canvas.d.ts +23 -3
  19. package/dist/core/tools/canvas.d.ts.map +1 -1
  20. package/dist/core/tools/canvas.js +99 -4
  21. package/dist/core/tools/canvas.js.map +1 -1
  22. package/dist/extensions/core/canvas.d.ts +20 -2
  23. package/dist/extensions/core/canvas.d.ts.map +1 -1
  24. package/dist/extensions/core/canvas.js +279 -36
  25. package/dist/extensions/core/canvas.js.map +1 -1
  26. package/dist/extensions/core/scaffold.d.ts +7 -1
  27. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  28. package/dist/extensions/core/scaffold.js +7 -185
  29. package/dist/extensions/core/scaffold.js.map +1 -1
  30. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  31. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  32. package/examples/extensions/sandbox/package.json +1 -1
  33. package/examples/extensions/with-deps/package.json +1 -1
  34. package/package.json +4 -4
@@ -35,6 +35,40 @@ export const CANVAS_INSTANCE_IDLE_MS = 30 * 60 * 1_000;
35
35
  export const CANVAS_CHILD_LINGER_MS = 60 * 1_000;
36
36
  /** Default cap on concurrent instances of a single canvas. */
37
37
  export const CANVAS_MAX_INSTANCES_PER_CANVAS = 8;
38
+ /**
39
+ * Index an extension's actions by `canvasId.actionName`, against a stable
40
+ * serialization of the declaration.
41
+ *
42
+ * `JSON.stringify` of the whole declaration is the comparison, which makes it
43
+ * sensitive to key order — but both sides come from the same code path in the
44
+ * same shim, so a reordering here means the author reordered the source, and
45
+ * reporting that as "changed" is closer to true than missing a reshaped schema.
46
+ */
47
+ function indexActions(declarations) {
48
+ const index = new Map();
49
+ for (const [canvasId, declaration] of declarations) {
50
+ for (const action of declaration.actions ?? []) {
51
+ index.set(`${canvasId}.${action.name}`, JSON.stringify(action));
52
+ }
53
+ }
54
+ return index;
55
+ }
56
+ /** Compare two action inventories. */
57
+ function diffActions(before, after) {
58
+ const added = [];
59
+ const removed = [];
60
+ const changed = [];
61
+ for (const [key, shape] of after) {
62
+ if (!before.has(key))
63
+ added.push(key);
64
+ else if (before.get(key) !== shape)
65
+ changed.push(key);
66
+ }
67
+ for (const key of before.keys())
68
+ if (!after.has(key))
69
+ removed.push(key);
70
+ return { added: added.sort(), removed: removed.sort(), changed: changed.sort(), current: [...after.keys()].sort() };
71
+ }
38
72
  /** Render a key as a stable string, for maps and messages. */
39
73
  export function canvasInstanceKeyOf(key) {
40
74
  return `${key.extensionId}::${key.canvasId}::${key.instanceId}`;
@@ -84,6 +118,7 @@ export class CanvasRegistry {
84
118
  title: result?.title,
85
119
  status: result?.status,
86
120
  lastTouchedAt: this.now(),
121
+ openInput: input,
87
122
  };
88
123
  this.instances.set(canvasInstanceKeyOf(instance), instance);
89
124
  child.idleSince = undefined;
@@ -132,6 +167,134 @@ export class CanvasRegistry {
132
167
  if (this.instancesOf(key.extensionId).length === 0)
133
168
  child.idleSince = this.now();
134
169
  }
170
+ /**
171
+ * Re-fork a running extension from disk and put its open instances back.
172
+ *
173
+ * This is what makes a canvas *iterable*. A canvas has no passive half — its
174
+ * id, its actions and its UI all come from running its code — so editing
175
+ * `extension.mjs` changes nothing at all while the child that was forked from
176
+ * the old bytes is still serving: not the open page, and not even a freshly
177
+ * opened second instance, because {@link child} hands back the child already in
178
+ * the table. Without a reload the only way to see an edit is to end the session.
179
+ *
180
+ * The order is deliberate. The new child is forked and asked for its
181
+ * declarations **before** the old one is touched, so an edit that does not run —
182
+ * a syntax error, a throw at module scope, a `joinSession` that never resolves —
183
+ * leaves the person looking at exactly the canvas they had, and the error is
184
+ * reported instead of being paid for with their open surface.
185
+ *
186
+ * Instance ids are preserved, so an `instanceId` the model already holds keeps
187
+ * working across a reload. **URLs are not**: the extension binds a fresh
188
+ * ephemeral port and mints a fresh capability token in `open()`, and the host
189
+ * has no way to make it reuse either. So a reload always hands back new URLs,
190
+ * and the caller must show them — an already-open browser tab is pointing at a
191
+ * port that is now closed.
192
+ *
193
+ * The `input` each instance was opened with is replayed, so a reload restores
194
+ * the canvas rather than a blank one. Everything the *extension* kept in memory
195
+ * is gone, which is the honest meaning of restarting a process.
196
+ */
197
+ async reload(extensionId, options) {
198
+ const previous = this.children.get(extensionId);
199
+ if (!previous) {
200
+ throw new Error(`Canvas extension "${extensionId}" is not running, so there is nothing to reload. Open it first.`);
201
+ }
202
+ // Snapshot before anything moves: `close` mutates the instance table, and the
203
+ // old child's declarations go with it when it is terminated.
204
+ const carried = this.instancesOf(extensionId);
205
+ const actionsBefore = indexActions(previous.declarations);
206
+ // Fork the edited code first. If it does not come up, the old child is still
207
+ // registered and still serving, and this throws without costing anything.
208
+ const next = await this.spawn(previous.extension);
209
+ // The swap. Registering the new child before stopping the old one is what
210
+ // makes the old one's `onExit` a no-op rather than a table-clearing race.
211
+ this.children.set(extensionId, next);
212
+ for (const instance of carried)
213
+ this.instances.delete(canvasInstanceKeyOf(instance));
214
+ for (const instance of carried) {
215
+ try {
216
+ await previous.process.close({
217
+ sessionId: extensionId,
218
+ extensionId,
219
+ canvasId: instance.canvasId,
220
+ instanceId: instance.instanceId,
221
+ });
222
+ }
223
+ catch {
224
+ // The old child is about to be killed, so a refused close costs nothing:
225
+ // its ports go with the process. Reporting it would be noise on a path
226
+ // the person asked for.
227
+ }
228
+ }
229
+ await previous.process.terminate();
230
+ const reopened = [];
231
+ const dropped = [];
232
+ for (const instance of carried) {
233
+ // The edit may have renamed or removed the canvas. That is a legitimate
234
+ // thing for an author to do mid-iteration, so it is reported rather than
235
+ // thrown — the other instances still come back.
236
+ if (!next.declarations.has(instance.canvasId)) {
237
+ dropped.push({
238
+ instanceId: instance.instanceId,
239
+ canvasId: instance.canvasId,
240
+ reason: `the reloaded extension no longer declares canvas "${instance.canvasId}" (declares: ${[...next.declarations.keys()].join(", ") || "none"})`,
241
+ });
242
+ continue;
243
+ }
244
+ try {
245
+ const result = (await next.process.open({
246
+ sessionId: extensionId,
247
+ extensionId,
248
+ canvasId: instance.canvasId,
249
+ instanceId: instance.instanceId,
250
+ input: instance.openInput,
251
+ }, options));
252
+ const fresh = {
253
+ ...instance,
254
+ url: result?.url,
255
+ title: result?.title,
256
+ status: result?.status,
257
+ lastTouchedAt: this.now(),
258
+ };
259
+ this.instances.set(canvasInstanceKeyOf(fresh), fresh);
260
+ reopened.push(fresh);
261
+ }
262
+ catch (cause) {
263
+ await this.abandon(extensionId, instance.canvasId, instance.instanceId);
264
+ dropped.push({
265
+ instanceId: instance.instanceId,
266
+ canvasId: instance.canvasId,
267
+ reason: cause instanceof Error ? cause.message : String(cause),
268
+ });
269
+ }
270
+ }
271
+ next.idleSince = reopened.length === 0 ? this.now() : undefined;
272
+ return {
273
+ extensionId,
274
+ reopened,
275
+ dropped,
276
+ canvases: [...next.declarations.keys()],
277
+ actions: diffActions(actionsBefore, indexActions(next.declarations)),
278
+ };
279
+ }
280
+ /**
281
+ * Stop an extension's child now, rather than at the end of its linger period.
282
+ *
283
+ * The reaper's grace period is right for an extension nobody is using and wrong
284
+ * for one whose directory is about to be moved or deleted: a child outliving
285
+ * its own source is the most confusing state a canvas can be in, because it
286
+ * keeps serving code that is no longer anywhere on disk.
287
+ */
288
+ async stopChild(extensionId) {
289
+ const child = this.children.get(extensionId);
290
+ if (!child)
291
+ return false;
292
+ for (const instance of this.instancesOf(extensionId))
293
+ await this.close(instance);
294
+ this.children.delete(extensionId);
295
+ await child.process.terminate();
296
+ return true;
297
+ }
135
298
  /** Every open instance. */
136
299
  listInstances() {
137
300
  return [...this.instances.values()];
@@ -228,6 +391,25 @@ export class CanvasRegistry {
228
391
  return this.listInstances().filter((instance) => instance.extensionId === extensionId);
229
392
  }
230
393
  async child(extension) {
394
+ const existing = this.children.get(extension.id);
395
+ if (existing?.process.running)
396
+ return existing;
397
+ if (existing)
398
+ this.children.delete(extension.id);
399
+ const entry = await this.spawn(extension);
400
+ this.children.set(extension.id, entry);
401
+ return entry;
402
+ }
403
+ /**
404
+ * Fork one extension and wait for its declarations, without registering it.
405
+ *
406
+ * Separate from {@link child} because {@link reload} needs to fork a *second*
407
+ * child while the first is still serving the person's open canvas: if the edit
408
+ * that prompted the reload does not run, the old child is still there and
409
+ * nothing was lost. Registering is therefore the caller's step, taken only once
410
+ * the new child has answered.
411
+ */
412
+ async spawn(extension) {
231
413
  // The single choke point: every path that could start a process comes through
232
414
  // here, so the gate is enforced once and cannot be bypassed by a caller that
233
415
  // forgot to filter. Callers should still filter with `gateCanvasExtensions`
@@ -235,11 +417,7 @@ export class CanvasRegistry {
235
417
  if (shouldWithholdCanvas(extension, this.options.cwd, this.options.agentDir)) {
236
418
  throw new CanvasTrustError(extension.id, this.options.cwd);
237
419
  }
238
- const existing = this.children.get(extension.id);
239
- if (existing?.process.running)
240
- return existing;
241
- if (existing)
242
- this.children.delete(extension.id);
420
+ let spawned;
243
421
  const process = spawnCanvasExtension({
244
422
  extensionId: extension.id,
245
423
  entry: extension.entry,
@@ -249,19 +427,36 @@ export class CanvasRegistry {
249
427
  onLog: (message, level) => this.options.onLog?.(extension.id, message, level),
250
428
  onStray: (line) => this.options.onStray?.(extension.id, line),
251
429
  onStderr: (chunk) => this.options.onStderr?.(extension.id, chunk),
252
- onExit: () => this.forget(extension.id),
430
+ // Only the child that is *currently registered* may clear the table. A
431
+ // reload's probe dying before it is adopted must not take the live child's
432
+ // instances with it, and the old child's own exit — which reload causes on
433
+ // purpose, after the new one is registered — must not undo the swap.
434
+ onExit: () => {
435
+ const registered = this.children.get(extension.id);
436
+ if (spawned && registered?.process === spawned)
437
+ this.forget(extension.id);
438
+ },
253
439
  });
254
- const ready = await process.ready;
440
+ spawned = process;
441
+ let ready;
442
+ try {
443
+ ready = await process.ready;
444
+ }
445
+ catch (cause) {
446
+ // `ready` rejects when the child exits first, but a child that answered
447
+ // nothing and stayed up would otherwise be orphaned by the throw.
448
+ await process.terminate();
449
+ throw cause;
450
+ }
255
451
  if (ready.unsupported && ready.unsupported.length > 0) {
256
452
  this.options.onDiagnostic?.(extension.id, `Canvas extension "${extension.id}" declares ${ready.unsupported.join(", ")}, which hoocode does not support; those surfaces are ignored.`);
257
453
  }
258
- const entry = {
454
+ return {
259
455
  process,
260
456
  declarations: new Map(ready.canvases.map((declaration) => [declaration.id, declaration])),
261
457
  idleSince: this.now(),
458
+ extension,
262
459
  };
263
- this.children.set(extension.id, entry);
264
- return entry;
265
460
  }
266
461
  /** Drop a dead child and its instances, so a crash cannot leave stale entries. */
267
462
  forget(extensionId) {
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","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.js","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"]}
@@ -0,0 +1,123 @@
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 type { MarketplacePlatform } from "../extensions/plugins/formats/types.js";
16
+ /**
17
+ * Where a canvas extension lives, per platform.
18
+ *
19
+ * This does not go through `WorkspaceLayout` like the other scaffolds, and the
20
+ * reason is that a canvas has no Claude convention to emit into: the surface
21
+ * exists in Copilot and in hoocode's own `.agents/` tree and nowhere else. A
22
+ * layout method returning nothing for one adapter would be a worse lie than
23
+ * naming the two real homes here — these are exactly the roots
24
+ * `core/canvas/discovery.ts` searches, which is what makes a scaffold live on the
25
+ * next `/canvas`.
26
+ */
27
+ export declare const CANVAS_HOMES: Partial<Record<MarketplacePlatform, string[]>>;
28
+ /** Validates a canvas name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */
29
+ export declare function validateCanvasName(name: string): string | null;
30
+ /**
31
+ * Turn a sentence into a directory name.
32
+ *
33
+ * Returns undefined when nothing usable survives — an all-punctuation request, or
34
+ * a sentence of nothing but filler — because inventing `canvas-1` would hide from
35
+ * the person that we did not understand them.
36
+ */
37
+ export declare function canvasNameFromDescription(description: string): string | undefined;
38
+ /** What a person asked `/new-canvas` for. */
39
+ export interface CanvasRequest {
40
+ /** Directory and default canvas id. */
41
+ name: string;
42
+ /**
43
+ * What they want it to do, in their words, or undefined when they only named
44
+ * one. Present means the model is expected to build it (Copilot's
45
+ * `/create-canvas` shape); absent means they want the template to edit by hand.
46
+ */
47
+ description: string | undefined;
48
+ }
49
+ /**
50
+ * Parse `/new-canvas`'s argument.
51
+ *
52
+ * Three shapes, in the order they are tested:
53
+ *
54
+ * - `my-board` — a bare name. Unchanged from before descriptions existed, and
55
+ * tested first so it can never be re-read as a one-word description.
56
+ * - `my-board: a kanban board for the release checklist` — both, when someone
57
+ * cares what the directory is called. A colon rather than a flag because the
58
+ * rest of the line is prose and a flag parser would have to guess where it ends.
59
+ * - `a kanban board for the release checklist` — a description, the shape
60
+ * `/create-canvas` uses. The name is derived and reported.
61
+ *
62
+ * Returns a string when the input cannot become a canvas; the caller shows it.
63
+ */
64
+ export declare function parseCanvasRequest(input: string): CanvasRequest | string;
65
+ /**
66
+ * A working single-canvas extension.
67
+ *
68
+ * Deliberately complete rather than a stub: a canvas has no passive half — its
69
+ * name, its actions and its UI all come from running its code — so a scaffold
70
+ * that does not run teaches nothing and cannot be checked with `/canvas open`.
71
+ * This one opens, serves a page, answers an action, and closes cleanly, which is
72
+ * the whole contract; everything past that is the author's.
73
+ *
74
+ * Shaped like the catalog extensions hoocode already hosts: the only import is
75
+ * `@github/copilot-sdk/extension` (host-resolved — never installed, see
76
+ * `docs/canvas-extensions-design.md` §4.1) plus `node:` builtins, and the UI is
77
+ * served from an ephemeral loopback port behind a per-instance token so nothing
78
+ * else on the machine can read it.
79
+ */
80
+ export declare const CANVAS_ENTRY_TEMPLATE: (name: string) => string;
81
+ /** What {@link scaffoldCanvas} wrote. */
82
+ export interface CanvasScaffoldResult {
83
+ /** Workspace-relative entry files created, one per platform target. */
84
+ created: string[];
85
+ /** Workspace-relative entry files that already existed and were left alone. */
86
+ skipped: string[];
87
+ }
88
+ /**
89
+ * Write the template into every platform target that has a canvas home.
90
+ *
91
+ * Existing files are never clobbered — they are reported and skipped, so running
92
+ * `/new-canvas` twice on a canvas you have been editing cannot lose it.
93
+ */
94
+ export declare function scaffoldCanvas(cwd: string, name: string, platforms: MarketplacePlatform[]): CanvasScaffoldResult;
95
+ /** Where a build brief's canvas is, if opening it worked. */
96
+ export interface CanvasBriefTarget {
97
+ instanceId: string;
98
+ url: string | undefined;
99
+ }
100
+ /**
101
+ * The message the model builds from — hoocode's half of `/create-canvas`.
102
+ *
103
+ * A scaffold plus a sentence is not a canvas, and the gap between them is the
104
+ * agent's work. This is what turns "a kanban board for the release checklist"
105
+ * into a build task with the contract attached, so the model does not have to
106
+ * infer the rules of a surface it cannot see from a template it has not read.
107
+ *
108
+ * Four of those rules are stated because getting them wrong fails in ways whose
109
+ * symptom does not name the cause: an installed dependency (the resolver already
110
+ * provides the SDK, and a `node_modules` here is a §4.1 violation), a
111
+ * `console.log` (corrupts the JSON-RPC channel and surfaces as "non-protocol
112
+ * stdout"), a write without a reload (changes nothing at all, because the running
113
+ * child was forked from the old bytes), and a renamed canvas id.
114
+ *
115
+ * That last one is the newest and was found the hard way, by building a canvas
116
+ * with this command: the scaffold names the canvas after the directory, a model
117
+ * that thinks of a better name renames the `id`, and the next reload drops the
118
+ * instance the person is looking at — correctly, since the canvas it was opened
119
+ * against no longer exists. `displayName` is the half they actually see, so it
120
+ * is the half to change.
121
+ */
122
+ export declare function canvasBuildBrief(name: string, description: string, entryPath: string, target: CanvasBriefTarget | undefined): string;
123
+ //# sourceMappingURL=scaffold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/scaffold.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wCAAwC,CAAC;AAGlF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,EAAE,CAAC,CAGvE,CAAC;AAEF,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM9D;AA+GD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsBjF;AAED,6CAA6C;AAC7C,MAAM,WAAW,aAAa;IAC7B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAkBxE;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,0BAkFjC,CAAC;AAEF,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,uEAAuE;IACvE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,GAAG,oBAAoB,CAiBhH;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC/B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,iBAAiB,GAAG,SAAS,GACnC,MAAM,CAsCR","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"]}