{"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"]}