mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-21 08:26:52 +00:00
927c1b5b38
* canvas-extension: new chat cards extension * codespell: resolve mispelling * code review, debug extension finding github app issue * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * apply edits from code review * apply edits from code review * apply edits from code review --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1224 lines
43 KiB
JavaScript
1224 lines
43 KiB
JavaScript
// Extension: chat-cards
|
|
// A GitHub Copilot canvas extension. The agent builds interactive cards
|
|
// (tabs, tables, charts, forms, show/hide sections, sequential lists,
|
|
// markdown documents, video) through canvas actions; the canvas renders
|
|
// them as a live deck. Form submissions and per-card context actions travel
|
|
// back to the conversation as prompts via session.send.
|
|
//
|
|
// All rendering lives in cards-core.mjs (dependency-free, testable without
|
|
// the SDK); this file wires the canvas/session lifecycle, the action
|
|
// surface, and a per-panel page server: open() starts one, onClose() stops
|
|
// it, so the process holds no sockets while no panel is showing.
|
|
import http from "node:http";
|
|
import crypto from "node:crypto";
|
|
import {
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
|
|
import { buildCard, CARD_BUILDERS } from "./cards-core.mjs";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const CANVAS_PAGE = readFileSync(path.join(__dirname, "assets", "canvas.html"), "utf8");
|
|
|
|
// Deck limits. A live deck can hold plenty, but it is still a conversation
|
|
// surface, not a database.
|
|
const MAX_CARDS = 60;
|
|
const MAX_SUBMISSIONS = 50;
|
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
const MAX_PROMPT_CHARS = 16000;
|
|
|
|
let session = null;
|
|
|
|
// instanceId -> { token, sessionId, cards: [], submissions: [], updatedAt }
|
|
const instances = new Map();
|
|
// instanceId -> Set of SSE responses
|
|
const sseClients = new Map();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Deck persistence
|
|
// ---------------------------------------------------------------------------
|
|
// Decks are written to per-user state on disk so the cards a conversation
|
|
// produced are still there after the Copilot app (and this extension process
|
|
// with it) restarts. Tokens are deliberately NOT persisted: they only
|
|
// authenticate the canvas page against this process, so each run mints fresh
|
|
// ones and open() hands the page a fresh URL.
|
|
|
|
const STATE_VERSION = 1;
|
|
const MAX_PERSISTED_DECKS = 8;
|
|
const PERSIST_DEBOUNCE_MS = 300;
|
|
|
|
function stateDirectory() {
|
|
if (process.platform === "win32") {
|
|
return path.join(
|
|
process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"),
|
|
"chat-cards",
|
|
);
|
|
}
|
|
if (process.platform === "darwin") {
|
|
return path.join(os.homedir(), "Library", "Application Support", "chat-cards");
|
|
}
|
|
return path.join(
|
|
process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state"),
|
|
"chat-cards",
|
|
);
|
|
}
|
|
|
|
// One file per deck rather than one shared document. Several extension
|
|
// processes (parallel Copilot sessions) share this directory, and each one
|
|
// only knows about the decks it holds open plus the snapshot it read at
|
|
// startup. A process rewriting a shared document would flush that stale
|
|
// snapshot over decks another process had since changed, and the two would
|
|
// race on a single temp path. A file per deck means a process only ever
|
|
// replaces the decks it owns, through a temp name scoped to its own pid.
|
|
//
|
|
// Every file also records which process has that deck open, so the other
|
|
// cross-process question, whether a file may be deleted to hold the
|
|
// directory to its cap, has an answer that does not assume one process.
|
|
const DECKS_DIR = path.join(stateDirectory(), "decks");
|
|
// The single-document layout this replaced: imported once, then removed.
|
|
const LEGACY_STATE_FILE = path.join(stateDirectory(), "state.json");
|
|
|
|
// Stamped into every deck this process holds open, and cleared on the way
|
|
// out. The host name travels with the pid because a home directory can be
|
|
// shared between machines, where a pid from one says nothing about another.
|
|
const DECK_OWNER = { host: os.hostname(), pid: process.pid };
|
|
|
|
// Canvas instance ids come from the host, so hash rather than trust one as a
|
|
// path segment. The id itself is stored inside the file.
|
|
function deckFile(instanceId) {
|
|
const name = crypto.createHash("sha256").update(instanceId).digest("hex").slice(0, 32);
|
|
return path.join(DECKS_DIR, `${name}.json`);
|
|
}
|
|
|
|
// Is the process that stamped a deck still running? Signal 0 asks the OS
|
|
// whether a pid exists without touching the process. An owner recorded on
|
|
// another machine cannot be checked at all, so it counts as running: holding
|
|
// a deck longer than the cap only costs disk, while deleting a live one
|
|
// costs the user cards a canvas is still showing.
|
|
function ownerIsRunning(owner) {
|
|
if (!owner || typeof owner !== "object") return false;
|
|
if (owner.host !== DECK_OWNER.host) return true;
|
|
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
|
|
try {
|
|
process.kill(owner.pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
// ESRCH: the process is gone. EPERM: it is there, under another user.
|
|
return error?.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function readDeckOwner(file) {
|
|
try {
|
|
return JSON.parse(readFileSync(file, "utf8"))?.owner ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Replace one deck's file atomically. The temp name carries this process's
|
|
// pid, so two processes writing this directory can never rename over each
|
|
// other's half-written file. Pass owner as null to publish a deck this
|
|
// process is no longer holding open.
|
|
function writeDeckSync(instanceId, deck, owner = DECK_OWNER) {
|
|
const target = deckFile(instanceId);
|
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
try {
|
|
mkdirSync(DECKS_DIR, { recursive: true, mode: 0o700 });
|
|
writeFileSync(
|
|
tmp,
|
|
JSON.stringify({
|
|
version: STATE_VERSION,
|
|
instanceId,
|
|
owner: owner ?? null,
|
|
sessionId: deck.sessionId ?? null,
|
|
cards: deck.cards ?? [],
|
|
submissions: deck.submissions ?? [],
|
|
updatedAt: deck.updatedAt ?? new Date().toISOString(),
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
renameSync(tmp, target);
|
|
} catch {
|
|
// Persistence is best effort; the live deck is unaffected.
|
|
try {
|
|
rmSync(tmp, { force: true });
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function removeDeckSync(instanceId) {
|
|
try {
|
|
rmSync(deckFile(instanceId), { force: true });
|
|
} catch {}
|
|
}
|
|
|
|
function loadPersistedDecks() {
|
|
const decks = {};
|
|
let names;
|
|
try {
|
|
names = readdirSync(DECKS_DIR);
|
|
} catch {
|
|
return decks; // Nothing persisted yet.
|
|
}
|
|
for (const name of names) {
|
|
if (!name.endsWith(".json")) continue;
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path.join(DECKS_DIR, name), "utf8"));
|
|
if (parsed?.version !== STATE_VERSION || typeof parsed.instanceId !== "string") continue;
|
|
// A deck another running process has open belongs to that process:
|
|
// recalling it here would put the same cards in two canvases and have
|
|
// both flush copies of it.
|
|
if (ownerIsRunning(parsed.owner)) continue;
|
|
decks[parsed.instanceId] = {
|
|
sessionId: parsed.sessionId ?? null,
|
|
cards: Array.isArray(parsed.cards) ? parsed.cards : [],
|
|
submissions: Array.isArray(parsed.submissions) ? parsed.submissions : [],
|
|
updatedAt: parsed.updatedAt,
|
|
};
|
|
} catch {
|
|
// One unreadable file must not cost the user the other decks.
|
|
}
|
|
}
|
|
return decks;
|
|
}
|
|
|
|
// Carry decks written by the single-document layout over to per-deck files,
|
|
// then drop that file so this runs at most once. They arrive unowned: no
|
|
// process is holding any of them open yet.
|
|
function migrateLegacyState() {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(LEGACY_STATE_FILE, "utf8"));
|
|
} catch {
|
|
return; // Nothing left from the old layout.
|
|
}
|
|
if (parsed?.version === STATE_VERSION && parsed.instances && typeof parsed.instances === "object") {
|
|
for (const [instanceId, deck] of Object.entries(parsed.instances)) {
|
|
if (deck && typeof deck === "object") writeDeckSync(instanceId, deck, null);
|
|
}
|
|
}
|
|
try {
|
|
rmSync(LEGACY_STATE_FILE, { force: true });
|
|
} catch {}
|
|
}
|
|
|
|
migrateLegacyState();
|
|
|
|
// instanceId -> { sessionId, cards, submissions, updatedAt } from earlier
|
|
// runs or an exited process, not yet claimed by an in-memory instance this
|
|
// run.
|
|
const persistedDecks = loadPersistedDecks();
|
|
|
|
let persistTimer = null;
|
|
|
|
function schedulePersist() {
|
|
if (persistTimer) return;
|
|
persistTimer = setTimeout(() => {
|
|
persistTimer = null;
|
|
flushStateSync();
|
|
}, PERSIST_DEBOUNCE_MS);
|
|
// The debounce must never be what keeps this process alive.
|
|
persistTimer.unref();
|
|
}
|
|
|
|
// Synchronous so the process "exit" event and the shutdown path can use it.
|
|
// Only the decks this process owns are written: a deck belonging to another
|
|
// process, or to an earlier run, stays on disk exactly as its owner left it.
|
|
function flushStateSync(owner = DECK_OWNER) {
|
|
if (persistTimer) {
|
|
clearTimeout(persistTimer);
|
|
persistTimer = null;
|
|
}
|
|
const owned = new Set();
|
|
for (const [instanceId, instance] of instances) {
|
|
writeDeckSync(
|
|
instanceId,
|
|
{
|
|
sessionId: instance.sessionId ?? null,
|
|
cards: instance.cards,
|
|
submissions: instance.submissions,
|
|
updatedAt: instance.updatedAt ?? new Date().toISOString(),
|
|
},
|
|
owner,
|
|
);
|
|
owned.add(path.basename(deckFile(instanceId)));
|
|
}
|
|
pruneDeckFiles(owned);
|
|
}
|
|
|
|
// The last write of a run: the same state with no owner stamp, so the next
|
|
// run can recall these decks and prune them normally.
|
|
function releaseDecksSync() {
|
|
flushStateSync(null);
|
|
}
|
|
|
|
// Hold the directory to MAX_PERSISTED_DECKS, newest first. Files are ranked
|
|
// by mtime rather than by the updatedAt inside them: a deck's file is
|
|
// rewritten whenever it changes, so mtime gives the same order without
|
|
// reading every file, and it stays right for decks other processes own.
|
|
function pruneDeckFiles(owned) {
|
|
let names;
|
|
try {
|
|
names = readdirSync(DECKS_DIR).filter((name) => name.endsWith(".json"));
|
|
} catch {
|
|
return;
|
|
}
|
|
if (names.length <= MAX_PERSISTED_DECKS) return;
|
|
const ranked = names
|
|
.map((name) => {
|
|
let mtimeMs = 0;
|
|
try {
|
|
mtimeMs = statSync(path.join(DECKS_DIR, name)).mtimeMs;
|
|
} catch {}
|
|
return { name, mtimeMs };
|
|
})
|
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
for (const { name } of ranked.slice(MAX_PERSISTED_DECKS)) {
|
|
// A deck someone still has open outranks the cap: dropping its file
|
|
// would lose cards a canvas is still showing. The set covers this
|
|
// process, the owner stamp covers every other one, so only a deck no
|
|
// running process claims is deleted here.
|
|
if (owned.has(name)) continue;
|
|
const file = path.join(DECKS_DIR, name);
|
|
if (ownerIsRunning(readDeckOwner(file))) continue;
|
|
try {
|
|
rmSync(file, { force: true });
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function markDirty(instance) {
|
|
instance.updatedAt = new Date().toISOString();
|
|
schedulePersist();
|
|
}
|
|
|
|
// Recall a persisted deck for a canvas instance: an exact instance match
|
|
// first, else the newest deck from the same session. The host may mint a new
|
|
// canvas instance id after a restart while the conversation (and so the deck
|
|
// the user expects to see again) is the same.
|
|
function takePersistedDeck(instanceId, sessionId) {
|
|
let key = Object.prototype.hasOwnProperty.call(persistedDecks, instanceId) ? instanceId : null;
|
|
if (!key && sessionId) {
|
|
let newest = -1;
|
|
for (const [candidate, deck] of Object.entries(persistedDecks)) {
|
|
if (deck?.sessionId !== sessionId || instances.has(candidate)) continue;
|
|
const at = Date.parse(deck.updatedAt ?? "") || 0;
|
|
if (at > newest) {
|
|
newest = at;
|
|
key = candidate;
|
|
}
|
|
}
|
|
}
|
|
if (!key) return null;
|
|
const deck = persistedDecks[key];
|
|
delete persistedDecks[key];
|
|
// A deck recalled under a different canvas instance id is written back
|
|
// out under that id, so drop the file it came from: leaving it would let
|
|
// the same deck be recalled a second time.
|
|
if (key !== instanceId) removeDeckSync(key);
|
|
return deck;
|
|
}
|
|
|
|
function getInstance(instanceId, sessionId) {
|
|
let instance = instances.get(instanceId);
|
|
if (!instance) {
|
|
const restored = takePersistedDeck(instanceId, sessionId);
|
|
instance = {
|
|
token: crypto.randomBytes(16).toString("hex"),
|
|
sessionId: sessionId ?? restored?.sessionId ?? null,
|
|
cards: Array.isArray(restored?.cards) ? restored.cards : [],
|
|
submissions: Array.isArray(restored?.submissions) ? restored.submissions : [],
|
|
updatedAt: restored?.updatedAt,
|
|
};
|
|
instances.set(instanceId, instance);
|
|
if (restored) schedulePersist();
|
|
} else if (sessionId && !instance.sessionId) {
|
|
instance.sessionId = sessionId;
|
|
}
|
|
return instance;
|
|
}
|
|
|
|
function validateToken(instanceId, token) {
|
|
const instance = instances.get(instanceId);
|
|
return Boolean(instance && token && instance.token === token);
|
|
}
|
|
|
|
function broadcast(instanceId, event, data) {
|
|
const clients = sseClients.get(instanceId);
|
|
if (!clients) return;
|
|
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
for (const res of clients) {
|
|
try {
|
|
res.write(message);
|
|
} catch {
|
|
clients.delete(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hand a prompt to the agent. Returns whether the handoff was accepted, so
|
|
// the form card can fall back to its copyable prompt when it was not.
|
|
async function sendPrompt(prompt) {
|
|
if (!session) return false;
|
|
try {
|
|
await Promise.resolve(session.send({ prompt }));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function findCardIndex(instance, cardId) {
|
|
return instance.cards.findIndex((card) => card.id === cardId);
|
|
}
|
|
|
|
function cardListing(instance) {
|
|
return instance.cards.map((card) => ({ cardId: card.id, kind: card.kind, title: card.title }));
|
|
}
|
|
|
|
// Create a card of the given kind, append it to the deck (trimming the
|
|
// oldest cards past the limit), and notify the canvas.
|
|
function createCardAction(ctx, kind) {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
const card = buildCard(kind, ctx.input ?? {});
|
|
instance.cards.push(card);
|
|
let note = "";
|
|
while (instance.cards.length > MAX_CARDS) {
|
|
const dropped = instance.cards.shift();
|
|
broadcast(ctx.instanceId, "remove", { cardId: dropped.id });
|
|
note = ` The deck was at its ${MAX_CARDS}-card limit, so the oldest card ("${dropped.title}") was removed.`;
|
|
}
|
|
markDirty(instance);
|
|
broadcast(ctx.instanceId, "upsert", { card });
|
|
return {
|
|
ok: true,
|
|
cardId: card.id,
|
|
kind: card.kind,
|
|
title: card.title,
|
|
summary:
|
|
card.summary +
|
|
note +
|
|
" The card is visible in the canvas; also state its key takeaway in the conversation for surfaces without the canvas.",
|
|
};
|
|
}
|
|
|
|
function actionError(error) {
|
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared schema fragments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TUTOR_TERMS_SCHEMA = {
|
|
type: "array",
|
|
description:
|
|
"Educational terms to mark in the card. Hovering a marked term for a moment shows its tip as a tooltip.",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
term: { type: "string", description: "Exact term as it appears in the card text" },
|
|
tip: { type: "string", description: "Short plain-text definition shown on hover" },
|
|
},
|
|
required: ["term"],
|
|
},
|
|
};
|
|
|
|
const CONTEXT_ACTIONS_SCHEMA = {
|
|
type: "array",
|
|
description:
|
|
"Right-click actions for the card. Choosing one sends its prompt to the conversation; {{selection}} in a prompt is replaced with the user's selected text.",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
label: { type: "string", description: "Menu item label" },
|
|
prompt: { type: "string", description: "Prompt sent to the conversation when chosen" },
|
|
},
|
|
required: ["label", "prompt"],
|
|
},
|
|
};
|
|
|
|
const COMMON_CARD_PROPERTIES = {
|
|
title: { type: "string", description: "Card title (plain text; the extension escapes it)" },
|
|
subtitle: { type: "string", description: "Optional subtitle under the title" },
|
|
tutorTerms: TUTOR_TERMS_SCHEMA,
|
|
contextActions: CONTEXT_ACTIONS_SCHEMA,
|
|
};
|
|
|
|
const TAB_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
tabs: {
|
|
type: "array",
|
|
minItems: 1,
|
|
description:
|
|
"One entry per tab. Give each tab exactly one of markdown, code, html, or text (non-empty).",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
label: { type: "string", description: "Tab label" },
|
|
markdown: { type: "string", description: "Markdown content for the tab" },
|
|
content: { type: "string", description: "Alias of markdown" },
|
|
text: { type: "string", description: "Plain text content for the tab" },
|
|
code: { type: "string", description: "Code sample shown with a copy button" },
|
|
language: { type: "string", description: "Language for the code sample" },
|
|
html: { type: "string", description: "HTML content (sanitized to an allowlisted subset)" },
|
|
},
|
|
required: ["label"],
|
|
},
|
|
},
|
|
tutorTermsInCode: {
|
|
type: "boolean",
|
|
description: "Also mark tutor terms inside code samples (off by default)",
|
|
},
|
|
};
|
|
|
|
const TABLE_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
caption: { type: "string" },
|
|
headers: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "Column titles; padded if fewer than columns",
|
|
},
|
|
rows: {
|
|
type: "array",
|
|
items: { type: "array", items: { type: "string" } },
|
|
description: "Explicit cell data, row-major",
|
|
},
|
|
text: { type: "string", description: "Raw text to convert when rows are not given (delimiter auto-detected)" },
|
|
columns: { type: "integer", minimum: 1, maximum: 12 },
|
|
cellDelimiter: { type: "string" },
|
|
linkColumns: {
|
|
type: "array",
|
|
items: { type: "integer", minimum: 0 },
|
|
description: "Zero-based column indexes whose cell values render as links",
|
|
},
|
|
autoLinkUrls: { type: "boolean", description: "Render URL-shaped cells as links (default true)" },
|
|
};
|
|
|
|
const CHART_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
type: { type: "string", enum: ["bar", "line", "pie", "donut"] },
|
|
labels: { type: "array", items: { type: "string" }, description: "X-axis labels (bar/line)" },
|
|
series: {
|
|
type: "array",
|
|
description: "Data series (bar/line); values must be 0 or greater",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
values: { type: "array", items: { type: "number", minimum: 0 } },
|
|
},
|
|
required: ["name", "values"],
|
|
},
|
|
},
|
|
values: {
|
|
type: "array",
|
|
description: "Slices (pie/donut)",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
label: { type: "string" },
|
|
value: { type: "number", minimum: 0 },
|
|
},
|
|
required: ["label", "value"],
|
|
},
|
|
},
|
|
yLabel: { type: "string" },
|
|
description: { type: "string" },
|
|
};
|
|
|
|
const FORM_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
description: { type: "string", description: "Help text shown above the fields" },
|
|
submitLabel: { type: "string" },
|
|
promptTemplate: {
|
|
type: "string",
|
|
description: "Prompt sent on submit; {{fieldName}} tokens are replaced with values",
|
|
},
|
|
fields: {
|
|
type: "array",
|
|
minItems: 1,
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
label: { type: "string" },
|
|
type: {
|
|
type: "string",
|
|
enum: ["text", "textarea", "select", "checkbox", "radio", "number", "email", "url", "date", "hidden"],
|
|
},
|
|
options: {
|
|
type: "array",
|
|
description: "Choices for select/checkbox/radio; strings or { label, value } objects",
|
|
items: {},
|
|
},
|
|
required: { type: "boolean" },
|
|
placeholder: { type: "string" },
|
|
value: { type: "string" },
|
|
help: { type: "string" },
|
|
},
|
|
required: ["name"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const REVEAL_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
sections: {
|
|
type: "array",
|
|
minItems: 1,
|
|
description:
|
|
"Collapsible sections. Give each section markdown, html, or text content (non-empty).",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
heading: { type: "string" },
|
|
markdown: { type: "string" },
|
|
content: { type: "string", description: "Alias of markdown" },
|
|
text: { type: "string" },
|
|
html: { type: "string", description: "HTML content (sanitized to an allowlisted subset)" },
|
|
open: { type: "boolean", description: "Start the section expanded" },
|
|
},
|
|
required: ["heading"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const LIST_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
intro: { type: "string", description: "Optional plain-text intro shown above the outline" },
|
|
items: {
|
|
type: "array",
|
|
minItems: 1,
|
|
description: "Outline items; nest with children arrays of the same { text, children } shape",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
text: { type: "string" },
|
|
children: { type: "array", items: { type: "object" }, description: "Nested items of the same shape" },
|
|
},
|
|
required: ["text"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const MARKDOWN_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
title: { type: "string", description: "Card title; defaults to the document's first H1" },
|
|
markdown: { type: "string", description: "The markdown document to render" },
|
|
splitSections: {
|
|
type: "boolean",
|
|
description: "Fold H2 sections into collapsible reveals (default true)",
|
|
},
|
|
openFirst: { type: "boolean", description: "With folding, open the first section initially (default true)" },
|
|
};
|
|
|
|
const VIDEO_CARD_PROPERTIES = {
|
|
...COMMON_CARD_PROPERTIES,
|
|
src: {
|
|
type: "string",
|
|
description:
|
|
"Direct video file URL (mp4/webm), data:video/* URI, or blob: URL. Not a streaming platform page URL.",
|
|
},
|
|
poster: { type: "string", description: "Optional poster image URL" },
|
|
description: { type: "string", description: "Help text shown above the player" },
|
|
};
|
|
|
|
const KIND_SCHEMAS = {
|
|
tabs: TAB_CARD_PROPERTIES,
|
|
table: TABLE_CARD_PROPERTIES,
|
|
chart: CHART_CARD_PROPERTIES,
|
|
form: FORM_CARD_PROPERTIES,
|
|
"show-hide": REVEAL_CARD_PROPERTIES,
|
|
list: LIST_CARD_PROPERTIES,
|
|
document: MARKDOWN_CARD_PROPERTIES,
|
|
video: VIDEO_CARD_PROPERTIES,
|
|
};
|
|
|
|
// Required fields per kind, mirroring the matching create action. update_card
|
|
// derives its discriminated per-kind schema from these plus KIND_SCHEMAS.
|
|
const KIND_REQUIRED_FIELDS = {
|
|
tabs: ["title", "tabs"],
|
|
table: ["title"],
|
|
chart: ["title", "type"],
|
|
form: ["title", "fields"],
|
|
"show-hide": ["title", "sections"],
|
|
list: ["title", "items"],
|
|
document: ["markdown"],
|
|
video: ["title", "src"],
|
|
};
|
|
|
|
function cardActionSchema(properties, required) {
|
|
return { type: "object", properties, required };
|
|
}
|
|
|
|
function makeCreateAction(name, kind, description, properties, required) {
|
|
return {
|
|
name,
|
|
description,
|
|
inputSchema: cardActionSchema(properties, required),
|
|
handler: (ctx) => {
|
|
try {
|
|
return createCardAction(ctx, kind);
|
|
} catch (error) {
|
|
return actionError(error);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Canvas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const canvas = createCanvas({
|
|
id: "chat-cards",
|
|
displayName: "Chat Cards",
|
|
description:
|
|
"Interactive card deck for explaining things visually: tab cards, tables, SVG charts, forms whose " +
|
|
"submissions come back to the conversation as prompts, show/hide sections, numbered outlines, " +
|
|
"rendered markdown documents, and short video clips. " +
|
|
"Use it for research, education, professional and hobbyist skills, history, and news topics " +
|
|
"whenever a card communicates better than text. Cards render only in the canvas, so also state " +
|
|
"each card's key conclusion in the conversation.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string", description: "Optional canvas title" },
|
|
},
|
|
},
|
|
actions: [
|
|
makeCreateAction(
|
|
"create_tab_card",
|
|
"tabs",
|
|
"Render an interactive card with tabs, each showing a different context of the same subject " +
|
|
"(for example one tab per programming language, OS, or skill level). Tab content can be " +
|
|
"markdown, plain text, HTML, or code with a copy button.",
|
|
TAB_CARD_PROPERTIES,
|
|
["title", "tabs"],
|
|
),
|
|
makeCreateAction(
|
|
"create_table_card",
|
|
"table",
|
|
"Render an HTML table card from explicit rows, or convert loosely delimited text into a " +
|
|
"balanced table (delimiter auto-detected; newlines can mark cells). URL cells become links.",
|
|
TABLE_CARD_PROPERTIES,
|
|
["title"],
|
|
),
|
|
makeCreateAction(
|
|
"create_chart_card",
|
|
"chart",
|
|
"Render a dynamically generated SVG chart card (bar, line, pie, or donut) with a legend and " +
|
|
"a collapsible data table. Use for useful graphics summarizing referenced data.",
|
|
CHART_CARD_PROPERTIES,
|
|
["title", "type"],
|
|
),
|
|
makeCreateAction(
|
|
"create_form_card",
|
|
"form",
|
|
"Render a form card the user fills in to give the conversation context and direction. " +
|
|
"Submitting sends the values back to the conversation as the next prompt (optionally shaped " +
|
|
"by promptTemplate with {{fieldName}} tokens). Check get_form_responses if no prompt arrives.",
|
|
FORM_CARD_PROPERTIES,
|
|
["title", "fields"],
|
|
),
|
|
makeCreateAction(
|
|
"create_reveal_card",
|
|
"show-hide",
|
|
"Render a card of collapsible show/hide sections with show-all/hide-all controls. Good for " +
|
|
"FAQs, step-by-step detail, and progressive disclosure.",
|
|
REVEAL_CARD_PROPERTIES,
|
|
["title", "sections"],
|
|
),
|
|
makeCreateAction(
|
|
"create_list_card",
|
|
"list",
|
|
"Render a nested sequential outline card numbered 1., 1.1., 1.1.1. for plans, procedures, " +
|
|
"and structured overviews.",
|
|
LIST_CARD_PROPERTIES,
|
|
["title", "items"],
|
|
),
|
|
makeCreateAction(
|
|
"create_markdown_card",
|
|
"document",
|
|
"Render a markdown document (a guide or walkthrough you wrote) as one interactive card: the " +
|
|
"first H1 becomes the title, H2 sections fold into show/hide reveals, tables get card " +
|
|
"styling, and fenced code gets copy buttons. Pass the markdown content inline.",
|
|
MARKDOWN_CARD_PROPERTIES,
|
|
["markdown"],
|
|
),
|
|
makeCreateAction(
|
|
"create_video_card",
|
|
"video",
|
|
"Render a card with an HTML video player for a short clip. src must point straight at a " +
|
|
"video file (mp4/webm), not a streaming platform page: a direct http(s) URL, a small " +
|
|
"data:video/* URI, or a blob: URL.",
|
|
VIDEO_CARD_PROPERTIES,
|
|
["title", "src"],
|
|
),
|
|
{
|
|
name: "update_card",
|
|
description:
|
|
"Re-render an existing card in place, keeping its position in the deck. Pass the cardId, " +
|
|
"the card's kind, and the full replacement spec in the same shape the matching create " +
|
|
"action takes.",
|
|
inputSchema: {
|
|
type: "object",
|
|
description:
|
|
"Discriminated by kind: each branch takes cardId, kind, and the full replacement " +
|
|
"fields (with the same required fields) of the matching create action.",
|
|
oneOf: Object.entries(KIND_SCHEMAS).map(([kind, properties]) => ({
|
|
type: "object",
|
|
properties: {
|
|
cardId: { type: "string", description: "The id returned when the card was created" },
|
|
kind: { type: "string", enum: [kind] },
|
|
...properties,
|
|
},
|
|
required: ["cardId", "kind", ...KIND_REQUIRED_FIELDS[kind]],
|
|
additionalProperties: true,
|
|
})),
|
|
},
|
|
handler: (ctx) => {
|
|
try {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
const { cardId, kind, ...spec } = ctx.input ?? {};
|
|
const index = findCardIndex(instance, cardId);
|
|
if (index === -1) return { error: `No card with id "${cardId}". Use list_cards to see the deck.` };
|
|
const card = buildCard(kind, spec);
|
|
// Keep the original id so the canvas replaces the card in place.
|
|
card.articleHtml = card.articleHtml.replace(`data-card-id="${card.id}"`, `data-card-id="${cardId}"`);
|
|
card.config.id = cardId;
|
|
card.id = cardId;
|
|
instance.cards[index] = card;
|
|
markDirty(instance);
|
|
broadcast(ctx.instanceId, "upsert", { card });
|
|
return { ok: true, cardId, kind: card.kind, title: card.title, summary: `Updated card in place. ${card.summary}` };
|
|
} catch (error) {
|
|
return actionError(error);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "remove_card",
|
|
description: "Remove one card from the deck by id.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { cardId: { type: "string" } },
|
|
required: ["cardId"],
|
|
},
|
|
handler: (ctx) => {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
const index = findCardIndex(instance, ctx.input?.cardId);
|
|
if (index === -1) return { error: `No card with id "${ctx.input?.cardId}".` };
|
|
const [removed] = instance.cards.splice(index, 1);
|
|
markDirty(instance);
|
|
broadcast(ctx.instanceId, "remove", { cardId: removed.id });
|
|
return { ok: true, removed: { cardId: removed.id, kind: removed.kind, title: removed.title } };
|
|
},
|
|
},
|
|
{
|
|
name: "clear_cards",
|
|
description: "Remove every card from the deck.",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
handler: (ctx) => {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
const count = instance.cards.length;
|
|
instance.cards = [];
|
|
markDirty(instance);
|
|
broadcast(ctx.instanceId, "clear", {});
|
|
return { ok: true, removedCount: count };
|
|
},
|
|
},
|
|
{
|
|
name: "list_cards",
|
|
description:
|
|
"List the cards currently in the deck (id, kind, title) in display order. The user may " +
|
|
"have reordered or removed cards since they were created.",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
handler: (ctx) => {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
return { ok: true, count: instance.cards.length, cards: cardListing(instance) };
|
|
},
|
|
},
|
|
{
|
|
name: "get_form_responses",
|
|
description:
|
|
"Read form submissions received from the canvas, newest first. Submissions normally arrive " +
|
|
"as conversation prompts too; use this when one did not come through or to review earlier " +
|
|
"answers.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
cardId: { type: "string", description: "Only submissions from this form card" },
|
|
},
|
|
},
|
|
handler: (ctx) => {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
const wanted = ctx.input?.cardId;
|
|
const responses = instance.submissions
|
|
.filter((submission) => !wanted || submission.cardId === wanted)
|
|
.slice()
|
|
.reverse();
|
|
return { ok: true, count: responses.length, responses };
|
|
},
|
|
},
|
|
],
|
|
open: async (ctx) => {
|
|
const instance = getInstance(ctx.instanceId, ctx.sessionId);
|
|
const { port } = await startCanvasServer(ctx.instanceId);
|
|
return {
|
|
url: `http://127.0.0.1:${port}/?instance=${encodeURIComponent(ctx.instanceId)}&token=${instance.token}`,
|
|
title: ctx.input?.title || "Chat Cards",
|
|
status: instance.cards.length > 0 ? `${instance.cards.length} card(s)` : "Ready",
|
|
};
|
|
},
|
|
onClose: (ctx) => {
|
|
// Closing the panel (by the user, the agent, or the host on its way
|
|
// down) must not discard the deck; the canvas can be reopened and the
|
|
// cards are expected to still be there. Only the live SSE connections
|
|
// and this panel's page server go; the deck stays in memory and is
|
|
// flushed to disk.
|
|
const clients = sseClients.get(ctx.instanceId);
|
|
if (clients) {
|
|
for (const res of clients) {
|
|
try {
|
|
res.end();
|
|
} catch {}
|
|
}
|
|
sseClients.delete(ctx.instanceId);
|
|
}
|
|
stopCanvasServer(ctx.instanceId);
|
|
if (instances.has(ctx.instanceId)) flushStateSync();
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local HTTP server behind the canvas page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function json(res, code, data) {
|
|
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
|
|
function readJsonBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
let received = 0;
|
|
req.on("data", (chunk) => {
|
|
received += chunk.length;
|
|
if (received > MAX_BODY_BYTES) {
|
|
req.destroy();
|
|
reject(new Error("Request body too large."));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on("end", () => {
|
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
if (!body.trim()) {
|
|
resolve({});
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch {
|
|
reject(new Error("Request body must be valid JSON."));
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
// A panel's server answers for exactly one canvas instance: the id is
|
|
// bound when the server is created rather than taken from the request.
|
|
// Ports are recycled as panels close and reopen, so a page orphaned by an
|
|
// earlier panel can reach a later panel's server; its token is still in
|
|
// memory and would otherwise let it go on reading and changing its old
|
|
// deck through a port that now belongs to someone else.
|
|
async function handleCanvasRequest(req, res, servedInstanceId) {
|
|
let url;
|
|
try {
|
|
url = new URL(req.url, `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end("Bad request");
|
|
return;
|
|
}
|
|
const instanceId = url.searchParams.get("instance");
|
|
const token = url.searchParams.get("token");
|
|
if (instanceId !== servedInstanceId || !validateToken(instanceId, token)) {
|
|
res.writeHead(403);
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
const instance = getInstance(instanceId);
|
|
|
|
try {
|
|
if (req.method === "GET" && url.pathname === "/") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Referrer-Policy": "no-referrer",
|
|
});
|
|
res.end(CANVAS_PAGE);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && url.pathname === "/events") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
if (!sseClients.has(instanceId)) sseClients.set(instanceId, new Set());
|
|
sseClients.get(instanceId).add(res);
|
|
req.on("close", () => {
|
|
const clients = sseClients.get(instanceId);
|
|
if (clients) clients.delete(res);
|
|
});
|
|
res.write(`event: init\ndata: ${JSON.stringify({ cards: instance.cards })}\n\n`);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/state") {
|
|
json(res, 200, { cards: instance.cards });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/submit") {
|
|
const body = await readJsonBody(req);
|
|
const prompt = String(body.prompt ?? "").slice(0, MAX_PROMPT_CHARS);
|
|
if (!prompt.trim()) {
|
|
json(res, 400, { error: "A non-empty prompt is required." });
|
|
return;
|
|
}
|
|
const cardId = typeof body.cardId === "string" ? body.cardId : null;
|
|
const cardIndex = cardId ? findCardIndex(instance, cardId) : -1;
|
|
const values = body.values && typeof body.values === "object" && !Array.isArray(body.values) ? body.values : {};
|
|
const delivered = await sendPrompt(prompt);
|
|
instance.submissions.push({
|
|
id: crypto.randomBytes(6).toString("hex"),
|
|
cardId,
|
|
cardTitle: cardIndex === -1 ? null : instance.cards[cardIndex].title,
|
|
values,
|
|
prompt,
|
|
delivered,
|
|
receivedAt: new Date().toISOString(),
|
|
});
|
|
while (instance.submissions.length > MAX_SUBMISSIONS) instance.submissions.shift();
|
|
markDirty(instance);
|
|
json(res, 200, { ok: true, delivered });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/prompt") {
|
|
const body = await readJsonBody(req);
|
|
const prompt = String(body.prompt ?? "").slice(0, MAX_PROMPT_CHARS);
|
|
if (!prompt.trim()) {
|
|
json(res, 400, { error: "A non-empty prompt is required." });
|
|
return;
|
|
}
|
|
const delivered = await sendPrompt(prompt);
|
|
json(res, 200, { ok: true, delivered });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/remove") {
|
|
const body = await readJsonBody(req);
|
|
const index = findCardIndex(instance, String(body.cardId ?? ""));
|
|
if (index !== -1) {
|
|
const [removed] = instance.cards.splice(index, 1);
|
|
markDirty(instance);
|
|
broadcast(instanceId, "remove", { cardId: removed.id });
|
|
}
|
|
json(res, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/reorder") {
|
|
const body = await readJsonBody(req);
|
|
const order = Array.isArray(body.order) ? body.order.map(String) : [];
|
|
const byId = new Map(instance.cards.map((card) => [card.id, card]));
|
|
const reordered = [];
|
|
for (const cardId of order) {
|
|
const card = byId.get(cardId);
|
|
if (card) {
|
|
reordered.push(card);
|
|
byId.delete(cardId);
|
|
}
|
|
}
|
|
// Cards missing from the requested order (for example one created while
|
|
// the drag was in flight) keep their relative position at the end.
|
|
for (const card of instance.cards) {
|
|
if (byId.has(card.id)) reordered.push(card);
|
|
}
|
|
instance.cards = reordered;
|
|
markDirty(instance);
|
|
broadcast(instanceId, "reorder", { order: instance.cards.map((card) => card.id) });
|
|
json(res, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end("Not found");
|
|
} catch (error) {
|
|
json(res, 400, { error: error instanceof Error ? error.message : "Bad request" });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-panel page servers
|
|
// ---------------------------------------------------------------------------
|
|
// A server exists only while its canvas panel is open: open() starts it and
|
|
// onClose() stops it. While no panel is showing, this process holds no
|
|
// sockets at all; it is just an RPC child of the host serving actions.
|
|
//
|
|
// Servers bind OUTSIDE the OS dynamic port range (49152+). The Copilot app's
|
|
// own single-instance WebSocket takes an ephemeral port from that range, and
|
|
// a canvas page orphaned by a stopped extension process keeps retrying its
|
|
// old origin for a while; binding down here guarantees those retries can
|
|
// only ever land on a port this extension family owns, never on a recycled
|
|
// port the app's reopen handshake depends on.
|
|
|
|
const PORT_RANGE_START = 21750;
|
|
const PORT_RANGE_SIZE = 40;
|
|
|
|
// instanceId -> { server, sockets: Set, port }
|
|
const canvasServers = new Map();
|
|
|
|
function tryListen(server, target) {
|
|
return new Promise((resolve, reject) => {
|
|
const onError = (error) => {
|
|
server.off("listening", onListening);
|
|
reject(error);
|
|
};
|
|
const onListening = () => {
|
|
server.off("error", onError);
|
|
resolve();
|
|
};
|
|
server.once("error", onError);
|
|
server.once("listening", onListening);
|
|
server.listen(target, "127.0.0.1");
|
|
});
|
|
}
|
|
|
|
async function startCanvasServer(instanceId) {
|
|
const existing = canvasServers.get(instanceId);
|
|
if (existing) return existing;
|
|
|
|
const server = http.createServer((req, res) => handleCanvasRequest(req, res, instanceId));
|
|
const sockets = new Set();
|
|
// Connections are tracked for teardown and unref'd on arrival so an open
|
|
// keep-alive or SSE socket can never keep this process alive by itself.
|
|
server.on("connection", (socket) => {
|
|
socket.unref();
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
});
|
|
|
|
const entry = { server, sockets, port: 0 };
|
|
const offset = crypto.randomInt(PORT_RANGE_SIZE);
|
|
for (let i = 0; i < PORT_RANGE_SIZE && entry.port === 0; i++) {
|
|
const candidate = PORT_RANGE_START + ((offset + i) % PORT_RANGE_SIZE);
|
|
try {
|
|
await tryListen(server, candidate);
|
|
entry.port = candidate;
|
|
} catch {
|
|
// In use (another open panel or another session's process): next slot.
|
|
}
|
|
}
|
|
if (entry.port === 0) {
|
|
// Every slot taken: fall back to an ephemeral port rather than failing.
|
|
await tryListen(server, 0);
|
|
entry.port = server.address().port;
|
|
}
|
|
// The listener itself must never be what keeps this process alive.
|
|
server.unref();
|
|
canvasServers.set(instanceId, entry);
|
|
return entry;
|
|
}
|
|
|
|
function stopCanvasServer(instanceId) {
|
|
const entry = canvasServers.get(instanceId);
|
|
if (!entry) return;
|
|
canvasServers.delete(instanceId);
|
|
try {
|
|
entry.server.close();
|
|
} catch {}
|
|
for (const socket of entry.sockets) {
|
|
try {
|
|
socket.destroy();
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle: this process must never outlive the Copilot app
|
|
// ---------------------------------------------------------------------------
|
|
// The SDK talks to the host over stdio and only flips an internal
|
|
// "disconnected" flag when that pipe closes; nothing ends the process on
|
|
// its own. Two layers keep this process from outliving the app: every
|
|
// end-of-life signal below leads to an explicit flush-and-exit, and nothing
|
|
// this extension owns (page servers, their sockets, the persistence timer)
|
|
// refs the event loop, so the host's stdio pipe is the only thing keeping
|
|
// it alive at all.
|
|
|
|
let shuttingDown = false;
|
|
|
|
function shutdown() {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
releaseDecksSync();
|
|
for (const clients of sseClients.values()) {
|
|
for (const res of clients) {
|
|
try {
|
|
res.end();
|
|
} catch {}
|
|
}
|
|
}
|
|
sseClients.clear();
|
|
for (const instanceId of [...canvasServers.keys()]) {
|
|
stopCanvasServer(instanceId);
|
|
}
|
|
// Deliberately kept referenced: exit is guaranteed even if a handle the
|
|
// SDK or a dependency owns refuses to close.
|
|
setTimeout(() => process.exit(0), 150);
|
|
}
|
|
|
|
// The stdio pipe to the host is the ground truth: when the app goes away,
|
|
// stdin ends. Signals cover a graceful stop, "disconnect" an IPC parent.
|
|
process.stdin.on("end", shutdown);
|
|
process.stdin.on("close", shutdown);
|
|
process.on("disconnect", shutdown);
|
|
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP", "SIGBREAK"]) {
|
|
try {
|
|
process.on(signal, shutdown);
|
|
} catch {}
|
|
}
|
|
// If the event loop drains naturally instead, still write the deck out.
|
|
process.on("exit", releaseDecksSync);
|
|
|
|
// Nothing refs the event loop until the session join wires up stdio, so
|
|
// hold it open across the join, then hand that job to the stdio pipe.
|
|
const bootKeepalive = setInterval(() => {}, 60000);
|
|
session = await joinSession({ canvases: [canvas] });
|
|
clearInterval(bootKeepalive);
|
|
|
|
// The host also announces the end of the session as a first-class event.
|
|
try {
|
|
session.on("session.shutdown", shutdown);
|
|
} catch {
|
|
// An SDK build without this event type still exits via the stdio hooks.
|
|
}
|