@kolisachint/hoocode-agent 0.5.21 → 0.5.23
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 +60 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-log.d.ts +44 -0
- package/dist/core/agent-log.d.ts.map +1 -0
- package/dist/core/agent-log.js +74 -0
- package/dist/core/agent-log.js.map +1 -0
- package/dist/core/canvas/discovery.d.ts +60 -0
- package/dist/core/canvas/discovery.d.ts.map +1 -0
- package/dist/core/canvas/discovery.js +83 -0
- package/dist/core/canvas/discovery.js.map +1 -0
- package/dist/core/canvas/launch.d.ts +90 -0
- package/dist/core/canvas/launch.d.ts.map +1 -0
- package/dist/core/canvas/launch.js +176 -0
- package/dist/core/canvas/launch.js.map +1 -0
- package/dist/core/canvas/protocol.d.ts +229 -0
- package/dist/core/canvas/protocol.d.ts.map +1 -0
- package/dist/core/canvas/protocol.js +129 -0
- package/dist/core/canvas/protocol.js.map +1 -0
- package/dist/core/canvas/registry.d.ts +139 -0
- package/dist/core/canvas/registry.d.ts.map +1 -0
- package/dist/core/canvas/registry.js +275 -0
- package/dist/core/canvas/registry.js.map +1 -0
- package/dist/core/canvas/resolver.d.ts +26 -0
- package/dist/core/canvas/resolver.d.ts.map +1 -0
- package/dist/core/canvas/resolver.js +57 -0
- package/dist/core/canvas/resolver.js.map +1 -0
- package/dist/core/canvas/runner.d.ts +101 -0
- package/dist/core/canvas/runner.d.ts.map +1 -0
- package/dist/core/canvas/runner.js +196 -0
- package/dist/core/canvas/runner.js.map +1 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.d.ts +23 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.d.ts.map +1 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.js +36 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.js.map +1 -0
- package/dist/core/canvas/sdk-shim/index.d.ts +113 -0
- package/dist/core/canvas/sdk-shim/index.d.ts.map +1 -0
- package/dist/core/canvas/sdk-shim/index.js +184 -0
- package/dist/core/canvas/sdk-shim/index.js.map +1 -0
- package/dist/core/canvas/session.d.ts +112 -0
- package/dist/core/canvas/session.d.ts.map +1 -0
- package/dist/core/canvas/session.js +197 -0
- package/dist/core/canvas/session.js.map +1 -0
- package/dist/core/canvas/trust.d.ts +71 -0
- package/dist/core/canvas/trust.d.ts.map +1 -0
- package/dist/core/canvas/trust.js +88 -0
- package/dist/core/canvas/trust.js.map +1 -0
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +7 -10
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/plugins/trust.d.ts +21 -0
- package/dist/core/extensions/plugins/trust.d.ts.map +1 -1
- package/dist/core/extensions/plugins/trust.js +26 -0
- package/dist/core/extensions/plugins/trust.js.map +1 -1
- package/dist/core/lifeguard.d.ts.map +1 -1
- package/dist/core/lifeguard.js +2 -1
- package/dist/core/lifeguard.js.map +1 -1
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +6 -4
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/tools/canvas.d.ts +55 -0
- package/dist/core/tools/canvas.d.ts.map +1 -0
- package/dist/core/tools/canvas.js +159 -0
- package/dist/core/tools/canvas.js.map +1 -0
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +3 -2
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/extensions/core/canvas.d.ts +20 -0
- package/dist/extensions/core/canvas.d.ts.map +1 -0
- package/dist/extensions/core/canvas.js +192 -0
- package/dist/extensions/core/canvas.js.map +1 -0
- package/dist/extensions/core/hoo-core.d.ts +1 -0
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +3 -0
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/paths.d.ts +9 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +16 -0
- package/dist/utils/paths.js.map +1 -1
- package/docs/routing.md +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 +5 -4
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas instance registry — owns children, instances, and reaping.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §4, §6, §7. One child process per
|
|
5
|
+
* extension, many instances per child, keyed by `(extensionId, canvasId,
|
|
6
|
+
* instanceId)` because `joinSession({ canvases: [...] })` takes an array and each
|
|
7
|
+
* canvas can be opened more than once.
|
|
8
|
+
*
|
|
9
|
+
* **Correction to the design doc's §6.** That section called for an "SSE-liveness
|
|
10
|
+
* heartbeat — an instance with no connected client for N seconds is idle". That is
|
|
11
|
+
* not implementable. The SSE endpoint and its client set live inside the
|
|
12
|
+
* extension's own HTTP server (`entry.sseClients` in `pr-artifact-explorer`'s
|
|
13
|
+
* `server.mjs`); the host never sees them. Learning otherwise would take either
|
|
14
|
+
* proxying the canvas URL — which breaks the token, origin and CSP model the
|
|
15
|
+
* extension built — or adding a liveness call to the contract, which breaks tier-2
|
|
16
|
+
* portability. Neither is worth it for a reaper.
|
|
17
|
+
*
|
|
18
|
+
* So idleness here means something narrower and honest: **time since hoocode last
|
|
19
|
+
* touched the instance** (opened it, or invoked an action on it). A person reading
|
|
20
|
+
* a canvas in a browser tab is invisible to us, so a generous timeout is the point
|
|
21
|
+
* rather than a limitation, and `reapIdle` is advisory cleanup — not a claim about
|
|
22
|
+
* whether anybody is watching.
|
|
23
|
+
*
|
|
24
|
+
* The registry starts no timers. `reapIdle()` is driven by the caller and `now` is
|
|
25
|
+
* injectable, so lifetime policy belongs to whoever owns the session clock and the
|
|
26
|
+
* tests do not sleep.
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { spawnCanvasExtension, } from "./runner.js";
|
|
31
|
+
import { CanvasTrustError, shouldWithholdCanvas } from "./trust.js";
|
|
32
|
+
/** Default idle ceiling before an untouched instance is reaped. */
|
|
33
|
+
export const CANVAS_INSTANCE_IDLE_MS = 30 * 60 * 1_000;
|
|
34
|
+
/** Default grace period a child is kept alive after its last instance closes. */
|
|
35
|
+
export const CANVAS_CHILD_LINGER_MS = 60 * 1_000;
|
|
36
|
+
/** Default cap on concurrent instances of a single canvas. */
|
|
37
|
+
export const CANVAS_MAX_INSTANCES_PER_CANVAS = 8;
|
|
38
|
+
/** Render a key as a stable string, for maps and messages. */
|
|
39
|
+
export function canvasInstanceKeyOf(key) {
|
|
40
|
+
return `${key.extensionId}::${key.canvasId}::${key.instanceId}`;
|
|
41
|
+
}
|
|
42
|
+
export class CanvasRegistry {
|
|
43
|
+
children = new Map();
|
|
44
|
+
instances = new Map();
|
|
45
|
+
options;
|
|
46
|
+
now;
|
|
47
|
+
newInstanceId;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.options = options;
|
|
50
|
+
this.now = options.now ?? Date.now;
|
|
51
|
+
this.newInstanceId = options.newInstanceId ?? randomUUID;
|
|
52
|
+
}
|
|
53
|
+
/** Canvases an extension declares, forking it if it is not already running. */
|
|
54
|
+
async declarations(extension) {
|
|
55
|
+
const child = await this.child(extension);
|
|
56
|
+
return [...child.declarations.values()];
|
|
57
|
+
}
|
|
58
|
+
/** Open a canvas instance and return what the host needs to render it. */
|
|
59
|
+
async open(extension, canvasId, input, options) {
|
|
60
|
+
const child = await this.child(extension);
|
|
61
|
+
if (!child.declarations.has(canvasId)) {
|
|
62
|
+
const known = [...child.declarations.keys()].join(", ") || "none";
|
|
63
|
+
throw new Error(`Extension "${extension.id}" declares no canvas "${canvasId}" (declares: ${known}).`);
|
|
64
|
+
}
|
|
65
|
+
const limit = this.options.maxInstancesPerCanvas ?? CANVAS_MAX_INSTANCES_PER_CANVAS;
|
|
66
|
+
const open = this.listInstances().filter((instance) => instance.extensionId === extension.id && instance.canvasId === canvasId);
|
|
67
|
+
if (open.length >= limit) {
|
|
68
|
+
throw new Error(`Canvas "${canvasId}" already has ${open.length} open instances (limit ${limit}).`);
|
|
69
|
+
}
|
|
70
|
+
const instanceId = this.newInstanceId();
|
|
71
|
+
let result;
|
|
72
|
+
try {
|
|
73
|
+
result = (await child.process.open({ sessionId: extension.id, extensionId: extension.id, canvasId, instanceId, input }, options));
|
|
74
|
+
}
|
|
75
|
+
catch (cause) {
|
|
76
|
+
await this.abandon(extension.id, canvasId, instanceId);
|
|
77
|
+
throw cause;
|
|
78
|
+
}
|
|
79
|
+
const instance = {
|
|
80
|
+
extensionId: extension.id,
|
|
81
|
+
canvasId,
|
|
82
|
+
instanceId,
|
|
83
|
+
url: result?.url,
|
|
84
|
+
title: result?.title,
|
|
85
|
+
status: result?.status,
|
|
86
|
+
lastTouchedAt: this.now(),
|
|
87
|
+
};
|
|
88
|
+
this.instances.set(canvasInstanceKeyOf(instance), instance);
|
|
89
|
+
child.idleSince = undefined;
|
|
90
|
+
return instance;
|
|
91
|
+
}
|
|
92
|
+
/** Invoke an action on an open instance. */
|
|
93
|
+
async invokeAction(key, actionName, input, options) {
|
|
94
|
+
const instance = this.instances.get(canvasInstanceKeyOf(key));
|
|
95
|
+
if (!instance)
|
|
96
|
+
throw new Error(`No open canvas instance ${canvasInstanceKeyOf(key)}.`);
|
|
97
|
+
const child = this.children.get(key.extensionId);
|
|
98
|
+
if (!child)
|
|
99
|
+
throw new Error(`Canvas extension "${key.extensionId}" is not running.`);
|
|
100
|
+
const result = await child.process.invokeAction({
|
|
101
|
+
sessionId: key.extensionId,
|
|
102
|
+
extensionId: key.extensionId,
|
|
103
|
+
canvasId: key.canvasId,
|
|
104
|
+
instanceId: key.instanceId,
|
|
105
|
+
actionName,
|
|
106
|
+
input,
|
|
107
|
+
}, options);
|
|
108
|
+
instance.lastTouchedAt = this.now();
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
/** Close one instance. Unknown keys are a no-op, so close is idempotent. */
|
|
112
|
+
async close(key) {
|
|
113
|
+
const id = canvasInstanceKeyOf(key);
|
|
114
|
+
if (!this.instances.delete(id))
|
|
115
|
+
return;
|
|
116
|
+
const child = this.children.get(key.extensionId);
|
|
117
|
+
if (!child)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
await child.process.close({
|
|
121
|
+
sessionId: key.extensionId,
|
|
122
|
+
extensionId: key.extensionId,
|
|
123
|
+
canvasId: key.canvasId,
|
|
124
|
+
instanceId: key.instanceId,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
// onClose is fire-and-forget in the SDK contract, so a failure here must not
|
|
129
|
+
// leave the instance half-closed in our table.
|
|
130
|
+
this.options.onDiagnostic?.(key.extensionId, `Closing canvas instance ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
131
|
+
}
|
|
132
|
+
if (this.instancesOf(key.extensionId).length === 0)
|
|
133
|
+
child.idleSince = this.now();
|
|
134
|
+
}
|
|
135
|
+
/** Every open instance. */
|
|
136
|
+
listInstances() {
|
|
137
|
+
return [...this.instances.values()];
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Actions currently invocable, one entry per open instance per declared action.
|
|
141
|
+
* Empty when nothing is open — which is the point: a canvas that is not open
|
|
142
|
+
* costs the prompt nothing (§7).
|
|
143
|
+
*/
|
|
144
|
+
activeActions() {
|
|
145
|
+
const bindings = [];
|
|
146
|
+
for (const instance of this.instances.values()) {
|
|
147
|
+
const declaration = this.children.get(instance.extensionId)?.declarations.get(instance.canvasId);
|
|
148
|
+
for (const action of declaration?.actions ?? []) {
|
|
149
|
+
bindings.push({
|
|
150
|
+
extensionId: instance.extensionId,
|
|
151
|
+
canvasId: instance.canvasId,
|
|
152
|
+
instanceId: instance.instanceId,
|
|
153
|
+
action,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return bindings;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Close instances hoocode has not touched within the idle timeout, then reap
|
|
161
|
+
* children that have had no instances for the linger period. Advisory cleanup:
|
|
162
|
+
* see the module header on what "idle" can and cannot mean here.
|
|
163
|
+
*
|
|
164
|
+
* @returns The instance keys that were closed.
|
|
165
|
+
*/
|
|
166
|
+
async reapIdle() {
|
|
167
|
+
const idleTimeout = this.options.idleTimeoutMs ?? CANVAS_INSTANCE_IDLE_MS;
|
|
168
|
+
const linger = this.options.childLingerMs ?? CANVAS_CHILD_LINGER_MS;
|
|
169
|
+
const now = this.now();
|
|
170
|
+
const expired = this.listInstances().filter((instance) => now - instance.lastTouchedAt >= idleTimeout);
|
|
171
|
+
for (const instance of expired)
|
|
172
|
+
await this.close(instance);
|
|
173
|
+
for (const [extensionId, child] of [...this.children.entries()]) {
|
|
174
|
+
const unused = this.instancesOf(extensionId).length === 0;
|
|
175
|
+
if (!unused)
|
|
176
|
+
continue;
|
|
177
|
+
const since = child.idleSince ?? now;
|
|
178
|
+
child.idleSince = since;
|
|
179
|
+
if (now - since >= linger) {
|
|
180
|
+
this.children.delete(extensionId);
|
|
181
|
+
await child.process.terminate();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return expired.map((instance) => canvasInstanceKeyOf(instance));
|
|
185
|
+
}
|
|
186
|
+
/** Close everything and terminate every child. Safe to call twice. */
|
|
187
|
+
async shutdown() {
|
|
188
|
+
for (const instance of this.listInstances())
|
|
189
|
+
await this.close(instance);
|
|
190
|
+
const children = [...this.children.values()];
|
|
191
|
+
this.children.clear();
|
|
192
|
+
await Promise.all(children.map((child) => child.process.terminate()));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reconcile an instance we asked to open but never saw open — because a person
|
|
196
|
+
* cancelled, or the call timed out. One path serves both.
|
|
197
|
+
*
|
|
198
|
+
* The provider protocol has no cancel verb, so the child may have finished opening
|
|
199
|
+
* and be holding a port. `canvas.close` is the only way to tell it to let go, and
|
|
200
|
+
* it can be sent because the instance id was generated before the open call.
|
|
201
|
+
*
|
|
202
|
+
* If the close itself goes unanswered the child is wedged, and the only remaining
|
|
203
|
+
* lever is terminating it — but that kills every instance of that extension, so it
|
|
204
|
+
* is done only when no other instance is live. When siblings exist the child is left
|
|
205
|
+
* alone and the leak is reported, rather than paid for by someone else's open canvas.
|
|
206
|
+
*/
|
|
207
|
+
async abandon(extensionId, canvasId, instanceId) {
|
|
208
|
+
const child = this.children.get(extensionId);
|
|
209
|
+
if (!child)
|
|
210
|
+
return;
|
|
211
|
+
try {
|
|
212
|
+
await child.process.close({ sessionId: extensionId, extensionId, canvasId, instanceId });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
217
|
+
if (this.instancesOf(extensionId).length > 0) {
|
|
218
|
+
this.options.onDiagnostic?.(extensionId, `Stopped opening canvas "${canvasId}" but the extension did not confirm the close (${detail}). ` +
|
|
219
|
+
"It has other canvases open, so it was left running; a port may stay bound until it exits.");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.children.delete(extensionId);
|
|
223
|
+
await child.process.terminate();
|
|
224
|
+
this.options.onDiagnostic?.(extensionId, `Stopped opening canvas "${canvasId}" and the extension did not confirm the close (${detail}); it was stopped.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
instancesOf(extensionId) {
|
|
228
|
+
return this.listInstances().filter((instance) => instance.extensionId === extensionId);
|
|
229
|
+
}
|
|
230
|
+
async child(extension) {
|
|
231
|
+
// The single choke point: every path that could start a process comes through
|
|
232
|
+
// here, so the gate is enforced once and cannot be bypassed by a caller that
|
|
233
|
+
// forgot to filter. Callers should still filter with `gateCanvasExtensions`
|
|
234
|
+
// so they can explain the refusal; this is the backstop, not the UI.
|
|
235
|
+
if (shouldWithholdCanvas(extension, this.options.cwd, this.options.agentDir)) {
|
|
236
|
+
throw new CanvasTrustError(extension.id, this.options.cwd);
|
|
237
|
+
}
|
|
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);
|
|
243
|
+
const process = spawnCanvasExtension({
|
|
244
|
+
extensionId: extension.id,
|
|
245
|
+
entry: extension.entry,
|
|
246
|
+
runtime: this.options.runtime,
|
|
247
|
+
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
248
|
+
cwd: path.dirname(extension.dir),
|
|
249
|
+
onLog: (message, level) => this.options.onLog?.(extension.id, message, level),
|
|
250
|
+
onStray: (line) => this.options.onStray?.(extension.id, line),
|
|
251
|
+
onStderr: (chunk) => this.options.onStderr?.(extension.id, chunk),
|
|
252
|
+
onExit: () => this.forget(extension.id),
|
|
253
|
+
});
|
|
254
|
+
const ready = await process.ready;
|
|
255
|
+
if (ready.unsupported && ready.unsupported.length > 0) {
|
|
256
|
+
this.options.onDiagnostic?.(extension.id, `Canvas extension "${extension.id}" declares ${ready.unsupported.join(", ")}, which hoocode does not support; those surfaces are ignored.`);
|
|
257
|
+
}
|
|
258
|
+
const entry = {
|
|
259
|
+
process,
|
|
260
|
+
declarations: new Map(ready.canvases.map((declaration) => [declaration.id, declaration])),
|
|
261
|
+
idleSince: this.now(),
|
|
262
|
+
};
|
|
263
|
+
this.children.set(extension.id, entry);
|
|
264
|
+
return entry;
|
|
265
|
+
}
|
|
266
|
+
/** Drop a dead child and its instances, so a crash cannot leave stale entries. */
|
|
267
|
+
forget(extensionId) {
|
|
268
|
+
this.children.delete(extensionId);
|
|
269
|
+
for (const [id, instance] of [...this.instances.entries()]) {
|
|
270
|
+
if (instance.extensionId === extensionId)
|
|
271
|
+
this.instances.delete(id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +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"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module resolution for a forked canvas extension.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §4.1. A canvas extension's only
|
|
5
|
+
* external import is `@github/copilot-sdk/extension`, and GitHub's own
|
|
6
|
+
* `docs/extensions.md` says that import "is resolved automatically — you don't
|
|
7
|
+
* install it". So hoocode has to make the specifier resolvable inside the child
|
|
8
|
+
* without writing anything into the extension directory: a catalog canvas must
|
|
9
|
+
* stay byte-identical to upstream (tier 1 of the design's ownership split).
|
|
10
|
+
*
|
|
11
|
+
* The mechanism is a Node module-resolution hook (`node:module`'s `register`)
|
|
12
|
+
* delivered entirely through `--import` **data: URLs**. Nothing is written to
|
|
13
|
+
* disk and no `.mjs` asset has to be copied into `dist/` or embedded in the
|
|
14
|
+
* packaged binary — the whole resolver is two generated modules that exist only
|
|
15
|
+
* as command-line arguments.
|
|
16
|
+
*/
|
|
17
|
+
/** The specifier a canvas extension imports, and the package it belongs to. */
|
|
18
|
+
export declare const CANVAS_SDK_PACKAGE = "@github/copilot-sdk";
|
|
19
|
+
/**
|
|
20
|
+
* Build the `--import` argument that makes `@github/copilot-sdk` resolve to
|
|
21
|
+
* `shimUrl` inside the child.
|
|
22
|
+
*
|
|
23
|
+
* @param shimUrl Absolute `file:` URL of the module implementing the SDK surface.
|
|
24
|
+
*/
|
|
25
|
+
export declare function canvasResolverImportArg(shimUrl: string): string;
|
|
26
|
+
//# sourceMappingURL=resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,kBAAkB,wBAAwB,CAAC;AAyBxD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAU/D","sourcesContent":["/**\n * Module resolution for a forked canvas extension.\n *\n * Design: `docs/canvas-extensions-design.md` §4.1. A canvas extension's only\n * external import is `@github/copilot-sdk/extension`, and GitHub's own\n * `docs/extensions.md` says that import \"is resolved automatically — you don't\n * install it\". So hoocode has to make the specifier resolvable inside the child\n * without writing anything into the extension directory: a catalog canvas must\n * stay byte-identical to upstream (tier 1 of the design's ownership split).\n *\n * The mechanism is a Node module-resolution hook (`node:module`'s `register`)\n * delivered entirely through `--import` **data: URLs**. Nothing is written to\n * disk and no `.mjs` asset has to be copied into `dist/` or embedded in the\n * packaged binary — the whole resolver is two generated modules that exist only\n * as command-line arguments.\n */\n\n/** The specifier a canvas extension imports, and the package it belongs to. */\nexport const CANVAS_SDK_PACKAGE = \"@github/copilot-sdk\";\n\n/**\n * Resolution hooks, as source. `initialize` receives the shim URL through\n * `register`'s `data` channel rather than the environment, because the hooks run\n * on a separate loader thread and the documented `data` path is the supported way\n * to parameterise them.\n */\nconst HOOKS_SOURCE = `\nlet shimUrl;\nexport function initialize(data) {\n\tshimUrl = data.shimUrl;\n}\nexport async function resolve(specifier, context, next) {\n\tif (specifier === ${JSON.stringify(CANVAS_SDK_PACKAGE)} || specifier.startsWith(${JSON.stringify(`${CANVAS_SDK_PACKAGE}/`)})) {\n\t\treturn { url: shimUrl, shortCircuit: true };\n\t}\n\treturn next(specifier, context);\n}\n`;\n\nfunction dataModule(source: string): string {\n\treturn `data:text/javascript,${encodeURIComponent(source)}`;\n}\n\n/**\n * Build the `--import` argument that makes `@github/copilot-sdk` resolve to\n * `shimUrl` inside the child.\n *\n * @param shimUrl Absolute `file:` URL of the module implementing the SDK surface.\n */\nexport function canvasResolverImportArg(shimUrl: string): string {\n\tif (!shimUrl.startsWith(\"file://\")) {\n\t\tthrow new Error(`Canvas shim URL must be an absolute file: URL, received \"${shimUrl}\".`);\n\t}\n\tconst hooksUrl = dataModule(HOOKS_SOURCE);\n\tconst registerSource = `\nimport { register } from \"node:module\";\nregister(${JSON.stringify(hooksUrl)}, { parentURL: import.meta.url, data: ${JSON.stringify({ shimUrl })} });\n`;\n\treturn dataModule(registerSource);\n}\n"]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module resolution for a forked canvas extension.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §4.1. A canvas extension's only
|
|
5
|
+
* external import is `@github/copilot-sdk/extension`, and GitHub's own
|
|
6
|
+
* `docs/extensions.md` says that import "is resolved automatically — you don't
|
|
7
|
+
* install it". So hoocode has to make the specifier resolvable inside the child
|
|
8
|
+
* without writing anything into the extension directory: a catalog canvas must
|
|
9
|
+
* stay byte-identical to upstream (tier 1 of the design's ownership split).
|
|
10
|
+
*
|
|
11
|
+
* The mechanism is a Node module-resolution hook (`node:module`'s `register`)
|
|
12
|
+
* delivered entirely through `--import` **data: URLs**. Nothing is written to
|
|
13
|
+
* disk and no `.mjs` asset has to be copied into `dist/` or embedded in the
|
|
14
|
+
* packaged binary — the whole resolver is two generated modules that exist only
|
|
15
|
+
* as command-line arguments.
|
|
16
|
+
*/
|
|
17
|
+
/** The specifier a canvas extension imports, and the package it belongs to. */
|
|
18
|
+
export const CANVAS_SDK_PACKAGE = "@github/copilot-sdk";
|
|
19
|
+
/**
|
|
20
|
+
* Resolution hooks, as source. `initialize` receives the shim URL through
|
|
21
|
+
* `register`'s `data` channel rather than the environment, because the hooks run
|
|
22
|
+
* on a separate loader thread and the documented `data` path is the supported way
|
|
23
|
+
* to parameterise them.
|
|
24
|
+
*/
|
|
25
|
+
const HOOKS_SOURCE = `
|
|
26
|
+
let shimUrl;
|
|
27
|
+
export function initialize(data) {
|
|
28
|
+
shimUrl = data.shimUrl;
|
|
29
|
+
}
|
|
30
|
+
export async function resolve(specifier, context, next) {
|
|
31
|
+
if (specifier === ${JSON.stringify(CANVAS_SDK_PACKAGE)} || specifier.startsWith(${JSON.stringify(`${CANVAS_SDK_PACKAGE}/`)})) {
|
|
32
|
+
return { url: shimUrl, shortCircuit: true };
|
|
33
|
+
}
|
|
34
|
+
return next(specifier, context);
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
function dataModule(source) {
|
|
38
|
+
return `data:text/javascript,${encodeURIComponent(source)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the `--import` argument that makes `@github/copilot-sdk` resolve to
|
|
42
|
+
* `shimUrl` inside the child.
|
|
43
|
+
*
|
|
44
|
+
* @param shimUrl Absolute `file:` URL of the module implementing the SDK surface.
|
|
45
|
+
*/
|
|
46
|
+
export function canvasResolverImportArg(shimUrl) {
|
|
47
|
+
if (!shimUrl.startsWith("file://")) {
|
|
48
|
+
throw new Error(`Canvas shim URL must be an absolute file: URL, received "${shimUrl}".`);
|
|
49
|
+
}
|
|
50
|
+
const hooksUrl = dataModule(HOOKS_SOURCE);
|
|
51
|
+
const registerSource = `
|
|
52
|
+
import { register } from "node:module";
|
|
53
|
+
register(${JSON.stringify(hooksUrl)}, { parentURL: import.meta.url, data: ${JSON.stringify({ shimUrl })} });
|
|
54
|
+
`;
|
|
55
|
+
return dataModule(registerSource);
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.js","sourceRoot":"","sources":["../../../src/core/canvas/resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,+EAA+E;AAC/E,MAAM,CAAC,MAAM,kBAAkB,GAAG,qBAAqB,CAAC;AAExD;;;;;GAKG;AACH,MAAM,YAAY,GAAG;;;;;;qBAMA,IAAI,CAAC,SAAS,CAAC,kBAAkB,CAAC,4BAA4B,IAAI,CAAC,SAAS,CAAC,GAAG,kBAAkB,GAAG,CAAC;;;;;CAK1H,CAAC;AAEF,SAAS,UAAU,CAAC,MAAc,EAAU;IAC3C,OAAO,wBAAwB,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;AAAA,CAC5D;AAED;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CAAC,OAAe,EAAU;IAChE,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CAAC,4DAA4D,OAAO,IAAI,CAAC,CAAC;IAC1F,CAAC;IACD,MAAM,QAAQ,GAAG,UAAU,CAAC,YAAY,CAAC,CAAC;IAC1C,MAAM,cAAc,GAAG;;WAEb,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,yCAAyC,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,CAAC;CACtG,CAAC;IACD,OAAO,UAAU,CAAC,cAAc,CAAC,CAAC;AAAA,CAClC","sourcesContent":["/**\n * Module resolution for a forked canvas extension.\n *\n * Design: `docs/canvas-extensions-design.md` §4.1. A canvas extension's only\n * external import is `@github/copilot-sdk/extension`, and GitHub's own\n * `docs/extensions.md` says that import \"is resolved automatically — you don't\n * install it\". So hoocode has to make the specifier resolvable inside the child\n * without writing anything into the extension directory: a catalog canvas must\n * stay byte-identical to upstream (tier 1 of the design's ownership split).\n *\n * The mechanism is a Node module-resolution hook (`node:module`'s `register`)\n * delivered entirely through `--import` **data: URLs**. Nothing is written to\n * disk and no `.mjs` asset has to be copied into `dist/` or embedded in the\n * packaged binary — the whole resolver is two generated modules that exist only\n * as command-line arguments.\n */\n\n/** The specifier a canvas extension imports, and the package it belongs to. */\nexport const CANVAS_SDK_PACKAGE = \"@github/copilot-sdk\";\n\n/**\n * Resolution hooks, as source. `initialize` receives the shim URL through\n * `register`'s `data` channel rather than the environment, because the hooks run\n * on a separate loader thread and the documented `data` path is the supported way\n * to parameterise them.\n */\nconst HOOKS_SOURCE = `\nlet shimUrl;\nexport function initialize(data) {\n\tshimUrl = data.shimUrl;\n}\nexport async function resolve(specifier, context, next) {\n\tif (specifier === ${JSON.stringify(CANVAS_SDK_PACKAGE)} || specifier.startsWith(${JSON.stringify(`${CANVAS_SDK_PACKAGE}/`)})) {\n\t\treturn { url: shimUrl, shortCircuit: true };\n\t}\n\treturn next(specifier, context);\n}\n`;\n\nfunction dataModule(source: string): string {\n\treturn `data:text/javascript,${encodeURIComponent(source)}`;\n}\n\n/**\n * Build the `--import` argument that makes `@github/copilot-sdk` resolve to\n * `shimUrl` inside the child.\n *\n * @param shimUrl Absolute `file:` URL of the module implementing the SDK surface.\n */\nexport function canvasResolverImportArg(shimUrl: string): string {\n\tif (!shimUrl.startsWith(\"file://\")) {\n\t\tthrow new Error(`Canvas shim URL must be an absolute file: URL, received \"${shimUrl}\".`);\n\t}\n\tconst hooksUrl = dataModule(HOOKS_SOURCE);\n\tconst registerSource = `\nimport { register } from \"node:module\";\nregister(${JSON.stringify(hooksUrl)}, { parentURL: import.meta.url, data: ${JSON.stringify({ shimUrl })} });\n`;\n\treturn dataModule(registerSource);\n}\n"]}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas extension runner — forks an extension and speaks the provider protocol.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §3.1, §4, §6.1. Two decisions are
|
|
5
|
+
* load-bearing here:
|
|
6
|
+
*
|
|
7
|
+
* 1. **It forks; it does not import.** hoocode's own extensions load in-process
|
|
8
|
+
* (`core/extensions/loader.ts`), which would put a third-party canvas inside
|
|
9
|
+
* the permission gate with full access to the tool registry and provider
|
|
10
|
+
* credentials. Canvas extensions are strangers, so they get a process
|
|
11
|
+
* boundary — which is also what the Copilot CLI does, so a canvas behaves
|
|
12
|
+
* the same in both hosts.
|
|
13
|
+
* 2. **Lifecycle mirrors the documented CLI contract**: forked child, JSON-RPC
|
|
14
|
+
* over stdio, and shutdown by SIGTERM followed by SIGKILL after 5s.
|
|
15
|
+
*
|
|
16
|
+
* stdout is the protocol channel, exactly as in the Copilot CLI, so a stray
|
|
17
|
+
* `console.log` in an extension corrupts it there and here alike. Rather than
|
|
18
|
+
* failing opaquely, unparseable lines are surfaced through `onStray` so a caller
|
|
19
|
+
* can tell the author to use `session.log` instead.
|
|
20
|
+
*/
|
|
21
|
+
import { type CanvasProviderCloseRequest, type CanvasProviderInvokeActionRequest, type CanvasProviderMethod, type CanvasProviderOpenRequest, type CanvasReadyMessage, type JsonValue } from "./protocol.js";
|
|
22
|
+
/** Code on the error a cancelled or timed-out provider call rejects with. */
|
|
23
|
+
export declare const CANVAS_ERROR_CODE_ABORTED = "aborted";
|
|
24
|
+
/** Per-call options. A signal cancels the wait; the child is reconciled by the caller. */
|
|
25
|
+
export interface CanvasCallOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Stop waiting when this aborts.
|
|
28
|
+
*
|
|
29
|
+
* The provider protocol has no cancel verb, so aborting only ends *our* wait —
|
|
30
|
+
* the child may still be working and may still answer. `registry.ts` is what
|
|
31
|
+
* reconciles that, by closing the instance it will never see (see its `abandon`).
|
|
32
|
+
*/
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
/** Grace period between SIGTERM and SIGKILL, matching the documented CLI contract. */
|
|
36
|
+
export declare const CANVAS_SHUTDOWN_GRACE_MS = 5000;
|
|
37
|
+
/**
|
|
38
|
+
* Default ceiling per provider method, so a wedged handler cannot hang a session
|
|
39
|
+
* (design doc §11.4).
|
|
40
|
+
*
|
|
41
|
+
* These differ because the calls do. `canvas.open` may legitimately do real work
|
|
42
|
+
* before it can return a URL — `pr-artifact-explorer` starts a server and
|
|
43
|
+
* `inspect_artifact`-shaped canvases may fetch — whereas an action is a request
|
|
44
|
+
* against an already-open instance, and `close` should be near-instant since the
|
|
45
|
+
* SDK contract makes `onClose` fire-and-forget. A single 30s ceiling for all three
|
|
46
|
+
* was a guess, and wrong at both ends.
|
|
47
|
+
*/
|
|
48
|
+
export declare const CANVAS_REQUEST_TIMEOUT_MS: Readonly<Record<CanvasProviderMethod, number>>;
|
|
49
|
+
/** How the child is launched. Injectable so tests can run the TypeScript shim under tsx. */
|
|
50
|
+
export interface CanvasRuntime {
|
|
51
|
+
/** Executable to fork. */
|
|
52
|
+
execPath: string;
|
|
53
|
+
/** Arguments that precede the entry file, excluding the resolver `--import`. */
|
|
54
|
+
execArgv: string[];
|
|
55
|
+
/** Absolute `file:` URL of the module the SDK specifier resolves to. */
|
|
56
|
+
shimUrl: string;
|
|
57
|
+
}
|
|
58
|
+
/** Everything needed to fork one extension. */
|
|
59
|
+
export interface CanvasRunnerOptions {
|
|
60
|
+
/** Provider identifier — the extension directory name. */
|
|
61
|
+
extensionId: string;
|
|
62
|
+
/** Absolute path to the extension's `extension.mjs`. */
|
|
63
|
+
entry: string;
|
|
64
|
+
/** Value for the child's `SESSION_ID`. Defaults to the extension id. */
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
runtime: CanvasRuntime;
|
|
67
|
+
/** Working directory for the child. Defaults to the extension directory's parent. */
|
|
68
|
+
cwd?: string;
|
|
69
|
+
env?: NodeJS.ProcessEnv;
|
|
70
|
+
/** Per-method overrides merged over {@link CANVAS_REQUEST_TIMEOUT_MS}. 0 disables. */
|
|
71
|
+
requestTimeoutMs?: Partial<Record<CanvasProviderMethod, number>>;
|
|
72
|
+
/** A `session.log` call from the extension. */
|
|
73
|
+
onLog?: (message: string, level: string | undefined, ephemeral: boolean | undefined) => void;
|
|
74
|
+
/** A stdout line that was not protocol. Almost always a stray `console.log`. */
|
|
75
|
+
onStray?: (line: string) => void;
|
|
76
|
+
/** The child's stderr, which extensions use normally. */
|
|
77
|
+
onStderr?: (chunk: string) => void;
|
|
78
|
+
/** The child exited, expectedly or not. */
|
|
79
|
+
onExit?: (code: number | null, signal: NodeJS.Signals | null) => void;
|
|
80
|
+
}
|
|
81
|
+
/** A running canvas extension. */
|
|
82
|
+
export interface CanvasExtensionProcess {
|
|
83
|
+
readonly extensionId: string;
|
|
84
|
+
/** Resolves with the child's first `ready` message, or rejects if it exits first. */
|
|
85
|
+
readonly ready: Promise<CanvasReadyMessage>;
|
|
86
|
+
/** Whether the child is still running. */
|
|
87
|
+
readonly running: boolean;
|
|
88
|
+
open(params: CanvasProviderOpenRequest, options?: CanvasCallOptions): Promise<JsonValue>;
|
|
89
|
+
close(params: CanvasProviderCloseRequest, options?: CanvasCallOptions): Promise<JsonValue>;
|
|
90
|
+
invokeAction(params: CanvasProviderInvokeActionRequest, options?: CanvasCallOptions): Promise<JsonValue>;
|
|
91
|
+
/** SIGTERM, then SIGKILL after {@link CANVAS_SHUTDOWN_GRACE_MS}. Resolves on exit. */
|
|
92
|
+
terminate(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
/** Error carrying the `CanvasError.code` an extension handler threw. */
|
|
95
|
+
export declare class CanvasCallError extends Error {
|
|
96
|
+
readonly code: string;
|
|
97
|
+
constructor(code: string, message: string);
|
|
98
|
+
}
|
|
99
|
+
/** Fork an extension and return a handle to its provider surface. */
|
|
100
|
+
export declare function spawnCanvasExtension(options: CanvasRunnerOptions): CanvasExtensionProcess;
|
|
101
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/runner.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Canvas extension runner — forks an extension and speaks the provider protocol.\n *\n * Design: `docs/canvas-extensions-design.md` §3.1, §4, §6.1. Two decisions are\n * load-bearing here:\n *\n * 1. **It forks; it does not import.** hoocode's own extensions load in-process\n * (`core/extensions/loader.ts`), which would put a third-party canvas inside\n * the permission gate with full access to the tool registry and provider\n * credentials. Canvas extensions are strangers, so they get a process\n * boundary — which is also what the Copilot CLI does, so a canvas behaves\n * the same in both hosts.\n * 2. **Lifecycle mirrors the documented CLI contract**: forked child, JSON-RPC\n * over stdio, and shutdown by SIGTERM followed by SIGKILL after 5s.\n *\n * stdout is the protocol channel, exactly as in the Copilot CLI, so a stray\n * `console.log` in an extension corrupts it there and here alike. Rather than\n * failing opaquely, unparseable lines are surfaced through `onStray` so a caller\n * can tell the author to use `session.log` instead.\n */\n\nimport { type ChildProcessWithoutNullStreams, spawn } from \"node:child_process\";\nimport {\n\tCANVAS_ENVELOPE_VERSION,\n\ttype CanvasChildToHostMessage,\n\tCanvasMessageDecoder,\n\ttype CanvasProviderCloseRequest,\n\ttype CanvasProviderInvokeActionRequest,\n\ttype CanvasProviderMethod,\n\ttype CanvasProviderOpenRequest,\n\ttype CanvasReadyMessage,\n\tencodeCanvasMessage,\n\tisCanvasChildToHostMessage,\n\ttype JsonValue,\n} from \"./protocol.js\";\nimport { canvasResolverImportArg } from \"./resolver.js\";\n\n/** Code on the error a cancelled or timed-out provider call rejects with. */\nexport const CANVAS_ERROR_CODE_ABORTED = \"aborted\";\n\n/** Per-call options. A signal cancels the wait; the child is reconciled by the caller. */\nexport interface CanvasCallOptions {\n\t/**\n\t * Stop waiting when this aborts.\n\t *\n\t * The provider protocol has no cancel verb, so aborting only ends *our* wait —\n\t * the child may still be working and may still answer. `registry.ts` is what\n\t * reconciles that, by closing the instance it will never see (see its `abandon`).\n\t */\n\tsignal?: AbortSignal;\n}\n\n/** Grace period between SIGTERM and SIGKILL, matching the documented CLI contract. */\nexport const CANVAS_SHUTDOWN_GRACE_MS = 5_000;\n\n/**\n * Default ceiling per provider method, so a wedged handler cannot hang a session\n * (design doc §11.4).\n *\n * These differ because the calls do. `canvas.open` may legitimately do real work\n * before it can return a URL — `pr-artifact-explorer` starts a server and\n * `inspect_artifact`-shaped canvases may fetch — whereas an action is a request\n * against an already-open instance, and `close` should be near-instant since the\n * SDK contract makes `onClose` fire-and-forget. A single 30s ceiling for all three\n * was a guess, and wrong at both ends.\n */\nexport const CANVAS_REQUEST_TIMEOUT_MS: Readonly<Record<CanvasProviderMethod, number>> = {\n\t\"canvas.open\": 120_000,\n\t\"canvas.action.invoke\": 30_000,\n\t\"canvas.close\": 5_000,\n};\n\n/** How the child is launched. Injectable so tests can run the TypeScript shim under tsx. */\nexport interface CanvasRuntime {\n\t/** Executable to fork. */\n\texecPath: string;\n\t/** Arguments that precede the entry file, excluding the resolver `--import`. */\n\texecArgv: string[];\n\t/** Absolute `file:` URL of the module the SDK specifier resolves to. */\n\tshimUrl: string;\n}\n\n/** Everything needed to fork one extension. */\nexport interface CanvasRunnerOptions {\n\t/** Provider identifier — the extension directory name. */\n\textensionId: string;\n\t/** Absolute path to the extension's `extension.mjs`. */\n\tentry: string;\n\t/** Value for the child's `SESSION_ID`. Defaults to the extension id. */\n\tsessionId?: string;\n\truntime: CanvasRuntime;\n\t/** Working directory for the child. Defaults to the extension directory's parent. */\n\tcwd?: string;\n\tenv?: NodeJS.ProcessEnv;\n\t/** Per-method overrides merged over {@link CANVAS_REQUEST_TIMEOUT_MS}. 0 disables. */\n\trequestTimeoutMs?: Partial<Record<CanvasProviderMethod, number>>;\n\t/** A `session.log` call from the extension. */\n\tonLog?: (message: string, level: string | undefined, ephemeral: boolean | undefined) => void;\n\t/** A stdout line that was not protocol. Almost always a stray `console.log`. */\n\tonStray?: (line: string) => void;\n\t/** The child's stderr, which extensions use normally. */\n\tonStderr?: (chunk: string) => void;\n\t/** The child exited, expectedly or not. */\n\tonExit?: (code: number | null, signal: NodeJS.Signals | null) => void;\n}\n\n/** A running canvas extension. */\nexport interface CanvasExtensionProcess {\n\treadonly extensionId: string;\n\t/** Resolves with the child's first `ready` message, or rejects if it exits first. */\n\treadonly ready: Promise<CanvasReadyMessage>;\n\t/** Whether the child is still running. */\n\treadonly running: boolean;\n\topen(params: CanvasProviderOpenRequest, options?: CanvasCallOptions): Promise<JsonValue>;\n\tclose(params: CanvasProviderCloseRequest, options?: CanvasCallOptions): Promise<JsonValue>;\n\tinvokeAction(params: CanvasProviderInvokeActionRequest, options?: CanvasCallOptions): Promise<JsonValue>;\n\t/** SIGTERM, then SIGKILL after {@link CANVAS_SHUTDOWN_GRACE_MS}. Resolves on exit. */\n\tterminate(): Promise<void>;\n}\n\ninterface PendingCall {\n\tresolve: (value: JsonValue) => void;\n\treject: (reason: Error) => void;\n\t/** Clear the timer and detach the abort listener. Safe to call twice. */\n\tdispose: () => void;\n}\n\n/** Error carrying the `CanvasError.code` an extension handler threw. */\nexport class CanvasCallError extends Error {\n\treadonly code: string;\n\n\tconstructor(code: string, message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"CanvasCallError\";\n\t\tthis.code = code;\n\t}\n}\n\n/** Fork an extension and return a handle to its provider surface. */\nexport function spawnCanvasExtension(options: CanvasRunnerOptions): CanvasExtensionProcess {\n\tconst timeouts: Record<CanvasProviderMethod, number> = { ...CANVAS_REQUEST_TIMEOUT_MS, ...options.requestTimeoutMs };\n\tconst child = spawn(\n\t\toptions.runtime.execPath,\n\t\t[...options.runtime.execArgv, \"--import\", canvasResolverImportArg(options.runtime.shimUrl), options.entry],\n\t\t{\n\t\t\tcwd: options.cwd,\n\t\t\tenv: {\n\t\t\t\t...(options.env ?? process.env),\n\t\t\t\tHOOCODE_CANVAS_EXTENSION_ID: options.extensionId,\n\t\t\t\t// The real SDK's joinSession throws without SESSION_ID (design doc §11.2).\n\t\t\t\t// Our shim does not read it, but an extension that uses the SDK directly\n\t\t\t\t// would, so setting it is free fidelity. COPILOT_HOME is deliberately left\n\t\t\t\t// alone: a canvas should not be able to tell the hosts apart.\n\t\t\t\tSESSION_ID: options.sessionId ?? options.extensionId,\n\t\t\t},\n\t\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t},\n\t) as ChildProcessWithoutNullStreams;\n\n\tconst pending = new Map<number, PendingCall>();\n\tconst decoder = new CanvasMessageDecoder();\n\tlet nextId = 1;\n\tlet exited = false;\n\tlet readyResolve: ((value: CanvasReadyMessage) => void) | undefined;\n\tlet readyReject: ((reason: Error) => void) | undefined;\n\tconst ready = new Promise<CanvasReadyMessage>((resolve, reject) => {\n\t\treadyResolve = resolve;\n\t\treadyReject = reject;\n\t});\n\n\tconst settleAll = (reason: Error): void => {\n\t\tfor (const call of pending.values()) {\n\t\t\tcall.dispose();\n\t\t\tcall.reject(reason);\n\t\t}\n\t\tpending.clear();\n\t};\n\n\tconst handle = (message: CanvasChildToHostMessage): void => {\n\t\tswitch (message.type) {\n\t\t\tcase \"ready\":\n\t\t\t\treadyResolve?.(message);\n\t\t\t\treturn;\n\t\t\tcase \"log\":\n\t\t\t\toptions.onLog?.(message.message, message.level, message.ephemeral);\n\t\t\t\treturn;\n\t\t\tdefault: {\n\t\t\t\t// A response for an id no longer pending is one whose caller stopped\n\t\t\t\t// waiting — aborted or timed out. Dropping it is the whole point.\n\t\t\t\tconst call = pending.get(message.id);\n\t\t\t\tif (!call) return;\n\t\t\t\tpending.delete(message.id);\n\t\t\t\tcall.dispose();\n\t\t\t\tif (message.type === \"response\") call.resolve(message.result);\n\t\t\t\telse call.reject(new CanvasCallError(message.code, message.message));\n\t\t\t}\n\t\t}\n\t};\n\n\tchild.stdout.setEncoding(\"utf8\");\n\tchild.stdout.on(\"data\", (chunk: string) => {\n\t\tconst { values, strays } = decoder.push(chunk);\n\t\tfor (const line of strays) options.onStray?.(line);\n\t\tfor (const value of values) {\n\t\t\tif (isCanvasChildToHostMessage(value)) handle(value);\n\t\t\telse options.onStray?.(JSON.stringify(value));\n\t\t}\n\t});\n\n\tchild.stderr.setEncoding(\"utf8\");\n\tchild.stderr.on(\"data\", (chunk: string) => options.onStderr?.(chunk));\n\n\tchild.on(\"error\", (error: Error) => {\n\t\texited = true;\n\t\treadyReject?.(error);\n\t\tsettleAll(error);\n\t});\n\n\tchild.on(\"exit\", (code, signal) => {\n\t\texited = true;\n\t\tconst reason = new Error(\n\t\t\t`Canvas extension \"${options.extensionId}\" exited (code ${String(code)}, signal ${String(signal)}).`,\n\t\t);\n\t\treadyReject?.(reason);\n\t\tsettleAll(reason);\n\t\toptions.onExit?.(code, signal);\n\t});\n\n\tconst call = (\n\t\tmethod: CanvasProviderMethod,\n\t\tparams: JsonValue,\n\t\tcallOptions?: CanvasCallOptions,\n\t): Promise<JsonValue> => {\n\t\tif (exited) {\n\t\t\treturn Promise.reject(new Error(`Canvas extension \"${options.extensionId}\" is not running.`));\n\t\t}\n\t\tconst signal = callOptions?.signal;\n\t\tif (signal?.aborted) {\n\t\t\t// Nothing is written to the child: a request nobody awaits should not exist.\n\t\t\treturn Promise.reject(\n\t\t\t\tnew CanvasCallError(CANVAS_ERROR_CODE_ABORTED, `Canvas ${method} was cancelled before it started.`),\n\t\t\t);\n\t\t}\n\t\tconst id = nextId;\n\t\tnextId += 1;\n\t\tconst timeoutMs = timeouts[method];\n\t\treturn new Promise<JsonValue>((resolve, reject) => {\n\t\t\tconst abandon = (error: Error): void => {\n\t\t\t\tconst entry = pending.get(id);\n\t\t\t\tif (!entry) return;\n\t\t\t\tpending.delete(id);\n\t\t\t\tentry.dispose();\n\t\t\t\treject(error);\n\t\t\t};\n\t\t\tconst timer =\n\t\t\t\ttimeoutMs > 0\n\t\t\t\t\t? setTimeout(\n\t\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\t\tabandon(\n\t\t\t\t\t\t\t\t\tnew CanvasCallError(\n\t\t\t\t\t\t\t\t\t\tCANVAS_ERROR_CODE_ABORTED,\n\t\t\t\t\t\t\t\t\t\t`Canvas extension \"${options.extensionId}\" did not answer ${method} within ${timeoutMs}ms.`,\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\ttimeoutMs,\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined;\n\t\t\tconst onAbort = () =>\n\t\t\t\tabandon(new CanvasCallError(CANVAS_ERROR_CODE_ABORTED, `Canvas ${method} was cancelled.`));\n\t\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\tpending.set(id, {\n\t\t\t\tresolve,\n\t\t\t\treject,\n\t\t\t\tdispose: () => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\t\t},\n\t\t\t});\n\t\t\tchild.stdin.write(\n\t\t\t\tencodeCanvasMessage({ envelope: CANVAS_ENVELOPE_VERSION, type: \"request\", id, method, params }),\n\t\t\t);\n\t\t});\n\t};\n\n\treturn {\n\t\textensionId: options.extensionId,\n\t\tready,\n\t\tget running() {\n\t\t\treturn !exited;\n\t\t},\n\t\topen: (params, callOptions) => call(\"canvas.open\", params as unknown as JsonValue, callOptions),\n\t\tclose: (params, callOptions) => call(\"canvas.close\", params as unknown as JsonValue, callOptions),\n\t\tinvokeAction: (params, callOptions) => call(\"canvas.action.invoke\", params as unknown as JsonValue, callOptions),\n\t\tterminate: () =>\n\t\t\tnew Promise<void>((resolve) => {\n\t\t\t\tif (exited) {\n\t\t\t\t\tresolve();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst kill = setTimeout(() => child.kill(\"SIGKILL\"), CANVAS_SHUTDOWN_GRACE_MS);\n\t\t\t\tchild.once(\"exit\", () => {\n\t\t\t\t\tclearTimeout(kill);\n\t\t\t\t\tresolve();\n\t\t\t\t});\n\t\t\t\tchild.kill(\"SIGTERM\");\n\t\t\t}),\n\t};\n}\n"]}
|