diff --git a/Dockerfile b/Dockerfile index a066006..dfd2dde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,7 @@ COPY apps/server/package.json apps/server/ COPY apps/web/package.json apps/web/ COPY packages/shared/package.json packages/shared/ COPY packages/build/package.json packages/build/ +COPY packages/ui-extensions-sdk/package.json packages/ui-extensions-sdk/ RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false \ --filter @tangent/server... @@ -61,6 +62,7 @@ RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false \ COPY apps/server ./apps/server COPY packages/shared ./packages/shared COPY packages/build ./packages/build +COPY packages/ui-extensions-sdk ./packages/ui-extensions-sdk # Produces apps/server/dist/index.js plus its runtime assets (prompts, agents, # extensions, migrations). Invoked via node directly to avoid pnpm's pre-run diff --git a/Dockerfile.fullstack b/Dockerfile.fullstack index 177a992..5e4ab51 100644 --- a/Dockerfile.fullstack +++ b/Dockerfile.fullstack @@ -58,9 +58,12 @@ COPY apps/web/package.json apps/web/ COPY packages/shared/package.json packages/shared/ COPY packages/build/package.json packages/build/ COPY packages/ui-primitives/package.json packages/ui-primitives/ +COPY packages/ui-extensions-sdk/package.json packages/ui-extensions-sdk/ COPY packages/windows/package.json packages/windows/ COPY packages/analytics/package.json packages/analytics/ COPY packages/utils/package.json packages/utils/ +COPY packages/embed-react/package.json packages/embed-react/ +COPY packages/remote-subagent/package.json packages/remote-subagent/ RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false # Server + UI source plus the shared workspace packages they build against @@ -82,6 +85,15 @@ RUN node apps/server/build.mjs RUN VITE_DEFAULT_SESSION_BUNDLE_ID=__TANGENT_RUNTIME_DEFAULT_SESSION_BUNDLE_ID__ \ pnpm --filter @tangent/web exec vite build +# Build the embedded UI runtime -> apps/web/dist/embed/v1 (served by nginx at +# /embed/). Runs after the main build because its outDir lives under dist/; the +# main build's emptyOutDir would otherwise wipe it. +RUN pnpm --filter @tangent/web exec vite build --config vite.embed.config.ts + +# Fail early if the embed bundle is missing. +RUN test -f apps/web/dist/embed/v1/tangent-elements.js \ + || { echo "missing apps/web/dist/embed/v1/tangent-elements.js" >&2; exit 1; } + # Defense in depth: fail the image build if any extension the server loads at # runtime is missing from the bundle (the build script also asserts this). RUN for f in orchestrator proxyProvider memory triggers; do \ diff --git a/apps/server/src/auth/identity.ts b/apps/server/src/auth/identity.ts index fde6051..ce55997 100644 --- a/apps/server/src/auth/identity.ts +++ b/apps/server/src/auth/identity.ts @@ -52,22 +52,8 @@ function pickString( return ""; } -/** - * Resolves the current {@link UserIdentity} from a raw `Cookie` header. Reads - * the Oktasso JWT from {@link AUTH_JWT_TOKEN_COOKIE_NAME}, decodes its payload - * (no signature check), and maps the email + name claims onto the identity. - * - * Returns `null` when the cookie name is unconfigured, the cookie is missing, - * the token is malformed, or it carries no email. Name claims fall back across - * the OIDC standard (`given_name` / `family_name`) and snake-case - * (`first_name` / `last_name`) variants, defaulting to `""` when absent. - */ -export function resolveUserIdentity( - cookieHeader: string | undefined, -): UserIdentity | null { - if (!AUTH_JWT_TOKEN_COOKIE_NAME) return null; - - const token = parseCookies(cookieHeader)[AUTH_JWT_TOKEN_COOKIE_NAME]; +/** Maps a decoded JWT (no signature check) onto a {@link UserIdentity}. */ +function identityFromToken(token: string | undefined): UserIdentity | null { if (!token) return null; const payload = decodeJwtPayload(token); @@ -81,3 +67,35 @@ export function resolveUserIdentity( last_name: pickString(payload, ["last_name", "family_name"]), }; } + +/** Extracts the token from an `Authorization: Bearer ` header. */ +function bearerToken(header: string | undefined): string | undefined { + const match = /^Bearer\s+(.+)$/i.exec((header ?? "").trim()); + return match?.[1]; +} + +/** + * Resolves the current {@link UserIdentity} from an incoming request's + * credentials. Prefers an `Authorization: Bearer` JWT (the embed passes one + * cross-origin, where cookies are unavailable) and falls back to the Oktasso + * JWT in {@link AUTH_JWT_TOKEN_COOKIE_NAME}. The payload is decoded without a + * signature check and mapped from the email + name claims. + * + * Returns `null` when no token resolves, the token is malformed, or it carries + * no email. The cookie path additionally requires the cookie name to be + * configured; the bearer path does not. Name claims fall back across the OIDC + * standard (`given_name` / `family_name`) and snake-case (`first_name` / + * `last_name`) variants, defaulting to `""` when absent. + */ +export function resolveUserIdentity( + cookieHeader: string | undefined, + authorizationHeader?: string | undefined, +): UserIdentity | null { + const bearer = bearerToken(authorizationHeader); + if (bearer) return identityFromToken(bearer); + + if (!AUTH_JWT_TOKEN_COOKIE_NAME) return null; + return identityFromToken( + parseCookies(cookieHeader)[AUTH_JWT_TOKEN_COOKIE_NAME], + ); +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index dfa13e6..299f6e0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -170,6 +170,15 @@ export const PUBLIC_URL = (process.env.TANGENT_PUBLIC_URL ?? "").replace( */ export const REMOTE_ENV_TOKEN = process.env.REMOTE_ENV_TOKEN ?? ""; +/** + * HMAC key used to mint and verify scoped `/remote-env` tokens for embed hosts. + * Generated per server start unless pinned via env. Independent of + * {@link REMOTE_ENV_TOKEN} (the optional server-to-server shared secret) and of + * {@link INTERNAL_TOKEN} (which Pi children inherit). + */ +export const REMOTE_ENV_SIGNING_SECRET = + process.env.REMOTE_ENV_SIGNING_SECRET ?? randomUUID(); + /** * Secret Tangent presents (as a bearer token) to an attached A2A agent. Unlike * the other connector secrets this one travels outbound, so an empty value is @@ -185,6 +194,17 @@ export const A2A_TOKEN = process.env.A2A_TOKEN ?? ""; export const AUTH_JWT_TOKEN_COOKIE_NAME = process.env.AUTH_JWT_TOKEN_COOKIE_NAME ?? ""; +/** + * Origins allowed to embed the UI cross-origin (the host pages running + * `@tangent/embed-react`). Comma-separated; drives both the `/api` CORS headers + * and the Socket.IO handshake allowlist. Empty by default so a same-origin + * deployment grants no cross-origin trust. + */ +export const EMBED_ALLOWED_ORIGINS = (process.env.EMBED_ALLOWED_ORIGINS ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + /** * Base URL of the Tangle API reached by the bundle-UI/agent egress allowlist. * The OpenAPI doc declares no `servers`, so this is supplied per environment. diff --git a/apps/server/src/connectors/credentials.test.ts b/apps/server/src/connectors/credentials.test.ts index a5bb26a..53f0d0e 100644 --- a/apps/server/src/connectors/credentials.test.ts +++ b/apps/server/src/connectors/credentials.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; import { test } from "node:test"; import { @@ -9,6 +10,7 @@ import { InheritedTokenCredential, mintSecretCredential, PeerBearerCredential, + ScopedTokenCredential, } from "./credentials.ts"; test("a bearer credential accepts only its own token, exactly", () => { @@ -26,12 +28,16 @@ test("an unset secret authorizes nobody, rather than everybody", () => { // refuse every caller, including one presenting the empty string. const bearer = new BearerCredential("internal-bearer", ""); const handshake = new HandshakeTokenCredential(""); + const scoped = new ScopedTokenCredential(""); assert.equal(bearer.configured, false); assert.equal(bearer.verify({ authorization: "Bearer " }), false); assert.equal(handshake.configured, false); assert.equal(handshake.verify({ token: "" }), false); assert.equal(handshake.verify({ token: undefined }), false); + assert.equal(scoped.configured, false); + assert.equal(scoped.verify({ token: "re1.payload.mac" }), false); + assert.equal(scoped.parse("re1.payload.mac"), null); }); test("the handshake credential reads the handshake, not a header", () => { @@ -51,6 +57,7 @@ test("only an inherited credential hands its secret to a spawned child", () => { {}, ); assert.deepEqual(new HandshakeTokenCredential("tok").spawnEnv(), {}); + assert.deepEqual(new ScopedTokenCredential("tok").spawnEnv(), {}); assert.deepEqual(new PeerBearerCredential("tok").spawnEnv(), {}); assert.deepEqual(deniedCredential.spawnEnv(), {}); }); @@ -112,3 +119,78 @@ test("the denied credential authorizes nothing at all", () => { assert.equal(deniedCredential.verify({ authorization: "Bearer x" }), false); assert.equal(deniedCredential.verify({ token: "x" }), false); }); + +const SCOPED_INPUT = { + environmentId: "env-1", + sessionId: "s1", + sub: "user@example.com", +}; + +test("a scoped token round-trips through mint and verify", () => { + const credential = new ScopedTokenCredential("signing-secret"); + const minted = credential.mint(SCOPED_INPUT); + + assert.equal(credential.scheme, "scoped-token"); + assert.equal(credential.verify({ token: minted.token }), true); + assert.equal( + credential.verify({ authorization: `Bearer ${minted.token}` }), + false, + ); + + const claims = credential.parse(minted.token); + assert.ok(claims); + assert.equal(claims.scope, "remote-env"); + assert.equal(claims.environmentId, SCOPED_INPUT.environmentId); + assert.equal(claims.sessionId, SCOPED_INPUT.sessionId); + assert.equal(claims.sub, SCOPED_INPUT.sub); + assert.equal(minted.expiresAt, new Date(claims.exp * 1000).toISOString()); +}); + +test("a scoped token with a tampered payload is refused", () => { + const credential = new ScopedTokenCredential("signing-secret"); + const { token } = credential.mint(SCOPED_INPUT); + const [prefix, payload, mac] = token.split("."); + const claims = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as Record; + claims.sessionId = "other-session"; + const tampered = Buffer.from(JSON.stringify(claims)).toString("base64url"); + + assert.equal( + credential.verify({ token: `${prefix}.${tampered}.${mac}` }), + false, + ); +}); + +test("an expired scoped token is refused", () => { + const credential = new ScopedTokenCredential("signing-secret", 0); + const { token } = credential.mint(SCOPED_INPUT); + + assert.equal(credential.verify({ token }), false); + assert.equal(credential.parse(token), null); +}); + +test("a scoped token with the wrong scope is refused", () => { + const secret = "signing-secret"; + const credential = new ScopedTokenCredential(secret); + const payload = Buffer.from( + JSON.stringify({ + scope: "other", + environmentId: "env-1", + sessionId: "s1", + sub: "user@example.com", + iat: 1, + exp: 4_000_000_000, + }), + ).toString("base64url"); + const mac = createHmac("sha256", secret).update(payload).digest("base64url"); + + assert.equal(credential.verify({ token: `re1.${payload}.${mac}` }), false); +}); + +test("minting is refused when the scoped signing secret is unset", () => { + assert.throws( + () => new ScopedTokenCredential("").mint(SCOPED_INPUT), + /not configured/, + ); +}); diff --git a/apps/server/src/connectors/credentials.ts b/apps/server/src/connectors/credentials.ts index 93e7b8c..262e41a 100644 --- a/apps/server/src/connectors/credentials.ts +++ b/apps/server/src/connectors/credentials.ts @@ -1,8 +1,13 @@ -import { randomBytes } from "node:crypto"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import type { CredentialScheme } from "@tangent/shared/contracts.ts"; -import { A2A_TOKEN, INTERNAL_TOKEN, REMOTE_ENV_TOKEN } from "../config.ts"; +import { + A2A_TOKEN, + INTERNAL_TOKEN, + REMOTE_ENV_SIGNING_SECRET, + REMOTE_ENV_TOKEN, +} from "../config.ts"; /** Env var a spawned Pi child reads its inherited credential from. */ const INHERITED_TOKEN_VAR = "TANGENT_INTERNAL_TOKEN"; @@ -10,6 +15,12 @@ const INHERITED_TOKEN_VAR = "TANGENT_INTERNAL_TOKEN"; /** Bytes of entropy in a minted per-subject secret. */ const MINTED_SECRET_BYTES = 24; +/** Prefix so a scoped token cannot collide with a raw shared-secret UUID. */ +const SCOPED_TOKEN_PREFIX = "re1"; + +/** Lifetime of a minted scoped remote-env token. */ +export const SCOPED_TOKEN_TTL_MS = 60 * 60 * 1000; + /** * What a caller presented, whatever transport it arrived on. Both fields are * optional because a credential reads only the one its scheme uses — a socket @@ -112,6 +123,180 @@ export class HandshakeTokenCredential implements ConnectorCredential { } } +/** Claims encoded in a scoped remote-env token. */ +export interface ScopedTokenClaims { + scope: "remote-env"; + environmentId: string; + sessionId: string; + sub: string; + iat: number; + exp: number; +} + +/** Inputs {@link ScopedTokenCredential.mint} signs into a token. */ +export interface MintScopedTokenInput { + environmentId: string; + sessionId: string; + sub: string; +} + +/** A minted scoped token and the instant it stops verifying. */ +export interface MintedScopedToken { + token: string; + expiresAt: string; +} + +/** HMAC-SHA256 of `payload` using `secret`, encoded base64url. */ +function scopedMac(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +/** Constant-time compare of two base64url MAC strings. */ +function macEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** Reads a non-empty string field, or `null`. */ +function stringClaim( + record: Record, + key: string, +): string | null { + const value = record[key]; + if (typeof value !== "string" || !value) return null; + return value; +} + +/** Reads a finite number field, or `null`. */ +function unixClaim( + record: Record, + key: string, +): number | null { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return value; +} + +/** Narrows decoded JSON to an object record. */ +function asRecord(raw: unknown): Record | null { + if (typeof raw !== "object" || raw === null) return null; + return raw as Record; +} + +/** Reads the string claims of a scoped token payload. */ +function readStringClaims( + record: Record, +): Pick | null { + if (record.scope !== "remote-env") return null; + const environmentId = stringClaim(record, "environmentId"); + const sessionId = stringClaim(record, "sessionId"); + const sub = stringClaim(record, "sub"); + if (!environmentId || !sessionId || !sub) return null; + return { environmentId, sessionId, sub }; +} + +/** Parses and validates the JSON claims object from a scoped token payload. */ +function claimsFromUnknown(raw: unknown): ScopedTokenClaims | null { + const record = asRecord(raw); + if (!record) return null; + const strings = readStringClaims(record); + if (!strings) return null; + const iat = unixClaim(record, "iat"); + const exp = unixClaim(record, "exp"); + if (iat === null || exp === null) return null; + return { scope: "remote-env", ...strings, iat, exp }; +} + +/** Decodes a base64url payload segment into claims, or `null` if malformed. */ +function decodeClaims(payload: string): ScopedTokenClaims | null { + try { + const json = Buffer.from(payload, "base64url").toString("utf8"); + return claimsFromUnknown(JSON.parse(json) as unknown); + } catch { + return null; + } +} + +/** Splits a `re1.payload.mac` token into verified-shape segments. */ +function splitScopedToken( + token: string | undefined, +): { payload: string; mac: string } | null { + if (!token) return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + const [prefix, payload, mac] = parts; + if (prefix !== SCOPED_TOKEN_PREFIX) return null; + if (!payload || !mac) return null; + return { payload, mac }; +} + +/** Drops claims that are missing or past `exp`. */ +function liveClaims( + claims: ScopedTokenClaims | null, +): ScopedTokenClaims | null { + if (!claims) return null; + if (claims.exp <= Math.floor(Date.now() / 1000)) return null; + return claims; +} + +/** + * A short-lived HMAC token minted for one embed host + session, presented in + * the Socket.IO handshake. The gateway derives `environmentId` and `sessionId` + * from the claims rather than trusting the handshake fields. + */ +export class ScopedTokenCredential implements ConnectorCredential { + readonly scheme: CredentialScheme = "scoped-token"; + private readonly secret: string; + private readonly ttlMs: number; + + constructor(secret: string, ttlMs: number = SCOPED_TOKEN_TTL_MS) { + this.secret = secret; + this.ttlMs = ttlMs; + } + + get configured(): boolean { + return this.secret.length > 0; + } + + mint(input: MintScopedTokenInput): MintedScopedToken { + if (!this.configured) { + throw new Error("Scoped remote-env tokens are not configured."); + } + const iat = Math.floor(Date.now() / 1000); + const exp = iat + Math.floor(this.ttlMs / 1000); + const claims: ScopedTokenClaims = { + scope: "remote-env", + environmentId: input.environmentId, + sessionId: input.sessionId, + sub: input.sub, + iat, + exp, + }; + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const token = `${SCOPED_TOKEN_PREFIX}.${payload}.${scopedMac(payload, this.secret)}`; + return { token, expiresAt: new Date(exp * 1000).toISOString() }; + } + + parse(token: string | undefined): ScopedTokenClaims | null { + if (!this.configured) return null; + const parts = splitScopedToken(token); + if (!parts) return null; + if (!macEqual(parts.mac, scopedMac(parts.payload, this.secret))) + return null; + return liveClaims(decodeClaims(parts.payload)); + } + + verify(presented: CredentialPresentation): boolean { + return this.parse(presented.token) !== null; + } + + spawnEnv(): Record { + return {}; + } +} + /** * A secret minted for one subject rather than for the server: the peer is told * it when its channel is opened, and it opens nothing else. This is the scheme @@ -196,6 +381,11 @@ export const remoteEnvCredential = new HandshakeTokenCredential( REMOTE_ENV_TOKEN, ); +/** Embed hosts: a per-session HMAC token minted by `POST /api/embed/remote-env-token`. */ +export const scopedRemoteEnvCredential = new ScopedTokenCredential( + REMOTE_ENV_SIGNING_SECRET, +); + /** External registrants: the same internal token, presented as a bearer. */ export const externalCredential = new BearerCredential( "internal-bearer", diff --git a/apps/server/src/connectors/nullConnector.ts b/apps/server/src/connectors/nullConnector.ts index faac312..657a8f1 100644 --- a/apps/server/src/connectors/nullConnector.ts +++ b/apps/server/src/connectors/nullConnector.ts @@ -43,6 +43,14 @@ export class NullConnector implements Connector { } deliver(request: DeliveryRequest): DeliveryResult { + console.warn( + "[nullConnector] refusing delivery: no connector holds participant", + { + sessionId: request.sessionId, + participantId: request.participantId, + conversationId: request.conversationId, + }, + ); return refuseDelivery(this.handlers, request, NOT_AVAILABLE); } diff --git a/apps/server/src/conversation/hostResources.test.ts b/apps/server/src/conversation/hostResources.test.ts new file mode 100644 index 0000000..18348cd --- /dev/null +++ b/apps/server/src/conversation/hostResources.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root + global memory dir at throwaway dirs before importing +// modules that read config at load time, so writes never touch the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "host-res-")); +const MEM = mkdtempSync(path.join(tmpdir(), "host-mem-")); +process.env.SESSIONS_ROOT = ROOT; +process.env.GLOBAL_MEMORY_DIR = MEM; + +const { applyResourceInput, memoryScopeFromUri, removeResourceByUri } = + await import("./hostResources.ts"); +const { ResourceCatalog } = await import("./resourceCatalog.ts"); +const { MemoryManager } = await import("../pi/memory.ts"); +const { HostResourcePreamble } = await import("../pi/hostResourcePreamble.ts"); +const { InMemoryResourceStore } = + await import("../store/inMemoryResourceStore.ts"); +const { MEMORY_AUTHOR } = await import("@tangent/shared/contracts.ts"); + +import type { SessionStore } from "../store/sessionStore.ts"; + +// No roster rows, so `orchestratorConversationFor` resolves to the default +// "prime" conversation — enough for the catalog to reference into. +const store = { listAgents: async () => [] } as unknown as SessionStore; + +after(() => { + rmSync(ROOT, { recursive: true, force: true }); + rmSync(MEM, { recursive: true, force: true }); +}); + +function deps() { + const catalog = new ResourceCatalog(new InMemoryResourceStore()); + return { store, memory: new MemoryManager(), catalog }; +} + +function sessionRoot() { + return mkdtempSync(path.join(ROOT, "s-")); +} + +test("memoryScopeFromUri maps the memory uris and nothing else", () => { + assert.equal(memoryScopeFromUri("memory://session"), "session"); + assert.equal(memoryScopeFromUri("memory://global"), "global"); + assert.equal(memoryScopeFromUri("https://x/pipelines/1"), null); +}); + +test("a memory input writes MEMORY.md and catalogs memory://session", async () => { + const d = deps(); + const rootPath = sessionRoot(); + + const resource = await applyResourceInput(d, "sess1", rootPath, { + kind: "memory", + scope: "session", + content: "Prefer concise plans.", + }); + + assert.equal(resource.kind, "memory"); + assert.equal(resource.uri, "memory://session"); + assert.equal(resource.name, "Session memory"); + assert.equal(resource.authorParticipantId, MEMORY_AUTHOR.id); + + const file = readFileSync(path.join(rootPath, "MEMORY.md"), "utf8"); + assert.match(file, /Prefer concise plans\./); + + const listed = await d.catalog.listForSession("sess1"); + assert.equal(listed.length, 1); + assert.equal(listed[0].uri, "memory://session"); +}); + +test("a host input catalogs a host row carrying its free-form meta", async () => { + const d = deps(); + const rootPath = sessionRoot(); + + const resource = await applyResourceInput( + d, + "sess2", + rootPath, + { + kind: "host", + name: "Orders pipeline", + uri: "https://tangent.example/pipelines/orders", + meta: { + url: "https://tangent.example/pipelines/orders", + description: "Ingests orders and flags anomalies.", + }, + }, + "ben@example.com", + ); + + assert.equal(resource.kind, "host"); + assert.equal(resource.name, "Orders pipeline"); + assert.equal(resource.authorParticipantId, "ben@example.com"); + assert.deepEqual(resource.meta, { + url: "https://tangent.example/pipelines/orders", + description: "Ingests orders and flags anomalies.", + }); +}); + +test("re-adding the same host uri upserts rather than duplicating", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const uri = "https://tangent.example/pipelines/orders"; + + await applyResourceInput(d, "sess3", rootPath, { + kind: "host", + name: "Orders", + uri, + }); + await applyResourceInput(d, "sess3", rootPath, { + kind: "host", + name: "Orders (renamed)", + uri, + }); + + const listed = await d.catalog.listForSession("sess3"); + assert.equal(listed.length, 1); + assert.equal(listed[0].name, "Orders (renamed)"); +}); + +test("removing a host resource drops the row and leaves memory untouched", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const uri = "https://tangent.example/pipelines/orders"; + await applyResourceInput(d, "sess4", rootPath, { + kind: "host", + name: "Orders", + uri, + }); + + await removeResourceByUri(d, "sess4", rootPath, uri); + + assert.equal((await d.catalog.listForSession("sess4")).length, 0); +}); + +test("seeded resources are visible to the spawn preambles before ensure", async () => { + const d = deps(); + const rootPath = sessionRoot(); + await applyResourceInput(d, "sess6", rootPath, { + kind: "memory", + scope: "session", + content: "Prefer concise plans.", + }); + await applyResourceInput(d, "sess6", rootPath, { + kind: "host", + name: "Orders pipeline", + uri: "https://tangent.example/orders", + meta: { description: "Ingests orders." }, + }); + + assert.match(d.memory.buildPreamble(rootPath), /Prefer concise plans\./); + + const preamble = new HostResourcePreamble(d.catalog); + await preamble.refresh("sess6"); + const text = preamble.get("sess6"); + assert.match(text, /Orders pipeline/); + assert.match(text, /https:\/\/tangent\.example\/orders/); + assert.match(text, /Ingests orders\./); +}); + +test("removing a memory resource clears the store and drops the row", async () => { + const d = deps(); + const rootPath = sessionRoot(); + await applyResourceInput(d, "sess5", rootPath, { + kind: "memory", + scope: "session", + content: "remember-me-secret", + }); + + await removeResourceByUri(d, "sess5", rootPath, "memory://session"); + + const file = readFileSync(path.join(rootPath, "MEMORY.md"), "utf8"); + assert.doesNotMatch(file, /remember-me-secret/); + assert.equal((await d.catalog.listForSession("sess5")).length, 0); +}); diff --git a/apps/server/src/conversation/hostResources.ts b/apps/server/src/conversation/hostResources.ts new file mode 100644 index 0000000..2fb8b11 --- /dev/null +++ b/apps/server/src/conversation/hostResources.ts @@ -0,0 +1,100 @@ +import { + type HostResourceInput, + MEMORY_AUTHOR, + type MemoryScope, +} from "@tangent/shared/contracts.ts"; + +import type { MemoryManager } from "../pi/memory.ts"; +import type { CatalogInput, Resource } from "../store/resourceStore.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { orchestratorConversationFor } from "./participantRegistry.ts"; +import type { ResourceCatalog } from "./resourceCatalog.ts"; + +/** Everything a host-resource write needs beyond the input. */ +export interface ResourceSeedDeps { + store: SessionStore; + memory: MemoryManager; + catalog: ResourceCatalog; +} + +/** The catalog entry a memory store stands for, keyed by its scope. */ +function memoryResource(sessionId: string, scope: MemoryScope): CatalogInput { + return { + sessionId, + kind: "memory", + name: scope === "global" ? "Global memory" : "Session memory", + uri: `memory://${scope}`, + authorParticipantId: MEMORY_AUTHOR.id, + meta: { scope }, + }; +} + +/** The catalog entry a host-provided resource stands for. */ +function hostResource( + sessionId: string, + input: Extract, + authorParticipantId: string | undefined, +): CatalogInput { + return { + sessionId, + kind: "host", + name: input.name, + uri: input.uri, + authorParticipantId, + meta: input.meta, + }; +} + +/** The memory scope a `memory://` uri names, or null for any other uri. */ +export function memoryScopeFromUri(uri: string): MemoryScope | null { + if (uri === "memory://session") return "session"; + if (uri === "memory://global") return "global"; + return null; +} + +/** + * Applies one host-provided resource to a session: writes the memory store (for + * a `memory` input, so the file the agent reads is the write authority) and + * references the entry into the orchestrator's Conversation so it surfaces. + * Returns the catalog entry; idempotent per uri. + */ +export async function applyResourceInput( + deps: ResourceSeedDeps, + sessionId: string, + rootPath: string, + input: HostResourceInput, + authorParticipantId?: string, +): Promise { + const conversationId = await orchestratorConversationFor( + deps.store, + sessionId, + ); + if (input.kind === "memory") { + const scope = input.scope ?? "session"; + deps.memory.write(rootPath, scope, input.content); + return deps.catalog.catalogIn( + conversationId, + memoryResource(sessionId, scope), + ); + } + return deps.catalog.catalogIn( + conversationId, + hostResource(sessionId, input, authorParticipantId), + ); +} + +/** + * Removes a catalogued resource by uri, cascading its references. Removing a + * memory store's entry also empties the store, so `read_memory` and the catalog + * agree. + */ +export async function removeResourceByUri( + deps: ResourceSeedDeps, + sessionId: string, + rootPath: string, + uri: string, +): Promise { + await deps.catalog.remove(sessionId, uri); + const scope = memoryScopeFromUri(uri); + if (scope) deps.memory.clear(rootPath, scope); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 65af5de..dae82d3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,11 +2,12 @@ import "./loadEnv.ts"; import { createServer } from "node:http"; +import { SocketEvents } from "@tangent/shared/contracts.ts"; import express from "express"; import { Server as SocketIOServer } from "socket.io"; import { A2aPeerGateway } from "./a2a/a2aPeerGateway.ts"; -import { PORT } from "./config.ts"; +import { EMBED_ALLOWED_ORIGINS, PORT } from "./config.ts"; import { createConnectorRegistry } from "./connectors/connectorRegistry.ts"; import { ConversationRouter } from "./conversation/conversationRouter.ts"; import { MembershipRegistry } from "./conversation/membershipRegistry.ts"; @@ -16,7 +17,9 @@ import { ResourceCatalog } from "./conversation/resourceCatalog.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { createRelayReport } from "./mcp/relayReport.ts"; +import { createEmbedCors } from "./middleware/embedCors.ts"; import { errorHandler } from "./middleware/errorHandler.ts"; +import { HostResourcePreamble } from "./pi/hostResourcePreamble.ts"; import { MemoryManager } from "./pi/memory.ts"; import { type ConversationEventSink, @@ -26,12 +29,15 @@ import { TriggerEngine } from "./pi/triggers/triggerEngine.ts"; import { TriggerManager } from "./pi/triggers/triggerManager.ts"; import { RemoteEnvironmentGateway } from "./remote/remoteEnvironmentGateway.ts"; import { createAgentBundlesRouter } from "./routes/agentBundles.ts"; +import { createEmbedRouter } from "./routes/embed.ts"; import { createGlobalMemoryRouter } from "./routes/globalMemory.ts"; import { createInternalAgentsRouter } from "./routes/internalAgents.ts"; import { createInternalEgressRouter } from "./routes/internalEgress.ts"; import { createInternalExternalAgentsRouter } from "./routes/internalExternalAgents.ts"; import { createInternalMcpRelayRouter } from "./routes/internalMcpRelay.ts"; import { createInternalMemoryRouter } from "./routes/internalMemory.ts"; +import { createInternalRemoteToolsRouter } from "./routes/internalRemoteTools.ts"; +import { createInternalResourcesRouter } from "./routes/internalResources.ts"; import { createInternalSessionRouter } from "./routes/internalSession.ts"; import { createInternalTriggersRouter } from "./routes/internalTriggers.ts"; import { createMcpRelayRouter } from "./routes/mcp.ts"; @@ -53,6 +59,7 @@ import { createParticipantPresenceEmitter, PresenceTracker, } from "./sockets/presenceTracker.ts"; +import { roomFor } from "./sockets/rooms.ts"; import { createUiCommandEmitter } from "./sockets/sessionRoster.ts"; import { openDb } from "./store/db/client.ts"; import { FileAgentBundleStore } from "./store/fileAgentBundleStore.ts"; @@ -80,13 +87,17 @@ const store = new SqliteSessionStore(db, participants, resourceStore); const agentBundleStore = new FileAgentBundleStore(); const app = express(); +// Cross-origin embed hosts (allowlisted via EMBED_ALLOWED_ORIGINS) need CORS on +// /api; runs before body parsing so preflight OPTIONS short-circuit cheaply. +app.use(createEmbedCors(EMBED_ALLOWED_ORIGINS)); app.use(express.json()); const httpServer = createServer(app); const io = new SocketIOServer(httpServer, { - // In dev the UI is served by Vite and proxied here, so same-origin. CORS is - // left open to ease direct connections during local development. - cors: { origin: true }, + // Same allowlist as /api. In dev the UI is proxied by Vite (same-origin), so + // an empty allowlist reflects any origin to ease direct local connections; + // set EMBED_ALLOWED_ORIGINS to pin the handshake to embed hosts. + cors: { origin: EMBED_ALLOWED_ORIGINS.length ? EMBED_ALLOWED_ORIGINS : true }, }); // Owns the agents' global + per-session memory stores. @@ -101,6 +112,12 @@ const onMemorySuggestion = createMemorySuggestionHandler(io); // Pushes generic agent->UI directives (e.g. session rename) to the room. const emitUiCommand = createUiCommandEmitter(io); +// Signals a session's room that its resource catalog changed, so open clients +// refetch. Host resource CRUD has no ChatMessage to piggyback on. +const emitResourcesUpdated = (sessionId: string): void => { + io.to(roomFor(sessionId)).emit(SocketEvents.ResourcesUpdated, { sessionId }); +}; + // Who is in each Conversation and what each of them reacts to. Rows are derived // from the agent roster on a cache miss, so a session the backfill never touched // still resolves; `acceptsDelivery` is read lazily because the registry it comes @@ -120,6 +137,9 @@ const participantRegistry = new ParticipantRegistry(store, participants); // call, a finalized agent turn — goes through it. It also mirrors the content a // Message carries (attachments, memory writes) into the resource catalog. const resourceCatalog = new ResourceCatalog(resourceStore); +// Spawn-time projection of each session's host resources, appended to every +// agent's preamble and refreshed on every resource mutation. +const hostResourcePreamble = new HostResourcePreamble(resourceCatalog); const conversations = new ConversationRouter( io, store, @@ -155,7 +175,12 @@ void store.detachActiveSubagents().then((detached) => { // The manager runs a roster of Pi processes per session (Prime + sub-agents); // their streaming events and roster changes are relayed to the matching // Socket.IO room by the chat handlers. -const pi = new PiAgentManager(agentHandlers, memory, runs); +const pi = new PiAgentManager( + agentHandlers, + memory, + runs, + hostResourcePreamble, +); // Hosts sub-agents inside a connected remote environment over the `/remote-env` // namespace. Remote sub-agents share the same event sink as local ones, so what @@ -278,6 +303,9 @@ app.use( agentBundleStore, participantService, resourceCatalog, + memory, + hostResourcePreamble, + emitResourcesUpdated, ), ); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); @@ -286,6 +314,7 @@ app.use("/api/global-memory", createGlobalMemoryRouter(memory)); app.use("/api/mcp", createMcpRelayRouter(mcpRelay, relayReport)); // Returns the current user, derived from the Oktasso JWT cookie. app.use("/api/me", createMeRouter()); +app.use("/api/embed", createEmbedRouter(store)); // Internal API for the orchestrator extension running inside each Pi process. app.use( "/internal/agents", @@ -320,11 +349,22 @@ app.use( onMemorySuggestion, ), ); +// Internal API for the resources extension running inside each Pi process. +app.use( + "/internal/resources", + createInternalResourcesRouter(store, resourceCatalog), +); // Internal API for the session extension running inside each Pi process. app.use("/internal/session", createInternalSessionRouter(store, emitUiCommand)); // Internal API for bundle extensions to open/answer/close generic MCP relay // channels bound to their session (remote-runtime specifics stay in the bundle). app.use("/internal/mcp-relay", createInternalMcpRelayRouter(mcpRelay, store)); +// Internal API for the remote-tools extension: list and invoke the RPC tools a +// connected remote environment offers, without spawning a browser sub-agent. +app.use( + "/internal/remote-tools", + createInternalRemoteToolsRouter(remoteGateway), +); // Mounted last: async failures from any handler above land here with a // consistent `{ error }` shape (Express 5 forwards rejected promises to it). diff --git a/apps/server/src/middleware/embedCors.ts b/apps/server/src/middleware/embedCors.ts new file mode 100644 index 0000000..2a28bb1 --- /dev/null +++ b/apps/server/src/middleware/embedCors.ts @@ -0,0 +1,29 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +const ALLOWED_METHODS = "GET,POST,PATCH,PUT,DELETE,OPTIONS"; +const ALLOWED_HEADERS = "Authorization, Content-Type"; + +/** + * CORS for cross-origin embed hosts. Emits `Access-Control-*` headers only when + * the request's `Origin` is in the allowlist — echoing the specific origin, + * which is required because the embed sends an `Authorization` header — and + * short-circuits preflight `OPTIONS`. With an empty allowlist it is a no-op, so + * a same-origin deployment is unchanged. + */ +export function createEmbedCors(allowedOrigins: string[]): RequestHandler { + const allowed = new Set(allowedOrigins); + return (req: Request, res: Response, next: NextFunction): void => { + const origin = req.headers.origin; + if (origin && allowed.has(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", ALLOWED_METHODS); + res.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS); + } + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); + }; +} diff --git a/apps/server/src/pi/agentConfig.ts b/apps/server/src/pi/agentConfig.ts index e4be5e9..81017db 100644 --- a/apps/server/src/pi/agentConfig.ts +++ b/apps/server/src/pi/agentConfig.ts @@ -44,12 +44,16 @@ export const PRIME_ORCHESTRATION_TOOLS = [ * all agents since Pi's `--tools` filter would otherwise strip the extension's * tool from sub-agents. `pin_artifact` is registered by the session extension * for every agent, so it must be in the allowlist or Pi would filter it out. + * `list_remote_tools` / `call_remote_tool` are registered by the remote-tools + * extension for every agent, so both must be granted or Pi would strip them. */ export const SHARED_AGENT_TOOLS = [ "read_room", "read_memory", "message_prime", "pin_artifact", + "list_remote_tools", + "call_remote_tool", ] as const; /** diff --git a/apps/server/src/pi/extensions/remoteTools.ts b/apps/server/src/pi/extensions/remoteTools.ts new file mode 100644 index 0000000..a16c387 --- /dev/null +++ b/apps/server/src/pi/extensions/remoteTools.ts @@ -0,0 +1,145 @@ +// @ts-nocheck +/** + * Remote-tools dispatcher extension loaded into every session Pi process via + * `--extension`. + * + * Like the orchestrator/memory extensions, this is authored against Pi's + * extension runtime (it imports modules Pi resolves when loading extensions, + * e.g. `typebox`), not this repo's `node_modules`. It is excluded from our + * type-check (`@ts-nocheck`) and never imported by the server — only passed as a + * path to the Pi subprocess. + * + * A "remote tool" is a named async function hosted by a connected remote + * environment (e.g. a browser embed) and invoked over the `/remote-env` + * WebSocket — no second LLM, no browser sub-agent. Pi freezes its `--tools` + * allowlist at spawn, and the host usually connects after Prime is already + * running, so this ships a stable two-tool dispatcher instead of first-class + * per-host tool names: + * - `list_remote_tools` — the current catalog (changes as hosts connect/leave). + * - `call_remote_tool` — invoke one by name with JSON arguments. + * + * Both are thin clients over this server's internal remote-tools API; the + * gateway owns the socket and routes the call to the session's environment. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const SESSION_ID = process.env.TANGENT_SESSION_ID ?? ""; +const AGENT_ID = process.env.TANGENT_AGENT_ID ?? ""; +const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? ""; +const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? ""; + +async function callApi( + method: "GET" | "POST", + endpoint: string, + body?: Record, + query?: Record, +): Promise { + const url = new URL(`${INTERNAL_URL}/internal/remote-tools/${endpoint}`); + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, value); + } + + const response = await fetch(url, { + method, + headers: { + "content-type": "application/json", + authorization: `Bearer ${INTERNAL_TOKEN}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `internal API ${endpoint} failed (${response.status}): ${text}`, + ); + } + return response.json(); +} + +function textResult(text: string) { + return { content: [{ type: "text", text }], details: {} }; +} + +/** Renders one JSON-serializable tool result as readable text for the agent. */ +function renderResult(value: unknown): string { + if (value === undefined || value === null) return "(no result)"; + if (typeof value === "string") return value; + return JSON.stringify(value, null, 2); +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "list_remote_tools", + label: "List Remote Tools", + description: + "List the tools the connected host environment currently offers (e.g. a " + + "browser embedding this session). The catalog is dynamic: it appears when " + + "a host connects and is empty when none is. Call this before " + + "call_remote_tool to see what is available and each tool's arguments.", + promptSnippet: "List the RPC tools the connected host offers", + parameters: Type.Object({}), + async execute() { + const data = (await callApi("GET", "list", undefined, { + sessionId: SESSION_ID, + })) as { + tools: Array<{ + name: string; + description: string; + inputSchema: unknown; + }>; + }; + + if (!data.tools.length) { + return textResult( + "No host environment is connected, so there are no remote tools right now.", + ); + } + const lines = data.tools.map( + (tool) => + `- ${tool.name}: ${tool.description}\n arguments: ${JSON.stringify(tool.inputSchema)}`, + ); + return textResult(lines.join("\n")); + }, + }); + + pi.registerTool({ + name: "call_remote_tool", + label: "Call Remote Tool", + description: + "Invoke one tool offered by the connected host environment by name, " + + "passing its arguments as a JSON object. Call list_remote_tools first to " + + "learn the available names and each tool's argument schema. Returns the " + + "host's result. Fails clearly if no host is connected or the name is " + + "unknown.", + promptSnippet: "Invoke a host-provided remote tool by name", + parameters: Type.Object({ + name: Type.String({ + description: "The tool name from list_remote_tools.", + }), + arguments: Type.Optional( + Type.Unknown({ + description: + "The tool's arguments as a JSON object matching its inputSchema.", + }), + ), + }), + async execute(_toolCallId, params) { + const data = (await callApi("POST", "call", { + sessionId: SESSION_ID, + agentId: AGENT_ID, + name: params.name, + arguments: params.arguments ?? {}, + })) as { ok: boolean; result?: unknown; error?: string }; + + if (!data.ok) { + return textResult( + `Remote tool "${params.name}" failed: ${data.error ?? "unknown error"}`, + ); + } + return textResult(renderResult(data.result)); + }, + }); +} diff --git a/apps/server/src/pi/extensions/resources.ts b/apps/server/src/pi/extensions/resources.ts new file mode 100644 index 0000000..53c335d --- /dev/null +++ b/apps/server/src/pi/extensions/resources.ts @@ -0,0 +1,76 @@ +// @ts-nocheck +/** + * Resources extension loaded into every session Pi process via `--extension`. + * + * Like the other extensions, this is authored against Pi's extension runtime + * (it imports modules Pi resolves when loading extensions, e.g. `typebox`), not + * this repo's `node_modules`. It is excluded from our type-check (`@ts-nocheck`) + * and never imported by the server — only passed as a path to the Pi subprocess. + * + * The host resources the shell attaches are baked into the spawn preamble, but + * the host can add or remove them mid-session. This gives every agent a + * `read_resources` tool to re-read the current set before relying on it. It is a + * thin client over this server's internal resources API. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const SESSION_ID = process.env.TANGENT_SESSION_ID ?? ""; +const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? ""; +const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? ""; + +interface HostResource { + name: string; + uri: string; + meta?: Record; +} + +async function readResources(): Promise { + const url = new URL(`${INTERNAL_URL}/internal/resources/read`); + url.searchParams.set("sessionId", SESSION_ID); + + const response = await fetch(url, { + headers: { authorization: `Bearer ${INTERNAL_TOKEN}` }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `internal API resources/read failed (${response.status}): ${text}`, + ); + } + const data = (await response.json()) as { resources: HostResource[] }; + return data.resources ?? []; +} + +function render(resources: HostResource[]): string { + if (resources.length === 0) return "(no host resources)"; + return resources + .map((resource) => { + const meta = resource.meta ? `\n ${JSON.stringify(resource.meta)}` : ""; + return `- ${resource.name} (${resource.uri})${meta}`; + }) + .join("\n"); +} + +function textResult(text: string) { + return { content: [{ type: "text", text }], details: {} }; +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "read_resources", + label: "Read Resources", + description: + "Read the host resources the embedding app attached to this session " + + "(e.g. known pipelines). These can change during the session, so re-read " + + "before relying on or citing them rather than trusting your initial " + + "context.", + promptSnippet: "Read the host-attached resources for this session", + parameters: Type.Object({}), + async execute() { + const resources = await readResources(); + return textResult(`## Host resources\n\n${render(resources)}`); + }, + }); +} diff --git a/apps/server/src/pi/hostResourcePreamble.test.ts b/apps/server/src/pi/hostResourcePreamble.test.ts new file mode 100644 index 0000000..0ac6207 --- /dev/null +++ b/apps/server/src/pi/hostResourcePreamble.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { ResourceCatalog } from "../conversation/resourceCatalog.ts"; +import type { Resource } from "../store/resourceStore.ts"; +import { + HostResourcePreamble, + renderHostResourcesPreamble, +} from "./hostResourcePreamble.ts"; + +function resource(overrides: Partial): Resource { + return { + id: overrides.id ?? "r1", + sessionId: "s1", + kind: "host", + name: "Orders pipeline", + uri: "https://tangent.example/pipelines/orders", + createdAt: new Date().toISOString(), + ...overrides, + } as Resource; +} + +test("renderHostResourcesPreamble is empty when no host resources", () => { + assert.equal(renderHostResourcesPreamble([]), ""); + assert.equal(renderHostResourcesPreamble([resource({ kind: "memory" })]), ""); +}); + +test("renderHostResourcesPreamble lists each host row's name, uri, and description", () => { + const text = renderHostResourcesPreamble([ + resource({ + meta: { description: "Ingests orders and flags anomalies." }, + }), + ]); + assert.match(text, /## Host resources/); + assert.match(text, /Orders pipeline/); + assert.match(text, /https:\/\/tangent\.example\/pipelines\/orders/); + assert.match(text, /Ingests orders and flags anomalies\./); + assert.match(text, /read_resources/); +}); + +test("HostResourcePreamble.get reflects the catalog after refresh", async () => { + const hosts = [resource({})]; + const catalog = { + listForSession: async () => hosts, + } as unknown as ResourceCatalog; + const preamble = new HostResourcePreamble(catalog); + + assert.equal(preamble.get("s1"), ""); + await preamble.refresh("s1"); + assert.match(preamble.get("s1"), /Orders pipeline/); + + preamble.forget("s1"); + assert.equal(preamble.get("s1"), ""); +}); diff --git a/apps/server/src/pi/hostResourcePreamble.ts b/apps/server/src/pi/hostResourcePreamble.ts new file mode 100644 index 0000000..9156f2b --- /dev/null +++ b/apps/server/src/pi/hostResourcePreamble.ts @@ -0,0 +1,64 @@ +import type { ResourceCatalog } from "../conversation/resourceCatalog.ts"; +import type { Resource } from "../store/resourceStore.ts"; + +/** The `description` a host resource's meta carries, when it has one. */ +function description(meta: Record | undefined): string | null { + if (!meta) return null; + const value = meta.description; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Renders the `## Host resources` system-prompt block from a session's catalog, + * or `""` when it holds no host resources. Only `host` entries appear here; + * memory is carried by the memory preamble. + */ +export function renderHostResourcesPreamble(resources: Resource[]): string { + const hosts = resources.filter((resource) => resource.kind === "host"); + if (hosts.length === 0) return ""; + + const lines = [ + "## Host resources", + "", + "The host app attached the following resources as standing context. They can", + "change during the session — call `read_resources` to re-read the current", + "set before relying on them.", + "", + ]; + for (const host of hosts) { + lines.push(`- ${host.name} (${host.uri})`); + const desc = description(host.meta); + if (desc) lines.push(` ${desc}`); + } + return lines.join("\n"); +} + +/** + * Spawn-time projection of a session's host resources. The catalog is the store; + * this caches the rendered preamble so the synchronous spawn path can read it, + * and is refreshed on every resource mutation. + */ +export class HostResourcePreamble { + private readonly bySession = new Map(); + private readonly catalog: ResourceCatalog; + + constructor(catalog: ResourceCatalog) { + this.catalog = catalog; + } + + /** Rebuilds a session's cached preamble from the catalog. */ + async refresh(sessionId: string): Promise { + const resources = await this.catalog.listForSession(sessionId); + this.bySession.set(sessionId, renderHostResourcesPreamble(resources)); + } + + /** The cached preamble text (empty when none), read synchronously at spawn. */ + get(sessionId: string): string { + return this.bySession.get(sessionId) ?? ""; + } + + /** Drops a session's cached preamble (on session delete). */ + forget(sessionId: string): void { + this.bySession.delete(sessionId); + } +} diff --git a/apps/server/src/pi/memory.ts b/apps/server/src/pi/memory.ts index 0a1c1a2..101683c 100644 --- a/apps/server/src/pi/memory.ts +++ b/apps/server/src/pi/memory.ts @@ -205,6 +205,28 @@ export class MemoryManager { : this.writeSession(rootPath, text, replaces); } + /** + * Empties a store back to its header, so removing a memory resource leaves the + * store the agent reads consistent with the catalog. Global also refreshes the + * session snapshot. + */ + clear(rootPath: string, scope: MemoryScope): void { + if (scope === "global") { + this.ensureGlobalFile(); + writeFileSync(this.globalFile(), header("Global memory")); + try { + writeFileSync( + this.sessionGlobalSnapshot(rootPath), + header("Global memory"), + ); + } catch { + // Snapshot refresh is best-effort; the canonical write already succeeded. + } + return; + } + writeFileSync(this.sessionFile(rootPath), header("Session memory")); + } + /** Records an agent-initiated suggestion and returns its id. */ addSuggestion( sessionId: string, diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index f077645..1295cf5 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -105,7 +105,7 @@ function makeManager(): { const runStore = new InMemoryRunStore(); const runs = new RunRegistry(runStore); - const pi = new PiAgentManager(handlers, memory, runs, fakeSpawn); + const pi = new PiAgentManager(handlers, memory, runs, undefined, fakeSpawn); return { pi, spawns, rosterUpdates, events, runs, runStore }; } diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index f208a9b..ff38f81 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -36,6 +36,7 @@ import { type SubagentSpawnRequest, } from "./agentConfig.ts"; import { loadInstalledConfig } from "./config/bundleLoader.ts"; +import type { HostResourcePreamble } from "./hostResourcePreamble.ts"; import type { MemoryManager } from "./memory.ts"; import { type AgentDescriptor, @@ -56,6 +57,8 @@ import { parsePiEvent, PROXY_PROVIDER_EXTENSION, readDelta, + REMOTE_TOOLS_EXTENSION, + RESOURCES_EXTENSION, SESSION_EXTENSION, toDescriptor, toolActivityLabel, @@ -232,6 +235,25 @@ function resolveModelArgs(config: AgentConfig): ModelArgs { return { provider, model, thinking: config.thinkingDepth ?? PI_THINKING }; } +/** + * Built-in extensions loaded into every Pi process, in order. The orchestrator + * gives Prime its sub-agent tools; the proxy-provider registers Pi's providers + * against the LLM proxy (required without an auto-discovered `~/.pi/agent` + * config); memory registers read/remember; resources registers read_resources; + * triggers gives Prime its create/list/enable/disable/delete trigger tools; + * session gives Prime rename_session; remote-tools gives every agent the + * list_remote_tools/call_remote_tool dispatcher. + */ +const BUILTIN_EXTENSIONS = [ + ORCHESTRATOR_EXTENSION, + PROXY_PROVIDER_EXTENSION, + MEMORY_EXTENSION, + RESOURCES_EXTENSION, + TRIGGERS_EXTENSION, + SESSION_EXTENSION, + REMOTE_TOOLS_EXTENSION, +]; + /** Builds the `pi --mode rpc` CLI args for an agent process. */ function buildPiArgs( config: AgentConfig, @@ -257,24 +279,11 @@ function buildPiArgs( config.tools.join(","), "--append-system-prompt", appendPreambles(config, preambles), - // Orchestrator gives Prime its sub-agent tools; the proxy-provider - // extension registers Pi's providers against the LLM proxy (required in - // environments without an auto-discovered `~/.pi/agent` config); the memory - // extension registers the read/remember tools; the triggers extension gives - // Prime its create/list/enable/disable/delete trigger tools; the session - // extension gives Prime its rename_session tool. - "--extension", - ORCHESTRATOR_EXTENSION, - "--extension", - PROXY_PROVIDER_EXTENSION, - "--extension", - MEMORY_EXTENSION, - "--extension", - TRIGGERS_EXTENSION, - "--extension", - SESSION_EXTENSION, ]; + for (const extension of BUILTIN_EXTENSIONS) + args.push("--extension", extension); + // Bundle-provided skills, workflows, and custom tool extensions, applied to // every agent in the session so they share the bundle's capabilities. const flagged: Array<[string, string[]]> = [ @@ -350,6 +359,23 @@ function canReviveSubagent( return !session.agents.has(agent.id); } +/** The environment a spawned Pi process inherits, tagging it with its session. */ +function spawnEnv( + sessionId: string, + descriptor: AgentDescriptor, +): NodeJS.ProcessEnv { + return { + ...process.env, + TANGENT_SESSION_ID: sessionId, + TANGENT_AGENT_ID: descriptor.agentId, + TANGENT_AGENT_ROLE: descriptor.role, + // Gates the extension's orchestration-tool grant on capability, not role. + TANGENT_AGENT_CAPABILITIES: capabilities(descriptor.role), + TANGENT_INTERNAL_URL: INTERNAL_URL, + ...piCredential.spawnEnv(), + }; +} + /** Extracts the per-agent {@link SpawnExtras} from a session's bundle config. */ function spawnExtras(config: ResolvedSessionConfig | undefined): SpawnExtras { if (!config) { @@ -460,6 +486,11 @@ export class PiAgentManager { * settles them rather than having them inferred from the event stream. */ private readonly runs: RunRegistry; + /** + * Spawn-time projection of each session's host resources, appended to every + * agent's preamble. Optional so a bare manager (e.g. a test) skips it. + */ + private readonly hostResources?: HostResourcePreamble; /** * Process launcher, injectable so tests can supply a fake child without * spawning a real `pi` binary. Defaults to Node's {@link spawn}. @@ -475,12 +506,14 @@ export class PiAgentManager { handlers: ConversationEventSink, memory: MemoryManager, runs: RunRegistry, + hostResources?: HostResourcePreamble, spawnProcess: typeof spawn = spawn, ) { this.spawnProcess = spawnProcess; this.handlers = handlers; this.memory = memory; this.runs = runs; + this.hostResources = hostResources; } /** @@ -954,6 +987,19 @@ export class PiAgentManager { } } + /** + * The standing-context preambles appended to every agent's system prompt: the + * current user, the session + global memory, and the host resources the + * embedding app attached. Empty entries are dropped downstream. + */ + private spawnPreambles(sessionId: string, session: SessionAgents): string[] { + return [ + buildUserPreamble(session.user), + this.memory.buildPreamble(session.rootPath), + this.hostResources?.get(sessionId) ?? "", + ]; + } + private spawnAgent( sessionId: string, session: SessionAgents, @@ -967,23 +1013,12 @@ export class PiAgentManager { const extras = spawnExtras(session.config); logSpawn(sessionId, descriptor, session.rootPath, config, extras); - const memoryPreamble = this.memory.buildPreamble(session.rootPath); - const userPreamble = buildUserPreamble(session.user); const child = this.spawnProcess( PI_BIN, - buildPiArgs(config, extras, [userPreamble, memoryPreamble]), + buildPiArgs(config, extras, this.spawnPreambles(sessionId, session)), { cwd: session.rootPath, - env: { - ...process.env, - TANGENT_SESSION_ID: sessionId, - TANGENT_AGENT_ID: descriptor.agentId, - TANGENT_AGENT_ROLE: descriptor.role, - // Gates the extension's orchestration-tool grant on capability, not role. - TANGENT_AGENT_CAPABILITIES: capabilities(descriptor.role), - TANGENT_INTERNAL_URL: INTERNAL_URL, - ...piCredential.spawnEnv(), - }, + env: spawnEnv(sessionId, descriptor), stdio: ["pipe", "pipe", "pipe"], }, ) as ChildProcessWithoutNullStreams; diff --git a/apps/server/src/pi/primeSystemPrompt.md b/apps/server/src/pi/primeSystemPrompt.md index 785832a..0508101 100644 --- a/apps/server/src/pi/primeSystemPrompt.md +++ b/apps/server/src/pi/primeSystemPrompt.md @@ -109,6 +109,22 @@ that the condition is reached and you have acted on it, tear down **both** sides dedicated sub-agent). Do not leave a trigger firing or a watcher idling after its goal is met. +## Host tools + +The app embedding this session may connect a host environment that offers its +own tools over an RPC channel (for example, a browser editor exposing functions +to read and mutate what the user is looking at). These are not sub-agents and +cost no extra context — they are plain function calls you make directly. + +- `list_remote_tools` — see what the connected host currently offers, including + each tool's arguments. The catalog is dynamic: it appears when a host connects + and is empty when none is, so check it rather than assuming. +- `call_remote_tool` — invoke one by name with a JSON `arguments` object. + +Prefer host tools over spawning a sub-agent when the host exposes the capability +you need. If a call reports no host is connected, tell the human the host +(e.g. the editor) is not currently available. + ## Session naming Sessions keep the name they had when they were created. Do not call diff --git a/apps/server/src/pi/utils.ts b/apps/server/src/pi/utils.ts index 8ac290e..d39e82c 100644 --- a/apps/server/src/pi/utils.ts +++ b/apps/server/src/pi/utils.ts @@ -43,6 +43,17 @@ export const MEMORY_EXTENSION = path.join( "memory.ts", ); +/** + * Absolute path to the resources extension loaded into every Pi process. It + * registers the `read_resources` tool so any agent can re-read the host + * resources the embedding app attached, which can change after spawn. + */ +export const RESOURCES_EXTENSION = path.join( + import.meta.dirname, + "extensions", + "resources.ts", +); + /** * Absolute path to the triggers extension loaded into every Pi process. It * registers Prime-only tools to create, list, enable, disable, and delete the @@ -65,6 +76,18 @@ export const SESSION_EXTENSION = path.join( "session.ts", ); +/** + * Absolute path to the remote-tools extension loaded into every Pi process. It + * registers the `list_remote_tools` / `call_remote_tool` dispatcher so any agent + * can invoke the RPC tools a connected remote environment offers, without + * spawning a browser sub-agent. + */ +export const REMOTE_TOOLS_EXTENSION = path.join( + import.meta.dirname, + "extensions", + "remoteTools.ts", +); + /** Drops a single optional trailing CR from a line. */ function stripTrailingCr(line: string): string { return line.endsWith("\r") ? line.slice(0, -1) : line; diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts index afca432..45a366b 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -5,10 +5,16 @@ import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; import { type RemoteAgentEventPayload, RemoteEnvEvents, + type RemoteSpawnCommand, + type RemoteToolCallRequest, } from "@tangent/shared/remoteSubagent.ts"; import type { Server as SocketIOServer, Socket } from "socket.io"; -import { HandshakeTokenCredential } from "../connectors/credentials.ts"; +import { + HandshakeTokenCredential, + ScopedTokenCredential, +} from "../connectors/credentials.ts"; +import type { ResolvedSessionConfig } from "../pi/agentConfig.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; @@ -22,80 +28,151 @@ function flush(): Promise { /** The environment token this harness's gateway is configured with. */ const ENV_TOKEN = "test-env-token"; +/** HMAC secret for scoped tokens in this harness. */ +const SCOPED_SECRET = "test-signing-secret"; + +/** One thing an environment's socket was sent, with any ack callback. */ +interface SentEntry { + event: string; + payload: unknown; + ack?: (err: Error | null, response: unknown) => void; +} /** An environment's socket: what it was sent, and what it listens for. */ function fakeSocket(auth: Record) { - const listeners = new Map void>(); - const sent: Array<{ event: string; payload: unknown }> = []; + const listeners = new Map void>(); + const sent: SentEntry[] = []; + const emit = (event: string, payload: unknown, ack?: unknown) => + sent.push({ + event, + payload, + ack: ack as SentEntry["ack"], + }); const socket = { handshake: { auth }, - on: (event: string, handler: (payload: unknown) => void) => + data: {} as Record, + connected: true, + on: (event: string, handler: (...args: unknown[]) => void) => listeners.set(event, handler), - emit: (event: string, payload: unknown) => sent.push({ event, payload }), + emit, + // The ack path (`socket.timeout(ms).emit(event, payload, cb)`) chains off a + // timeout; the fake ignores the duration and reuses the same emit. + timeout: () => ({ emit }), + disconnect: () => listeners.get("disconnect")?.(undefined), } as unknown as Socket; return { socket, listeners, sent }; } -/** - * A gateway wired to a fake namespace, plus a `connect` that registers an - * environment by driving the captured middleware and then the captured - * connection handler — so every test goes through the real credential check, - * presenting the right token unless it asks not to. The returned handle drives - * the environment's inbound events and its disconnect. - */ -function makeHarness() { +interface HarnessOptions { + scoped?: ScopedTokenCredential; + sessionConfig?: (sessionId: string) => ResolvedSessionConfig | undefined; +} + +/** Captures Socket.IO namespace middleware so tests can drive connect/auth. */ +function fakeNamespace() { let onConnection: ((socket: Socket) => void) | undefined; let authenticate: | ((socket: Socket, next: (err?: Error) => void) => void) | undefined; - const namespace = { - use: (fn: (socket: Socket, next: (err?: Error) => void) => void) => { - authenticate = fn; + return { + namespace: { + use: (fn: (socket: Socket, next: (err?: Error) => void) => void) => { + authenticate = fn; + }, + on: (event: string, handler: (socket: Socket) => void) => { + if (event === "connection") onConnection = handler; + }, }, - on: (event: string, handler: (socket: Socket) => void) => { - if (event === "connection") onConnection = handler; + connect( + environmentId: string, + auth: { token?: string } = { token: ENV_TOKEN }, + ) { + const { socket, listeners, sent } = fakeSocket({ + environmentId, + ...auth, + }); + let refused: Error | undefined; + authenticate?.(socket, (err) => { + refused = err; + }); + if (!refused) onConnection?.(socket); + return { + refused, + sent, + send: (event: string, payload: unknown) => + listeners.get(event)?.(payload), + disconnect: () => listeners.get("disconnect")?.(undefined), + }; }, }; +} - const rosterUpdates: SubagentInfo[] = []; - const handlers: ConversationEventSink = { - onAgentEvent: () => {}, +function relayHandlers( + rosterUpdates: SubagentInfo[], + agentEvents: Array<{ sessionId: string; agentId: string }>, +): ConversationEventSink { + return { + onAgentEvent: (sessionId, agent) => { + agentEvents.push({ sessionId, agentId: agent.agentId }); + }, onSubagentUpdate: (_sessionId, info) => rosterUpdates.push(info), onAgentMessage: () => {}, onSessionStatus: () => {}, }; +} + +/** + * A gateway wired to a fake namespace, plus a `connect` that registers an + * environment by driving the captured middleware and then the captured + * connection handler — so every test goes through the real credential check, + * presenting the right token unless it asks not to. The returned handle drives + * the environment's inbound events and its disconnect. + */ +function makeHarness(options: HarnessOptions = {}) { + const { namespace, connect } = fakeNamespace(); + const rosterUpdates: SubagentInfo[] = []; + const agentEvents: Array<{ sessionId: string; agentId: string }> = []; const store = new InMemorySessionStore(); const runStore = new InMemoryRunStore(); const runs = new RunRegistry(runStore); - const gateway = new RemoteEnvironmentGateway( { of: () => namespace } as unknown as SocketIOServer, - handlers, + relayHandlers(rosterUpdates, agentEvents), store, runs, new HandshakeTokenCredential(ENV_TOKEN), + options.scoped ?? new ScopedTokenCredential(SCOPED_SECRET), + options.sessionConfig, ); - - const connect = ( - environmentId: string, - auth: { token?: string } = { token: ENV_TOKEN }, - ) => { - const { socket, listeners, sent } = fakeSocket({ environmentId, ...auth }); - let refused: Error | undefined; - authenticate?.(socket, (err) => { - refused = err; - }); - if (!refused) onConnection?.(socket); - return { - refused, - sent, - send: (event: string, payload: unknown) => - listeners.get(event)?.(payload), - disconnect: () => listeners.get("disconnect")?.(undefined), - }; + return { + gateway, + connect, + store, + runs, + runStore, + rosterUpdates, + agentEvents, }; +} - return { gateway, connect, store, runs, runStore, rosterUpdates }; +function lastSpawn( + sent: Array<{ event: string; payload: unknown }>, +): RemoteSpawnCommand { + const spawn = sent.findLast((entry) => entry.event === RemoteEnvEvents.Spawn); + assert.ok(spawn); + return spawn.payload as RemoteSpawnCommand; +} + +function mintScoped( + scoped: ScopedTokenCredential, + environmentId: string, + sessionId: string, +): string { + return scoped.mint({ + environmentId, + sessionId, + sub: "user@example.com", + }).token; } /** Seeds a persisted remote roster row hosted by `environmentId`. */ @@ -233,3 +310,232 @@ test("reattach ignores a row that never recorded its environment", async () => { // there is nothing to reattach them to. assert.deepEqual(h.gateway.listSubagents("s1"), []); }); + +test("a scoped token is accepted and a wrong HMAC is refused", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const token = mintScoped(scoped, "env-scoped", "s1"); + + assert.ok(h.connect("ignored", { token: "re1.payload.garbage" }).refused); + assert.equal(h.connect("ignored", { token }).refused, undefined); + assert.equal(h.gateway.hasConnectedEnvironment(), true); +}); + +test("an expired scoped token is refused", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET, 0); + const h = makeHarness({ scoped }); + const token = mintScoped(scoped, "env-scoped", "s1"); + + assert.ok(h.connect("ignored", { token }).refused); +}); + +test("a scoped connection ignores the handshake environmentId", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const token = mintScoped(scoped, "env-real", "s1"); + const env = h.connect("spoofed", { token }); + + const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" }); + + assert.equal(info.connector.environmentId, "env-real"); + assert.equal(lastSpawn(env.sent).sessionId, "s1"); +}); + +test("scoped environments only receive spawns for their bound session", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const envA = h.connect("ignored-a", { + token: mintScoped(scoped, "env-a", "sA"), + }); + const envB = h.connect("ignored-b", { + token: mintScoped(scoped, "env-b", "sB"), + }); + + h.gateway.spawnSubagent("sA", { name: "WorkerA" }); + h.gateway.spawnSubagent("sB", { name: "WorkerB" }); + + assert.equal(lastSpawn(envA.sent).sessionId, "sA"); + assert.equal(lastSpawn(envB.sent).sessionId, "sB"); + assert.equal(envA.sent.length, 1); + assert.equal(envB.sent.length, 1); +}); + +test("a scoped environment never receives another session's spawn", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const envA = h.connect("ignored-a", { + token: mintScoped(scoped, "env-a", "sA"), + }); + + assert.throws( + () => h.gateway.spawnSubagent("sB", { name: "WorkerB" }), + /No remote environment is connected/, + ); + assert.equal(envA.sent.length, 0); +}); + +test("an unscoped environment still receives spawns for an unbound session", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const legacy = h.connect("env-legacy"); + h.connect("ignored-a", { token: mintScoped(scoped, "env-a", "sA") }); + + h.gateway.spawnSubagent("sA", { name: "ScopedWorker" }); + h.gateway.spawnSubagent("sB", { name: "LegacyWorker" }); + + assert.equal(lastSpawn(legacy.sent).sessionId, "sB"); + assert.equal(lastSpawn(legacy.sent).name, "LegacyWorker"); +}); + +test("a scoped environment's inbound events for another session are dropped", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + h.connect("env-legacy"); + const { info } = h.gateway.spawnSubagent("s2", { name: "Worker" }); + const scopedEnv = h.connect("ignored-a", { + token: mintScoped(scoped, "env-a", "s1"), + }); + + scopedEnv.send(RemoteEnvEvents.AgentEvent, { + sessionId: "s2", + agentId: info.id, + event: { type: "start", messageId: "m1" }, + } satisfies RemoteAgentEventPayload); + + assert.deepEqual(h.agentEvents, []); +}); + +test("a remote spawn resolves tools and prompt from the session's editor template", () => { + const templates = new Map([ + [ + "editor", + { + name: "editor", + description: "", + tools: ["csom_edit"], + systemPrompt: "You edit the spec.", + }, + ], + ]); + const sessionConfig = (): ResolvedSessionConfig => ({ + prime: { tools: [], appendSystemPrompt: "" }, + subagentDefaults: {}, + templates, + skillPaths: [], + workflowPaths: [], + extensionPaths: [], + }); + const h = makeHarness({ sessionConfig }); + const env = h.connect("env-1"); + + h.gateway.spawnSubagent("s1", { name: "Editor", template: "editor" }); + + const command = lastSpawn(env.sent); + assert.ok(command.tools.includes("csom_edit")); + assert.match(command.systemPrompt, /You edit the spec\./); + assert.equal(command.template, "editor"); +}); + +const ECHO_TOOL = { + name: "echo", + description: "Echo the input back.", + inputSchema: { type: "object" }, +}; + +test("an environment's registered tools are listed for its session", () => { + const h = makeHarness(); + const env = h.connect("env-1"); + + assert.deepEqual(h.gateway.listTools("s1"), []); + + env.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "s1", + tools: [ECHO_TOOL], + }); + + assert.deepEqual(h.gateway.listTools("s1"), [ECHO_TOOL]); +}); + +test("re-registering replaces the prior catalog", () => { + const h = makeHarness(); + const env = h.connect("env-1"); + env.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "s1", + tools: [ECHO_TOOL], + }); + + env.send(RemoteEnvEvents.ToolsRegister, { sessionId: "s1", tools: [] }); + + assert.deepEqual(h.gateway.listTools("s1"), []); +}); + +test("a tool call routes to the environment and resolves with its result", async () => { + const h = makeHarness(); + const env = h.connect("env-1"); + env.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "s1", + tools: [ECHO_TOOL], + }); + + const pending = h.gateway.callTool("s1", "prime", "echo", { text: "hi" }); + const call = env.sent.findLast((e) => e.event === RemoteEnvEvents.ToolsCall); + assert.ok(call); + const request = call.payload as RemoteToolCallRequest; + assert.equal(request.name, "echo"); + assert.equal(request.agentId, "prime"); + assert.deepEqual(request.arguments, { text: "hi" }); + call.ack?.(null, { ok: true, result: "hi" }); + + assert.deepEqual(await pending, { ok: true, result: "hi" }); +}); + +test("calling a tool no environment registered rejects", async () => { + const h = makeHarness(); + const env = h.connect("env-1"); + env.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "s1", + tools: [ECHO_TOOL], + }); + + await assert.rejects( + () => h.gateway.callTool("s1", "prime", "missing", {}), + /No remote tool named "missing"/, + ); +}); + +test("calling a tool with no environment connected rejects", async () => { + const h = makeHarness(); + + await assert.rejects( + () => h.gateway.callTool("s1", "prime", "echo", {}), + /No remote environment is connected/, + ); +}); + +test("a disconnecting environment drops its tool catalog", () => { + const h = makeHarness(); + const env = h.connect("env-1"); + env.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "s1", + tools: [ECHO_TOOL], + }); + + env.disconnect(); + + assert.deepEqual(h.gateway.listTools("s1"), []); +}); + +test("a scoped environment cannot register tools for another session", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const env = h.connect("ignored", { + token: mintScoped(scoped, "env-a", "sA"), + }); + + env.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "sB", + tools: [ECHO_TOOL], + }); + + assert.deepEqual(h.gateway.listTools("sA"), []); +}); diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index 6e37b4d..28e9b51 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import path from "node:path"; import { connectorFields, @@ -22,19 +23,28 @@ import { type RemoteRoomReadResponse, type RemoteSpawnCommand, type RemoteSubagentUpdatePayload, + type RemoteToolCallRequest, + type RemoteToolCallResponse, + type RemoteToolDef, + type RemoteToolsRegisterPayload, } from "@tangent/shared/remoteSubagent.ts"; import type { Namespace, Server as SocketIOServer, Socket } from "socket.io"; +import { SESSIONS_ROOT } from "../config.ts"; import { type ConnectorCredential, remoteEnvCredential, + scopedRemoteEnvCredential, + type ScopedTokenCredential, } from "../connectors/credentials.ts"; import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; import { parseThinkingLevel, + type ResolvedSessionConfig, resolveSubagentConfig, type SubagentSpawnRequest, } from "../pi/agentConfig.ts"; +import { loadInstalledConfig } from "../pi/config/bundleLoader.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { AgentDescriptor, ConversationEventSink } from "../pi/types.ts"; import type { RunRegistry } from "../runs/runRegistry.ts"; @@ -44,6 +54,9 @@ import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; const DEFAULT_ROOM_LIMIT = 30; const MAX_ROOM_LIMIT = 200; +/** How long a remote tool call waits for the environment's ack before failing. */ +const TOOL_CALL_TIMEOUT_MS = 30_000; + /** One message to deliver to one remote sub-agent. */ export interface RemoteSendOptions { sessionId: string; @@ -58,7 +71,38 @@ export interface RemoteSendOptions { /** A connected remote environment and its live Socket.IO connection. */ interface RemoteEnvConnection { environmentId: string; + /** Set when the environment authenticated with a scoped per-session token. */ + sessionId?: string; socket: Socket; + /** The RPC tools this environment currently offers, keyed by tool name. */ + tools: Map; +} + +/** Looks up a session's installed bundle config for remote spawn resolution. */ +export type SessionConfigLookup = ( + sessionId: string, +) => ResolvedSessionConfig | undefined; + +/** Re-resolves the session's installed bundle, or `undefined` for a plain session. */ +function loadSessionBundleConfig( + sessionId: string, +): ResolvedSessionConfig | undefined { + try { + return loadInstalledConfig(path.join(SESSIONS_ROOT, sessionId)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[remote-env] failed to load installed bundle config for "${sessionId}"; using default config. ${message}`, + ); + return undefined; + } +} + +/** Reads a non-empty string off `socket.data`, or `undefined`. */ +function socketDataString(socket: Socket, key: string): string | undefined { + const value: unknown = socket.data[key]; + if (typeof value !== "string" || !value) return undefined; + return value; } /** A sub-agent hosted in a remote environment, tracked in the gateway roster. */ @@ -125,9 +169,13 @@ export class RemoteEnvironmentGateway { private readonly store: SessionStore; private readonly runs: RunRegistry; private readonly credential: ConnectorCredential; + private readonly scoped: ScopedTokenCredential; + private readonly sessionConfig: SessionConfigLookup; - /** Connected environments, keyed by their handshake `environmentId`. */ + /** Connected environments, keyed by `environmentId`. */ private readonly environments = new Map(); + /** Scoped environment bound to a session, keyed by sessionId. */ + private readonly sessionEnvironments = new Map(); /** Per-session remote sub-agent rosters, keyed by sessionId then agentId. */ private readonly sessions = new Map>(); @@ -137,12 +185,16 @@ export class RemoteEnvironmentGateway { store: SessionStore, runs: RunRegistry, credential: ConnectorCredential = remoteEnvCredential, + scoped: ScopedTokenCredential = scopedRemoteEnvCredential, + sessionConfig: SessionConfigLookup = loadSessionBundleConfig, ) { this.io = io; this.handlers = handlers; this.store = store; this.runs = runs; this.credential = credential; + this.scoped = scoped; + this.sessionConfig = sessionConfig; this.setupNamespace(); } @@ -165,22 +217,26 @@ export class RemoteEnvironmentGateway { /** * Spawns a sub-agent on a connected remote environment. Resolves the - * effective config from the global templates/defaults (remote environments - * are bundle-agnostic in this iteration), records the roster entry, and emits - * the spawn command. Throws when no environment is connected. + * effective config from the session's installed bundle templates/defaults + * (falling back to the global templates), records the roster entry, and emits + * the spawn command. Throws when no environment is connected for the session. */ spawnSubagent( sessionId: string, request: SubagentSpawnRequest, ): SpawnedSubagent { - const environment = this.pickEnvironment(); + const environment = this.pickEnvironment(sessionId); if (!environment) { throw new Error("No remote environment is connected."); } const agentId = randomUUID(); const homeConversationId = randomUUID(); - const config = resolveSubagentConfig(request); + const sessionConfig = this.sessionConfig(sessionId); + const config = resolveSubagentConfig(request, { + templates: sessionConfig?.templates, + defaults: sessionConfig?.subagentDefaults, + }); const autoRelayToPrime = request.autoRelayToPrime ?? true; const tools = [...config.tools]; @@ -333,10 +389,27 @@ export class RemoteEnvironmentGateway { this.environments.get(environmentId)?.socket.emit(event, payload); } - /** Picks a connected environment to host a new sub-agent (first connected). */ - private pickEnvironment(): RemoteEnvConnection | undefined { - const first = this.environments.values().next(); - return first.done ? undefined : first.value; + /** The environment bound to `sessionId`, or the first unscoped shared-secret env. */ + private pickEnvironment(sessionId: string): RemoteEnvConnection | undefined { + const bound = this.boundEnvironment(sessionId); + if (bound) return bound; + return this.firstUnscopedEnvironment(); + } + + /** The live environment recorded against `sessionId`, if still connected. */ + private boundEnvironment(sessionId: string): RemoteEnvConnection | undefined { + const boundId = this.sessionEnvironments.get(sessionId); + if (!boundId) return undefined; + return this.environments.get(boundId); + } + + /** First connected environment that is not bound to a session. */ + private firstUnscopedEnvironment(): RemoteEnvConnection | undefined { + for (const environment of this.environments.values()) { + if (environment.sessionId) continue; + return environment; + } + return undefined; } /** Returns (creating if needed) the session's remote sub-agent roster. */ @@ -377,6 +450,13 @@ export class RemoteEnvironmentGateway { /** Rejects connections lacking a valid token / environment id. */ private authenticate(socket: Socket, next: (err?: Error) => void): void { const auth = socket.handshake.auth as Partial; + const scoped = this.scoped.parse(auth.token); + if (scoped) { + socket.data.environmentId = scoped.environmentId; + socket.data.sessionId = scoped.sessionId; + next(); + return; + } if (!this.credential.verify({ token: auth.token })) { next(new Error("Unauthorized")); return; @@ -385,38 +465,165 @@ export class RemoteEnvironmentGateway { next(new Error("Missing environmentId")); return; } + socket.data.environmentId = auth.environmentId; next(); } /** Registers a connected environment and wires its inbound listeners. */ private onConnection(_namespace: Namespace, socket: Socket): void { - const { environmentId } = socket.handshake.auth as RemoteEnvHandshake; - this.environments.set(environmentId, { environmentId, socket }); + const environmentId = socketDataString(socket, "environmentId"); + if (!environmentId) return; + const sessionId = socketDataString(socket, "sessionId"); + + if (sessionId) this.bindSessionEnvironment(sessionId, environmentId); + this.environments.set(environmentId, { + environmentId, + sessionId, + socket, + tools: new Map(), + }); console.log(`[remote-env] connected: ${environmentId}`); - socket.on(RemoteEnvEvents.AgentEvent, (payload: RemoteAgentEventPayload) => - this.handleAgentEvent(payload), + this.wireInbound(socket, sessionId); + socket.on("disconnect", () => this.onDisconnect(environmentId, socket)); + + void this.replayRoster(environmentId); + } + + /** + * Records `environmentId` as the host for `sessionId`, disconnecting any + * previous scoped environment still bound to that session. + */ + private bindSessionEnvironment( + sessionId: string, + environmentId: string, + ): void { + const previousId = this.sessionEnvironments.get(sessionId); + this.sessionEnvironments.set(sessionId, environmentId); + if (!previousId || previousId === environmentId) return; + this.environments.get(previousId)?.socket.disconnect(); + } + + /** True when an unscoped env may speak for any session, or the ids match. */ + private acceptsSession( + boundSessionId: string | undefined, + sessionId: string, + ): boolean { + if (!boundSessionId) return true; + return boundSessionId === sessionId; + } + + /** Wires protocol listeners, dropping cross-session traffic on scoped envs. */ + private wireInbound(socket: Socket, sessionId: string | undefined): void { + socket.on( + RemoteEnvEvents.AgentEvent, + (payload: RemoteAgentEventPayload) => { + if (!this.acceptsSession(sessionId, payload.sessionId)) return; + this.handleAgentEvent(payload); + }, ); socket.on( RemoteEnvEvents.SubagentUpdate, - (payload: RemoteSubagentUpdatePayload) => - this.handleSubagentUpdate(payload), + (payload: RemoteSubagentUpdatePayload) => { + if (!this.acceptsSession(sessionId, payload.sessionId)) return; + this.handleSubagentUpdate(payload); + }, ); socket.on( RemoteEnvEvents.AgentMessage, - (payload: RemoteAgentMessagePayload) => - void this.handleAgentMessage(payload), + (payload: RemoteAgentMessagePayload) => { + if (!this.acceptsSession(sessionId, payload.sessionId)) return; + void this.handleAgentMessage(payload); + }, ); socket.on( RemoteEnvEvents.RoomRead, ( request: RemoteRoomReadRequest, callback: (response: RemoteRoomReadResponse) => void, - ) => void this.handleRoomRead(request, callback), + ) => { + if (!this.acceptsSession(sessionId, request.sessionId)) { + callback({ messages: [] }); + return; + } + void this.handleRoomRead(request, callback); + }, + ); + socket.on( + RemoteEnvEvents.ToolsRegister, + (payload: RemoteToolsRegisterPayload) => { + if (!this.acceptsSession(sessionId, payload.sessionId)) return; + this.handleToolsRegister(socket, payload); + }, ); - socket.on("disconnect", () => this.onDisconnect(environmentId)); + } - void this.replayRoster(environmentId); + /** Records the catalog an environment declares, replacing any prior one. */ + private handleToolsRegister( + socket: Socket, + payload: RemoteToolsRegisterPayload, + ): void { + const environmentId = socketDataString(socket, "environmentId"); + if (!environmentId) return; + const environment = this.environments.get(environmentId); + if (!environment || environment.socket !== socket) return; + environment.tools = new Map(payload.tools.map((tool) => [tool.name, tool])); + console.log( + `[remote-env] ${environmentId} registered ${environment.tools.size} tool(s)`, + ); + } + + /** + * The tools available to a session: the catalog of the environment bound to it + * (or the shared unscoped one it would spawn on). Empty when no environment is + * connected, so a caller can tell "no host" from "host offers nothing". + */ + listTools(sessionId: string): RemoteToolDef[] { + const environment = this.pickEnvironment(sessionId); + if (!environment) return []; + return [...environment.tools.values()]; + } + + /** + * Invokes one registered tool on the session's environment and resolves with + * its result. Rejects when no environment is connected, the tool is not in the + * environment's catalog, or the environment does not ack before the timeout. + */ + async callTool( + sessionId: string, + agentId: string, + name: string, + args: unknown, + ): Promise { + const environment = this.pickEnvironment(sessionId); + if (!environment) { + throw new Error("No remote environment is connected."); + } + if (!environment.tools.has(name)) { + throw new Error(`No remote tool named "${name}" is registered.`); + } + const request: RemoteToolCallRequest = { + callId: randomUUID(), + sessionId, + agentId, + name, + arguments: args, + }; + return new Promise((resolve, reject) => { + environment.socket + .timeout(TOOL_CALL_TIMEOUT_MS) + .emit( + RemoteEnvEvents.ToolsCall, + request, + (err: Error | null, response: RemoteToolCallResponse) => { + if (err) { + reject(err); + return; + } + resolve(response); + }, + ); + }); } /** @@ -529,8 +736,29 @@ export class RemoteEnvironmentGateway { } /** Drops a disconnected environment and detaches its sub-agents. */ - private onDisconnect(environmentId: string): void { + private onDisconnect(environmentId: string, socket: Socket): void { + const current = this.environments.get(environmentId); + if (current?.socket && current.socket !== socket) return; + this.dropEnvironment(environmentId, current?.sessionId); + } + + /** Clears the session binding if it still names this environment. */ + private unbindSession( + boundSessionId: string | undefined, + environmentId: string, + ): void { + if (!boundSessionId) return; + if (this.sessionEnvironments.get(boundSessionId) !== environmentId) return; + this.sessionEnvironments.delete(boundSessionId); + } + + /** Removes a dropped environment from the maps and detaches its agents. */ + private dropEnvironment( + environmentId: string, + boundSessionId: string | undefined, + ): void { this.environments.delete(environmentId); + this.unbindSession(boundSessionId, environmentId); for (const [sessionId, roster] of this.sessions) { this.detachEnvironmentAgents(sessionId, roster, environmentId); } diff --git a/apps/server/src/routes/embed.test.ts b/apps/server/src/routes/embed.test.ts new file mode 100644 index 0000000..e910a02 --- /dev/null +++ b/apps/server/src/routes/embed.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { Request, Response } from "express"; + +import { ScopedTokenCredential } from "../connectors/credentials.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import { handleMintRemoteEnvToken } from "./embed.ts"; + +class TestResponse { + statusCode = 200; + body: unknown; + + status(code: number): this { + this.statusCode = code; + return this; + } + + json(body: unknown): this { + this.body = body; + return this; + } +} + +const USER = { + email: "owner@example.com", + first_name: "Ada", + last_name: "Lovelace", +}; + +function fakeJwt(email: string): string { + const payload = Buffer.from(JSON.stringify({ email })).toString("base64url"); + return `hdr.${payload}.sig`; +} + +function requestWithBearer(email: string): Request { + return { + headers: { authorization: `Bearer ${fakeJwt(email)}` }, + } as Request; +} + +function validatedRequest(sessionId: string, email: string): Request { + const req = requestWithBearer(email) as Request & { + validated: { body: { sessionId: string }; params: unknown; query: unknown }; + }; + req.validated = { body: { sessionId }, params: undefined, query: undefined }; + return req; +} + +test("minting a remote-env token requires an identity", async () => { + const store = new InMemorySessionStore(); + const scoped = new ScopedTokenCredential("signing-secret"); + const response = new TestResponse(); + + await handleMintRemoteEnvToken( + store, + scoped, + { headers: {} } as Request, + response as unknown as Response, + ); + + assert.equal(response.statusCode, 401); + assert.deepEqual(response.body, { error: "Invalid or missing token" }); +}); + +test("minting a remote-env token 404s for an unknown session", async () => { + const store = new InMemorySessionStore(); + const scoped = new ScopedTokenCredential("signing-secret"); + const response = new TestResponse(); + + await handleMintRemoteEnvToken( + store, + scoped, + validatedRequest("missing-session", USER.email), + response as unknown as Response, + ); + + assert.equal(response.statusCode, 404); + assert.deepEqual(response.body, { error: "Session not found" }); +}); + +test("minting a remote-env token 403s when the session belongs to someone else", async () => { + const store = new InMemorySessionStore(); + const session = await store.createSession({ name: "Owned", user: USER }); + const scoped = new ScopedTokenCredential("signing-secret"); + const response = new TestResponse(); + + await handleMintRemoteEnvToken( + store, + scoped, + validatedRequest(session.id, "intruder@example.com"), + response as unknown as Response, + ); + + assert.equal(response.statusCode, 403); + assert.deepEqual(response.body, { error: "Forbidden" }); +}); + +test("minting a remote-env token succeeds when the caller owns the session", async () => { + const store = new InMemorySessionStore(); + const session = await store.createSession({ name: "Owned", user: USER }); + const scoped = new ScopedTokenCredential("signing-secret"); + const response = new TestResponse(); + + await handleMintRemoteEnvToken( + store, + scoped, + validatedRequest(session.id, USER.email), + response as unknown as Response, + ); + + assert.equal(response.statusCode, 200); + const body = response.body as { + token: string; + environmentId: string; + expiresAt: string; + }; + assert.equal(typeof body.token, "string"); + assert.equal(typeof body.environmentId, "string"); + assert.equal(typeof body.expiresAt, "string"); + + const claims = scoped.parse(body.token); + assert.ok(claims); + assert.equal(claims.sessionId, session.id); + assert.equal(claims.environmentId, body.environmentId); + assert.equal(claims.sub, USER.email); +}); + +test("minting a remote-env token succeeds for a session with no user", async () => { + const store = new InMemorySessionStore(); + const session = await store.createSession({ name: "Anonymous" }); + const scoped = new ScopedTokenCredential("signing-secret"); + const response = new TestResponse(); + + await handleMintRemoteEnvToken( + store, + scoped, + validatedRequest(session.id, USER.email), + response as unknown as Response, + ); + + assert.equal(response.statusCode, 200); + const body = response.body as { token: string }; + assert.ok(scoped.parse(body.token)); +}); diff --git a/apps/server/src/routes/embed.ts b/apps/server/src/routes/embed.ts new file mode 100644 index 0000000..f614f0c --- /dev/null +++ b/apps/server/src/routes/embed.ts @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; + +import type { + RemoteEnvTokenRequest, + RemoteEnvTokenResponse, + Session, +} from "@tangent/shared/contracts.ts"; +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import { resolveUserIdentity } from "../auth/identity.ts"; +import { + scopedRemoteEnvCredential, + type ScopedTokenCredential, +} from "../connectors/credentials.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; + +const remoteEnvTokenBodySchema = z.object({ + sessionId: z.string().min(1), +}); + +/** True when `email` may mint a token for `session`. */ +function canMintForSession(session: Session, email: string): boolean { + const owner = session.user?.email; + if (!owner) return true; + return owner === email; +} + +/** + * Mints a scoped `/remote-env` token for an embed host. Authed by the embed JWT + * (bearer) or the Oktasso cookie; the token is bound to `sessionId` and a + * freshly generated `environmentId`. + */ +export async function handleMintRemoteEnvToken( + store: SessionStore, + scoped: ScopedTokenCredential, + req: Request, + res: Response, +): Promise { + const identity = resolveUserIdentity( + req.headers.cookie, + req.headers.authorization, + ); + if (!identity) { + res.status(401).json({ error: "Invalid or missing token" }); + return; + } + + const { sessionId } = getValidated(req).body; + const session = await store.getSession(sessionId); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + if (!canMintForSession(session, identity.email)) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + if (!scoped.configured) { + res + .status(503) + .json({ error: "Remote environment tokens are not configured" }); + return; + } + + const environmentId = randomUUID(); + const minted = scoped.mint({ + environmentId, + sessionId, + sub: identity.email, + }); + const body: RemoteEnvTokenResponse = { + token: minted.token, + environmentId, + expiresAt: minted.expiresAt, + }; + res.json(body); +} + +/** Public embed routes (`POST /remote-env-token`). */ +export function createEmbedRouter( + store: SessionStore, + scoped: ScopedTokenCredential = scopedRemoteEnvCredential, +): Router { + const router = Router(); + router.post( + "/remote-env-token", + validate({ body: remoteEnvTokenBodySchema }), + (req: Request, res: Response) => + void handleMintRemoteEnvToken(store, scoped, req, res), + ); + return router; +} diff --git a/apps/server/src/routes/internalRemoteTools.ts b/apps/server/src/routes/internalRemoteTools.ts new file mode 100644 index 0000000..9f46f48 --- /dev/null +++ b/apps/server/src/routes/internalRemoteTools.ts @@ -0,0 +1,88 @@ +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; + +/** `GET /list` query: the session whose remote tool catalog to read. */ +const listQuerySchema = z.object({ + sessionId: z.string(), +}); +type ListQuery = z.infer; + +/** `POST /call` body: invoke one registered remote tool on behalf of an agent. */ +const callBodySchema = z.object({ + sessionId: z.string(), + agentId: z.string(), + name: z.string(), + arguments: z.unknown(), +}); +type CallBody = z.infer; + +/** `GET /list`: the tools the session's connected environment currently offers. */ +function handleList( + gateway: RemoteEnvironmentGateway, + query: ListQuery, + res: Response, +): void { + res.json({ tools: gateway.listTools(query.sessionId) }); +} + +/** + * `POST /call`: routes one tool call to the session's environment and returns + * its result. A missing environment or unknown tool is a 400 the agent surfaces + * to itself, not a server error. + */ +async function handleCall( + gateway: RemoteEnvironmentGateway, + body: CallBody, + res: Response, +): Promise { + try { + const response = await gateway.callTool( + body.sessionId, + body.agentId, + body.name, + body.arguments, + ); + res.json(response); + } catch (err) { + res.status(400).json({ error: (err as Error).message }); + } +} + +/** + * Internal API used only by the remote-tools extension running inside each Pi + * process. It lets any agent list and invoke the RPC tools a connected remote + * environment offers, without spawning a browser sub-agent. Guarded by the same + * bearer token as the other internal APIs so arbitrary local callers can't + * drive a session's host. + */ +export function createInternalRemoteToolsRouter( + gateway: RemoteEnvironmentGateway, +): Router { + const router = Router(); + + router.use(requireInternalToken); + + router.get( + "/list", + validate({ query: listQuerySchema }), + (req: Request, res: Response) => + handleList( + gateway, + getValidated(req).query, + res, + ), + ); + + router.post( + "/call", + validate({ body: callBodySchema }), + (req: Request, res: Response) => + void handleCall(gateway, getValidated(req).body, res), + ); + + return router; +} diff --git a/apps/server/src/routes/internalResources.ts b/apps/server/src/routes/internalResources.ts new file mode 100644 index 0000000..d366491 --- /dev/null +++ b/apps/server/src/routes/internalResources.ts @@ -0,0 +1,60 @@ +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import type { ResourceCatalog } from "../conversation/resourceCatalog.ts"; +import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { loadSession } from "./sessions/utils.ts"; + +/** `GET /read` query: the session whose resources to read. */ +const readQuerySchema = z.object({ + sessionId: z.string(), +}); +type ReadQuery = z.infer; + +/** + * `GET /read`: returns the session's host resources so an agent can see host + * entries added after it spawned (the spawn preamble is static). Memory has its + * own `read_memory` tool, so only `host` rows are returned here. + */ +async function handleRead( + store: SessionStore, + resources: ResourceCatalog, + query: ReadQuery, + res: Response, +): Promise { + const session = await loadSession(store, res, query.sessionId); + if (!session) return; + const all = await resources.listForSession(session.id); + res.json({ resources: all.filter((resource) => resource.kind === "host") }); +} + +/** + * Internal API used only by the resources extension running inside each Pi + * process. It lets any agent re-read the session's host resources, which can be + * added or removed after the agent spawned. Guarded by the same bearer token as + * the other internal APIs. + */ +export function createInternalResourcesRouter( + store: SessionStore, + resources: ResourceCatalog, +): Router { + const router = Router(); + + router.use(requireInternalToken); + + router.get( + "/read", + validate({ query: readQuerySchema }), + (req: Request, res: Response) => + handleRead( + store, + resources, + getValidated(req).query, + res, + ), + ); + + return router; +} diff --git a/apps/server/src/routes/me.ts b/apps/server/src/routes/me.ts index eb5623a..afcbd9f 100644 --- a/apps/server/src/routes/me.ts +++ b/apps/server/src/routes/me.ts @@ -1,21 +1,19 @@ import { type Request, type Response, Router } from "express"; import { resolveUserIdentity } from "../auth/identity.ts"; -import { AUTH_JWT_TOKEN_COOKIE_NAME } from "../config.ts"; /** - * Handles `GET /api/me`. Resolves the current user from the Oktasso JWT in the - * configured cookie ({@link AUTH_JWT_TOKEN_COOKIE_NAME}) and returns the full - * identity. `user_id` is kept (set to the email) for backward compatibility - * alongside the structured `email` / `first_name` / `last_name` fields. + * Handles `GET /api/me`. Resolves the current user from an + * `Authorization: Bearer` JWT (the embed passes one cross-origin) or the + * Oktasso JWT in the configured cookie and returns the full identity. `user_id` + * is kept (set to the email) for backward compatibility alongside the + * structured `email` / `first_name` / `last_name` fields. */ function handleGetMe(req: Request, res: Response): void { - if (!AUTH_JWT_TOKEN_COOKIE_NAME) { - res.status(501).json({ error: "Oktasso cookie name not configured" }); - return; - } - - const identity = resolveUserIdentity(req.headers.cookie); + const identity = resolveUserIdentity( + req.headers.cookie, + req.headers.authorization, + ); if (!identity) { res.status(401).json({ error: "Invalid or missing token" }); return; diff --git a/apps/server/src/routes/sessions/createSession.test.ts b/apps/server/src/routes/sessions/createSession.test.ts index 91fb427..70f67cc 100644 --- a/apps/server/src/routes/sessions/createSession.test.ts +++ b/apps/server/src/routes/sessions/createSession.test.ts @@ -3,11 +3,14 @@ import { test } from "node:test"; import type { Request, Response } from "express"; +import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; +import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; +import type { MemoryManager } from "../../pi/memory.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; -import { handleCreateSession } from "./handlers.ts"; +import { handleCreateSession, type SessionCreateDeps } from "./handlers.ts"; import { createSessionSchema } from "./schemas.ts"; class TestResponse { @@ -51,11 +54,19 @@ test("handleCreateSession returns 404 for unknown bundle ids", async () => { } as AgentBundleStore; const response = new TestResponse(); - await handleCreateSession( + const deps: SessionCreateDeps = { store, - {} as PiAgentManager, - {} as TriggerEngine, + pi: {} as PiAgentManager, + triggerEngine: {} as TriggerEngine, agentBundleStore, + memory: {} as MemoryManager, + resources: {} as ResourceCatalog, + hostPreamble: {} as HostResourcePreamble, + emitResourcesUpdated: () => {}, + }; + + await handleCreateSession( + deps, { headers: {} } as Request, { bundleId: "missing-bundle" }, response as unknown as Response, diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 0dca81a..792cca5 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { Attachment, + HostResourceInput, Session, SessionActivity, SessionConfigMeta, @@ -20,8 +21,12 @@ import { SESSIONS_ROOT, UPLOADS_DIRNAME, } from "../../config.ts"; +import { applyResourceInput } from "../../conversation/hostResources.ts"; import { orchestratorConversationFor } from "../../conversation/participantRegistry.ts"; +import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; import { installBundle } from "../../pi/config/bundleLoader.ts"; +import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; +import type { MemoryManager } from "../../pi/memory.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; @@ -131,22 +136,62 @@ function serveArtifact( }); } +/** Dependencies for provisioning and spawning a new session. */ +export interface SessionCreateDeps { + store: SessionStore; + pi: PiAgentManager; + triggerEngine: TriggerEngine; + agentBundleStore: AgentBundleStore; + memory: MemoryManager; + resources: ResourceCatalog; + hostPreamble: HostResourcePreamble; + /** Notifies a session's room that its resource catalog changed. */ + emitResourcesUpdated: (sessionId: string) => void; +} + +/** + * Applies the host-provided seed resources before Prime spawns, so memory seeds + * write the store the agent reads and host entries are in the spawn preamble + * from the first turn. Refreshes that preamble and signals the room once. + */ +async function seedResources( + deps: SessionCreateDeps, + sessionId: string, + rootPath: string, + resources: HostResourceInput[], + user: UserIdentity | undefined, +): Promise { + if (resources.length === 0) return; + for (const input of resources) { + await applyResourceInput( + { store: deps.store, memory: deps.memory, catalog: deps.resources }, + sessionId, + rootPath, + input, + input.kind === "host" ? user?.email : undefined, + ); + } + await deps.hostPreamble.refresh(sessionId); + deps.emitResourcesUpdated(sessionId); +} + /** * Provisions a new session from an uploaded Configuration Bundle: installs it - * into the session root, records its metadata, and spawns Prime with the - * resolved per-session config. On an invalid bundle the just-created session is - * removed so a failed upload leaves nothing half-provisioned. + * into the session root, records its metadata, applies host seed resources, and + * spawns Prime with the resolved per-session config. On an invalid bundle the + * just-created session is removed so a failed upload leaves nothing + * half-provisioned. */ async function createSessionFromBundle( - store: SessionStore, - pi: PiAgentManager, - triggerEngine: TriggerEngine, + deps: SessionCreateDeps, sessionId: string, rootPath: string, zipBuffer: Buffer, user: UserIdentity | undefined, + resources: HostResourceInput[], res: Response, ): Promise { + const { store, pi, triggerEngine } = deps; try { const { manifest, config } = await installBundle(zipBuffer, rootPath); const meta: SessionConfigMeta = { @@ -181,6 +226,8 @@ async function createSessionFromBundle( }); } + await seedResources(deps, sessionId, rootPath, resources, user); + pi.ensure( sessionId, rootPath, @@ -205,38 +252,37 @@ async function resolveCreateBundle( /** * Handles `POST /api/sessions`. Sessions are created from a saved marketplace - * agent bundle so every session carries bundle config metadata. + * agent bundle so every session carries bundle config metadata. Any host seed + * resources ride along and are applied before Prime spawns. */ export async function handleCreateSession( - store: SessionStore, - pi: PiAgentManager, - triggerEngine: TriggerEngine, - agentBundleStore: AgentBundleStore, + deps: SessionCreateDeps, req: Request, body: CreateSessionInput, res: Response, ): Promise { // Resolve any bundle before creating the session so a bad id fails without // leaving an empty session behind. - const zipBuffer = await resolveCreateBundle(body, agentBundleStore); + const zipBuffer = await resolveCreateBundle(body, deps.agentBundleStore); if (zipBuffer === "not-found") { res.status(404).json({ error: "Agent bundle not found" }); return; } - // Resolve the creator's identity from their Oktasso JWT cookie so every agent - // spawned for the session knows who it's helping. - const user = resolveUserIdentity(req.headers.cookie) ?? undefined; - const session = await store.createSession({ name: body.name, user }); + // Resolve the creator's identity from their bearer token or Oktasso JWT + // cookie so every agent spawned for the session knows who it's helping. + const user = + resolveUserIdentity(req.headers.cookie, req.headers.authorization) ?? + undefined; + const session = await deps.store.createSession({ name: body.name, user }); await createSessionFromBundle( - store, - pi, - triggerEngine, + deps, session.id, session.rootPath, zipBuffer, user, + body.resources ?? [], res, ); } @@ -271,7 +317,10 @@ export async function handleUploadFiles( /** Read-state key: the viewer's email, or `"local"` when no identity resolves. */ function resolveUserKey(req: Request): string { - return resolveUserIdentity(req.headers.cookie)?.email ?? "local"; + return ( + resolveUserIdentity(req.headers.cookie, req.headers.authorization)?.email ?? + "local" + ); } /** Computes the requesting user's {@link SessionActivity} for one session. */ diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index 4a5ce49..dbb064a 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -3,6 +3,8 @@ import { type Request, type Response, Router } from "express"; import type { ParticipantService } from "../../conversation/participantService.ts"; import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; import { getValidated, validate } from "../../middleware/validate.ts"; +import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; +import type { MemoryManager } from "../../pi/memory.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { TriggerManager } from "../../pi/triggers/triggerManager.ts"; @@ -17,6 +19,7 @@ import { handleMarkSessionViewed, handleUpdateSession, handleUploadFiles, + type SessionCreateDeps, uploadFiles, } from "./handlers.ts"; import { registerParticipantRoutes } from "./participants.ts"; @@ -36,13 +39,10 @@ import { registerTriggerRoutes } from "./triggers.ts"; /** Registers the session collection routes (`GET /` list, `POST /` create). */ function registerSessionCollectionRoutes( router: Router, - store: SessionStore, - pi: PiAgentManager, - triggerEngine: TriggerEngine, - agentBundleStore: AgentBundleStore, + createDeps: SessionCreateDeps, ): void { router.get("/", (req: Request, res: Response) => - handleListSessions(store, req, res), + handleListSessions(createDeps.store, req, res), ); router.post( @@ -50,10 +50,7 @@ function registerSessionCollectionRoutes( validate({ body: createSessionSchema }), (req: Request, res: Response) => handleCreateSession( - store, - pi, - triggerEngine, - agentBundleStore, + createDeps, req, getValidated(req).body, res, @@ -140,21 +137,35 @@ export function createSessionsRouter( agentBundleStore: AgentBundleStore, participants: ParticipantService, resources: ResourceCatalog, + memory: MemoryManager, + hostPreamble: HostResourcePreamble, + emitResourcesUpdated: (sessionId: string) => void, ): Router { const router = Router(); - registerSessionCollectionRoutes( - router, + const createDeps: SessionCreateDeps = { store, pi, triggerEngine, agentBundleStore, - ); + memory, + resources, + hostPreamble, + emitResourcesUpdated, + }; + + registerSessionCollectionRoutes(router, createDeps); registerSessionItemRoutes(router, store, pi, triggerEngine); registerSessionActivityRoutes(router, store); registerTriggerRoutes(router, store, triggers, triggerEngine); registerParticipantRoutes(router, store, participants); - registerResourceRoutes(router, store, resources); + registerResourceRoutes(router, { + store, + resources, + memory, + hostPreamble, + emitResourcesUpdated, + }); // Declared after the trigger routes so the `*splat` catch-all doesn't shadow // the more specific `/:id/triggers/...` paths. diff --git a/apps/server/src/routes/sessions/resources.test.ts b/apps/server/src/routes/sessions/resources.test.ts index 5ff0899..de4adb6 100644 --- a/apps/server/src/routes/sessions/resources.test.ts +++ b/apps/server/src/routes/sessions/resources.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -15,6 +15,9 @@ const { Router } = await import("express"); const { registerResourceRoutes } = await import("./resources.ts"); const { ResourceCatalog } = await import("../../conversation/resourceCatalog.ts"); +const { HostResourcePreamble } = + await import("../../pi/hostResourcePreamble.ts"); +const { MemoryManager } = await import("../../pi/memory.ts"); const { InMemoryResourceStore } = await import("../../store/inMemoryResourceStore.ts"); const { InMemorySessionStore } = @@ -51,7 +54,13 @@ async function serve() { const app = express(); app.use(express.json()); const router = Router(); - registerResourceRoutes(router, sessions, catalog); + registerResourceRoutes(router, { + store: sessions, + resources: catalog, + memory: new MemoryManager(), + hostPreamble: new HostResourcePreamble(catalog), + emitResourcesUpdated: () => {}, + }); app.use("/api/sessions", router); const server = app.listen(0); @@ -66,12 +75,32 @@ async function serve() { return { status: res.status, json: (text ? JSON.parse(text) : undefined) as { - resources: { id: string }[]; + resources: { id: string; kind: string; uri: string }[]; + }, + }; + }; + + const post = async (pathname: string, body: unknown) => { + const res = await fetch(`${base}${pathname}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + return { + status: res.status, + json: (text ? JSON.parse(text) : undefined) as { + resource?: { id: string; kind: string; uri: string; name: string }; }, }; }; - return { get, catalog, sessionId: session.id, a, b }; + const del = async (pathname: string) => { + const res = await fetch(`${base}${pathname}`, { method: "DELETE" }); + return { status: res.status }; + }; + + return { get, post, del, catalog, session, sessionId: session.id, a, b }; } test("GET resources returns the whole session catalog unscoped", async () => { @@ -114,3 +143,80 @@ test("GET resources scoped to a participant consults surfacedFor", async () => { ); assert.equal(other.json.resources.length, 2); }); + +test("POST a host resource catalogs it and it appears in GET", async () => { + const { get, post, sessionId } = await serve(); + + const created = await post(`/${sessionId}/resources`, { + kind: "host", + name: "Orders pipeline", + uri: "https://tangent.example/pipelines/orders", + meta: { description: "Ingests orders." }, + }); + assert.equal(created.status, 201); + assert.equal(created.json.resource?.kind, "host"); + assert.equal(created.json.resource?.name, "Orders pipeline"); + + const listed = await get(`/${sessionId}/resources`); + const host = listed.json.resources.find((r) => r.kind === "host"); + assert.equal(host?.uri, "https://tangent.example/pipelines/orders"); +}); + +test("POST a memory resource writes MEMORY.md and catalogs it", async () => { + const { post, session, sessionId } = await serve(); + + const created = await post(`/${sessionId}/resources`, { + kind: "memory", + scope: "session", + content: "Prefer concise plans.", + }); + assert.equal(created.status, 201); + assert.equal(created.json.resource?.uri, "memory://session"); + + const file = readFileSync(path.join(session.rootPath, "MEMORY.md"), "utf8"); + assert.match(file, /Prefer concise plans\./); +}); + +test("POST rejects a non-host-writable kind", async () => { + const { post, sessionId } = await serve(); + const rejected = await post(`/${sessionId}/resources`, { + kind: "artifact", + name: "A", + uri: "artifacts/a.html", + }); + assert.equal(rejected.status, 400); +}); + +test("DELETE drops a host row", async () => { + const { get, post, del, sessionId } = await serve(); + const uri = "https://tangent.example/pipelines/orders"; + await post(`/${sessionId}/resources`, { kind: "host", name: "Orders", uri }); + + const removed = await del( + `/${sessionId}/resources?uri=${encodeURIComponent(uri)}`, + ); + assert.equal(removed.status, 204); + + const listed = await get(`/${sessionId}/resources`); + assert.equal( + listed.json.resources.some((r) => r.uri === uri), + false, + ); +}); + +test("DELETE a memory resource clears the store", async () => { + const { post, del, session, sessionId } = await serve(); + await post(`/${sessionId}/resources`, { + kind: "memory", + scope: "session", + content: "remember-me-secret", + }); + + const removed = await del( + `/${sessionId}/resources?uri=${encodeURIComponent("memory://session")}`, + ); + assert.equal(removed.status, 204); + + const file = readFileSync(path.join(session.rootPath, "MEMORY.md"), "utf8"); + assert.doesNotMatch(file, /remember-me-secret/); +}); diff --git a/apps/server/src/routes/sessions/resources.ts b/apps/server/src/routes/sessions/resources.ts index 6e22c9e..57c9889 100644 --- a/apps/server/src/routes/sessions/resources.ts +++ b/apps/server/src/routes/sessions/resources.ts @@ -1,14 +1,47 @@ import type { Resource } from "@tangent/shared/contracts.ts"; import { type Request, type Response, Router } from "express"; +import { resolveUserIdentity } from "../../auth/identity.ts"; +import { + applyResourceInput, + removeResourceByUri, + type ResourceSeedDeps, +} from "../../conversation/hostResources.ts"; import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; import { catalogWorkspaceFiles } from "../../conversation/workspaceFiles.ts"; import { getValidated, validate } from "../../middleware/validate.ts"; +import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; +import type { MemoryManager } from "../../pi/memory.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; -import type { ListResourcesQuery, SessionParams } from "./schemas.ts"; -import { listResourcesQuerySchema, sessionParamsSchema } from "./schemas.ts"; +import type { + DeleteResourceQuery, + HostResourceInputBody, + ListResourcesQuery, + SessionParams, +} from "./schemas.ts"; +import { + deleteResourceQuerySchema, + hostResourceInputSchema, + listResourcesQuerySchema, + sessionParamsSchema, +} from "./schemas.ts"; import { loadSession } from "./utils.ts"; +/** Everything the resource routes need to read, write, and surface the catalog. */ +export interface ResourceRouteDeps { + store: SessionStore; + resources: ResourceCatalog; + memory: MemoryManager; + hostPreamble: HostResourcePreamble; + /** Notifies a session's room that its resource catalog changed. */ + emitResourcesUpdated: (sessionId: string) => void; +} + +/** The seed deps drawn from the route deps. */ +function seedDeps(deps: ResourceRouteDeps): ResourceSeedDeps { + return { store: deps.store, memory: deps.memory, catalog: deps.resources }; +} + /** * The catalog to surface: scoped to a Conversation + Participant's grants when * both are named (via {@link ResourceCatalog.surfacedFor}, which is @@ -36,23 +69,79 @@ async function resolveResources( * a Conversation + Participant when the query names both. */ async function handleListResources( - store: SessionStore, - resources: ResourceCatalog, + deps: ResourceRouteDeps, id: string, query: ListResourcesQuery, res: Response, ): Promise { - const session = await loadSession(store, res, id); + const session = await loadSession(deps.store, res, id); + if (!session) return; + await catalogWorkspaceFiles(deps.resources, session); + res.json({ + resources: await resolveResources(deps.resources, session.id, query), + }); +} + +/** + * `POST /:id/resources` → adds a host-owned resource (a memory write or a host + * entry). Refreshes the spawn preamble and signals the room so open clients + * refetch. Returns the stored resource. + */ +async function handleAddResource( + deps: ResourceRouteDeps, + id: string, + body: HostResourceInputBody, + req: Request, + res: Response, +): Promise { + const session = await loadSession(deps.store, res, id); + if (!session) return; + + const author = + body.kind === "host" + ? resolveUserIdentity(req.headers.cookie, req.headers.authorization) + ?.email + : undefined; + const resource = await applyResourceInput( + seedDeps(deps), + session.id, + session.rootPath, + body, + author, + ); + await deps.hostPreamble.refresh(session.id); + deps.emitResourcesUpdated(session.id); + res.status(201).json({ resource }); +} + +/** + * `DELETE /:id/resources?uri=...` → drops a host-owned resource. Removing a + * memory store's entry also empties the store so the agent's `read_memory` and + * the catalog stay consistent. + */ +async function handleRemoveResource( + deps: ResourceRouteDeps, + id: string, + query: DeleteResourceQuery, + res: Response, +): Promise { + const session = await loadSession(deps.store, res, id); if (!session) return; - await catalogWorkspaceFiles(resources, session); - res.json({ resources: await resolveResources(resources, session.id, query) }); + await removeResourceByUri( + seedDeps(deps), + session.id, + session.rootPath, + query.uri, + ); + await deps.hostPreamble.refresh(session.id); + deps.emitResourcesUpdated(session.id); + res.status(204).end(); } -/** Registers the resource catalog read route on a session. */ +/** Registers the resource catalog read/write routes on a session. */ export function registerResourceRoutes( router: Router, - store: SessionStore, - resources: ResourceCatalog, + deps: ResourceRouteDeps, ): void { router.get( "/:id/resources", @@ -63,7 +152,35 @@ export function registerResourceRoutes( SessionParams, ListResourcesQuery >(req); - return handleListResources(store, resources, params.id, query, res); + return handleListResources(deps, params.id, query, res); + }, + ); + + router.post( + "/:id/resources", + validate({ params: sessionParamsSchema, body: hostResourceInputSchema }), + (req: Request, res: Response) => { + const { params, body } = getValidated< + HostResourceInputBody, + SessionParams + >(req); + return handleAddResource(deps, params.id, body, req, res); + }, + ); + + router.delete( + "/:id/resources", + validate({ + params: sessionParamsSchema, + query: deleteResourceQuerySchema, + }), + (req: Request, res: Response) => { + const { params, query } = getValidated< + unknown, + SessionParams, + DeleteResourceQuery + >(req); + return handleRemoveResource(deps, params.id, query, res); }, ); } diff --git a/apps/server/src/routes/sessions/schemas.ts b/apps/server/src/routes/sessions/schemas.ts index 5e37654..1d08997 100644 --- a/apps/server/src/routes/sessions/schemas.ts +++ b/apps/server/src/routes/sessions/schemas.ts @@ -1,12 +1,39 @@ import { THINKING_LEVELS } from "@tangent/shared/contracts.ts"; import { z } from "zod"; +/** + * A resource the host may seed at create or add later. Only `memory` and `host` + * are host-writable; artifacts, attachments, and files stay on their own + * mechanisms and are rejected here. + */ +export const hostResourceInputSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("memory"), + scope: z.enum(["session", "global"]).optional(), + content: z.string().trim().min(1), + }), + z.object({ + kind: z.literal("host"), + name: z.string().trim().min(1), + uri: z.string().trim().min(1), + meta: z.record(z.string(), z.unknown()).optional(), + }), +]); +export type HostResourceInputBody = z.infer; + export const createSessionSchema = z.object({ name: z.string().optional(), bundleId: z.string().min(1), + resources: z.array(hostResourceInputSchema).optional(), }); export type CreateSessionInput = z.infer; +/** `DELETE /:id/resources` query: the resource uri to remove. */ +export const deleteResourceQuerySchema = z.object({ + uri: z.string().min(1), +}); +export type DeleteResourceQuery = z.infer; + /** Update-session body. */ export const updateSessionSchema = z.object({ name: z.string().optional(), diff --git a/apps/server/src/sockets/chat.test.ts b/apps/server/src/sockets/chat.test.ts index d3388ac..1931586 100644 --- a/apps/server/src/sockets/chat.test.ts +++ b/apps/server/src/sockets/chat.test.ts @@ -3,15 +3,21 @@ import { test } from "node:test"; import { type ChatAuthor, + type ChatMessagePayload, connectorFor, humanAuthor, type Session, type UserIdentity, } from "@tangent/shared/contracts.ts"; +import type { Socket } from "socket.io"; import type { Membership } from "../store/membershipStore.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; -import { authorizedConversations } from "./chat.ts"; +import { + authorizedConversations, + type ChatHandlerDeps, + handleChatMessage, +} from "./chat.ts"; const OWNER: UserIdentity = { email: "owner@example.com", @@ -108,3 +114,56 @@ test("a Membership in an unknown Conversation is ignored", () => { ]); assert.equal(authorized.size, 0); }); + +function primeAgent(homeConversationId: string): SessionAgent { + return { + id: "prime", + sessionId: "s1", + role: "prime", + name: "Prime", + capabilities: ["orchestrator"], + status: "active", + connector: connectorFor("pi-stdio"), + homeConversationId, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +test("a human message always ensures Prime, even when it targets a sub-agent thread", async () => { + const primeHome = "conv-prime-home"; + const ensureCalls: unknown[][] = []; + const postCalls: { conversationId?: string }[] = []; + + const deps = { + store: { + getSession: async () => session(OWNER), + listAgents: async () => [primeAgent(primeHome)], + }, + pi: { + ensure: (...args: unknown[]) => { + ensureCalls.push(args); + }, + }, + connectors: { list: () => [] }, + participantService: { list: async () => [] }, + conversations: { + post: async (input: { conversationId?: string }) => { + postCalls.push(input); + return { message: {}, woke: [], refused: [] }; + }, + }, + } as unknown as ChatHandlerDeps; + + const socket = { emit: () => {} } as unknown as Socket; + const payload: ChatMessagePayload = { + sessionId: "s1", + content: "hello", + conversationId: "sub-1", + }; + + await handleChatMessage(socket, deps, humanAuthor(OWNER), payload); + + assert.equal(ensureCalls.length, 1); + assert.equal(ensureCalls[0]?.[5], primeHome); + assert.equal(postCalls[0]?.conversationId, "sub-1"); +}); diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index f8bc18c..2d47547 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -111,7 +111,11 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { // Resolved once per connection: the identity is the connection's, and the // client never gets a say in who its messages are attributed to. - const author = resolveSocketAuthor(socket.handshake.headers.cookie); + const handshakeToken = socket.handshake.auth?.token; + const author = resolveSocketAuthor( + socket.handshake.headers.cookie, + typeof handshakeToken === "string" ? handshakeToken : undefined, + ); // (sessionId\0participantId) pairs whose presence this socket is holding up, // so the last of a person's tabs to close is what marks them detached. @@ -391,13 +395,19 @@ export async function mentionCandidates( /** * The chat identity of whoever is on the other end of a socket, read from the - * connection's own cookie rather than from anything the client sends. Falls back - * to {@link DEFAULT_USER} when no JWT is configured or the cookie is absent — - * the same fallback the UI uses, so both sides agree on the id and a message - * still renders as your own. + * handshake's bearer token (sent cross-origin by the embed) or the connection's + * own cookie — never from anything in the message payload. Falls back to + * {@link DEFAULT_USER} when no JWT resolves, so both sides agree on the id and a + * message still renders as your own. */ -export function resolveSocketAuthor(cookieHeader: string | undefined) { - return humanAuthor(resolveUserIdentity(cookieHeader) ?? DEFAULT_USER); +export function resolveSocketAuthor( + cookieHeader: string | undefined, + authToken?: string | undefined, +) { + const authorization = authToken ? `Bearer ${authToken}` : undefined; + return humanAuthor( + resolveUserIdentity(cookieHeader, authorization) ?? DEFAULT_USER, + ); } /** Key of the presence a socket holds for one participant in one session. */ @@ -454,7 +464,7 @@ function markAbsent(deps: ChatHandlerDeps, tracked: Set): void { * `pi.ensure` stays because it is lifecycle, not delivery: a cold session has no * Prime process for a reaction to reach. */ -async function handleChatMessage( +export async function handleChatMessage( socket: Socket, deps: ChatHandlerDeps, author: ChatAuthor, @@ -474,15 +484,20 @@ async function handleChatMessage( session.id, ); const conversationId = payload.conversationId ?? primaryConversationId; - if (conversationId === primaryConversationId) - pi.ensure( - session.id, - session.rootPath, - undefined, - undefined, - undefined, - primaryConversationId, - ); + // Always ensure Prime, regardless of which thread the message targets: it is + // idempotent, and unconditionally reviving Prime here means a human message + // self-heals a cold/raced session at the delivery layer instead of depending + // on the client sending the exact primary conversation id. Without this, a + // send that races `chat:join` (or arrives right after a backend restart) with + // the seed id skips the ensure and falls through to the NullConnector refusal. + pi.ensure( + session.id, + session.rootPath, + undefined, + undefined, + undefined, + primaryConversationId, + ); await conversations.post({ sessionId: session.id, diff --git a/apps/web/embed-harness/README.md b/apps/web/embed-harness/README.md new file mode 100644 index 0000000..9b45e85 --- /dev/null +++ b/apps/web/embed-harness/README.md @@ -0,0 +1,80 @@ +# Embed harness + +A minimal, framework-free host page that drives the `tangent-*` custom elements +directly (no npm wrapper), to exercise the runtime bundle, shadow-DOM styling, +`newSession`, and the `on*` events. It deliberately sets its own `--background` +token and a serif font so you can confirm styles do not leak either way. + +The page lays out `` (click a row to swap the chat), a +column of `` + `` + +`` + ``, ``, and +`` (revealed when the chat, asset list, or resource +list opens a page). Clicking an agent sets `chat.agentId` so the host can show +that subagent's thread. Opening a viewable resource points the artifact viewer +at it; toggling a participant's mute mutates the shared session and logs +`toggle-mute`. `` is registered by the same runtime; drive +it from your own host page by setting `moduleUrl` + `kind`. + +## Build the runtime first + +```bash +pnpm --filter @tangent/web build:embed +``` + +This emits `apps/web/dist/embed/v1/tangent-elements.js` (+ hashed chunks). + +## Same-origin (simplest, no CORS wall) + +Server auth/CORS hardening is deferred, so cross-origin `/api` calls are blocked +by the browser today. To validate the elements end to end, serve the harness and +the runtime from the **same** origin as the Tangent API. + +The straightforward path is the fullstack Docker image, where nginx serves +`/embed/**`, `/api/**`, and static files on one port: + +```bash +docker build -f Dockerfile.fullstack -t tangent . +docker run -p 8000:8000 tangent +``` + +Copy `index.html` next to the served UI (or open it through the same origin) and +load it with `?origin=http://localhost:8000` (or omit `origin` if served from +that origin). Click "New session". + +## True cross-origin + +Serve `index.html` from a second port (e.g. `npx serve apps/web/embed-harness`) +and point it at the Tangent origin: + +``` +http://localhost:3000/?origin=https://tangent.example +``` + +This works once the deferred server hardening lands (Bearer JWT verification, +per-request session authorization, and a CORS allowlist that also sets +`Access-Control-Allow-Origin` on `/api/**` and the Socket.IO handshake). Until +then the runtime module import succeeds (nginx sets CORS on `/embed/**`) but the +first `/api/sessions` call is blocked cross-origin. + +## What to validate (shadow-DOM spike) + +- The model picker dropdown, message action menus, and session-list row menus + open, are styled, escape the element's box (they portal to + ``), and trap/restore focus correctly with the keyboard. + These live in _different_ shadow roots than their triggers, so confirm + positioning and dismissal too. +- Streaming markdown renders with the right colors — tokens arrive by inheritance + onto `:host`, not from `:root`. +- Toggling `provider.theme = { colorScheme: "dark" }` reskins every element + (list, chat, artifact viewer, and the overlay root) without touching the host. +- Selecting a session in `` swaps the chat and the agent / + asset lists; the live status dots come from the lobby socket via the shared + runtime. +- Clicking an agent in `` sets `chat.agentId` (Prime clears + it); clicking a page in `` reveals the artifact viewer. +- `` surfaces the session's catalogued content read-only; + opening a viewable `file`/`artifact` reveals the artifact viewer at its + resolved url, while `memory`/`attachment` rows are inert. +- `` shows the roster with live presence; the mute + toggle on an agent in the active Conversation mutates the shared session and + emits `toggle-mute`. diff --git a/apps/web/embed-harness/index.html b/apps/web/embed-harness/index.html new file mode 100644 index 0000000..6bd722f --- /dev/null +++ b/apps/web/embed-harness/index.html @@ -0,0 +1,218 @@ + + + + + + Tangent embed harness + + + +
+
+ Host chrome + + + + +
+
+ + +
+ + + + +
+ + +
+
+
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json index 7e98840..b57f957 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "build:embed": "vite build --config vite.embed.config.ts", "preview": "vite preview", "lint": "eslint .", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", diff --git a/apps/web/src/embed/components/EmbedRoot.tsx b/apps/web/src/embed/components/EmbedRoot.tsx new file mode 100644 index 0000000..1c60445 --- /dev/null +++ b/apps/web/src/embed/components/EmbedRoot.tsx @@ -0,0 +1,28 @@ +import { PortalContainerContext } from "@tangent/ui-primitives/portal-container"; +import { QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +import { queryClient } from "@/shared/api/queryClient"; + +interface EmbedRootProps { + /** Container Radix portals target (the shared overlay root). */ + portalContainer: HTMLElement; +} + +/** + * Provider stack for an embed element's React root. Uses the shared query + * client (one per `embed.js` instance, so roots stay in sync) and points Radix + * portals at the overlay root so menus/tooltips stay styled and isolated. + */ +export function EmbedRoot({ + portalContainer, + children, +}: PropsWithChildren) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/embed/components/EmbeddedAgentList.tsx b/apps/web/src/embed/components/EmbeddedAgentList.tsx new file mode 100644 index 0000000..b4a6837 --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedAgentList.tsx @@ -0,0 +1,59 @@ +import { Box } from "@tangent/ui-primitives/box"; + +import { AgentList } from "@/features/chat/components/sidebar/agents/AgentList"; +import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { type Agent, buildAgents } from "@/features/chat/model/agents"; +import { ScrollRegion } from "@/shared/ui/patterns/scroll-region"; + +import type { EmbedAgentPayload } from "../types"; + +interface EmbeddedAgentListProps { + sessionId: string; + selectedId?: string; + onOpen: (agent: EmbedAgentPayload) => void; + onRemove: (id: string) => void; +} + +function toPayload(agent: Agent, conversationId: string): EmbedAgentPayload { + return { + id: agent.id, + name: agent.name, + kind: agent.kind, + status: agent.status, + conversationId, + }; +} + +/** + * The embedded agent list: Prime plus the live sub-agent roster. Clicking a + * card emits `onOpen` so the host can place a ``; dismissing a + * killed sub-agent updates the shared session room and notifies the host. + */ +export function EmbeddedAgentList({ + sessionId, + selectedId, + onOpen, + onRemove, +}: EmbeddedAgentListProps) { + const chat = useSessionChat(sessionId); + const agents = buildAgents(chat.subagents); + + return ( + + + + onOpen(toPayload(agent, chat.conversationForAgent(agent.id))) + } + onRemove={(agent) => { + chat.dismissSubagent(agent.id); + onRemove(agent.id); + }} + /> + + + ); +} diff --git a/apps/web/src/embed/components/EmbeddedArtifactViewer.tsx b/apps/web/src/embed/components/EmbeddedArtifactViewer.tsx new file mode 100644 index 0000000..5e46247 --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedArtifactViewer.tsx @@ -0,0 +1,37 @@ +import type { Attachment } from "@tangent/shared/contracts"; + +import { ArtifactTabView } from "@/features/chat/components/tabs/ArtifactTabView"; + +interface EmbeddedArtifactViewerProps { + /** Session that owns the artifact; review screenshots upload into it. */ + sessionId: string; + /** Resolved artifact URL under the session file API. */ + url: string; + /** Human-readable title (the iframe's accessible name). */ + title: string; + /** Forwards a review prompt (with the screenshot attached) to the host. */ + onSendPrompt?: (content: string, attachments?: Attachment[]) => void; +} + +/** + * The embedded artifact viewer: the shared `ArtifactTabView` with review-to-host + * wiring. The host decides what to do with a review prompt (usually feed it to a + * ``). + */ +export function EmbeddedArtifactViewer({ + sessionId, + url, + title, + onSendPrompt, +}: EmbeddedArtifactViewerProps) { + return ( + + onSendPrompt?.(content, attachments) + } + /> + ); +} diff --git a/apps/web/src/embed/components/EmbeddedAssetList.tsx b/apps/web/src/embed/components/EmbeddedAssetList.tsx new file mode 100644 index 0000000..9395324 --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedAssetList.tsx @@ -0,0 +1,70 @@ +import { Box } from "@tangent/ui-primitives/box"; + +import { AssetList } from "@/features/chat/components/sidebar/assets/AssetList"; +import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { type Asset, buildAssets } from "@/features/chat/model/assets"; +import { ScrollRegion } from "@/shared/ui/patterns/scroll-region"; + +import type { EmbedAssetPayload } from "../types"; + +interface EmbeddedAssetListProps { + sessionId: string; + selectedId?: string; + onOpen: (asset: EmbedAssetPayload) => void; + onUnpin: (path: string) => void; +} + +function toPayload(asset: Asset): EmbedAssetPayload { + if (asset.kind === "trigger") { + return { + kind: "trigger", + id: asset.id, + title: asset.title, + triggerKind: asset.trigger.kind, + enabled: asset.trigger.enabled, + }; + } + return { + kind: asset.kind, + id: asset.id, + title: asset.title, + url: asset.url, + path: asset.path, + }; +} + +/** + * The embedded asset list: pinned pages/files and triggers. Opening a row + * emits `onOpen` so the host can place an artifact viewer (or ignore a + * trigger); unpin goes through the shared session room and notifies the host. + */ +export function EmbeddedAssetList({ + sessionId, + selectedId, + onOpen, + onUnpin, +}: EmbeddedAssetListProps) { + const chat = useSessionChat(sessionId); + const assets = buildAssets({ + sessionId, + artifacts: chat.artifacts, + triggers: chat.triggers, + }); + + return ( + + + onOpen(toPayload(asset))} + onUnpin={(path) => { + chat.unpinArtifact(path); + onUnpin(path); + }} + /> + + + ); +} diff --git a/apps/web/src/embed/components/EmbeddedBundledUi.tsx b/apps/web/src/embed/components/EmbeddedBundledUi.tsx new file mode 100644 index 0000000..9e57e0e --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedBundledUi.tsx @@ -0,0 +1,42 @@ +import { BundleUiHost } from "@/features/bundle-ui/BundleUiHost"; +import type { BundleUiKind } from "@/features/bundle-ui/types"; + +interface EmbeddedBundledUiProps { + /** URL of the compiled bundle component JS. */ + moduleUrl: string; + /** Which surface the component renders on. */ + kind: BundleUiKind; + /** JSON props for a `message` component (ignored for `panel`). */ + props?: Record; + /** localStorage namespace for the component's persisted state (optional). */ + stateNamespace?: string; + /** Forwards a composed prompt from a `panel` component to the host. */ + onSendPrompt?: (text: string) => void; + /** The component asked to collapse its host surface. */ + onCollapse?: () => void; +} + +/** + * The embedded bundle-UI slot: the shared `BundleUiHost`. The embed build aliases + * the worker factory to an inlined variant (see `vite.embed.config.ts`), so the + * runtime stays a single file. The host wires prompt/collapse via callbacks. + */ +export function EmbeddedBundledUi({ + moduleUrl, + kind, + props, + stateNamespace, + onSendPrompt, + onCollapse, +}: EmbeddedBundledUiProps) { + return ( + + ); +} diff --git a/apps/web/src/embed/components/EmbeddedChat.tsx b/apps/web/src/embed/components/EmbeddedChat.tsx new file mode 100644 index 0000000..8bb613c --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedChat.tsx @@ -0,0 +1,228 @@ +import { + type ParticipantWithMemberships, + PI_AGENT, +} from "@tangent/shared/contracts"; +import { Box } from "@tangent/ui-primitives/box"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { useEffect } from "react"; + +import { AgentModelPicker } from "@/features/chat/components/composer/AgentModelPicker"; +import { ChatInput } from "@/features/chat/components/composer/ChatInput"; +import { ChatMessageList } from "@/features/chat/components/message/ChatMessageList"; +import { SubagentTabView } from "@/features/chat/components/tabs/SubagentTabView"; +import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { useSessionParticipants } from "@/features/chat/hooks/useSessionParticipants"; +import { buildMentionCandidates } from "@/features/chat/model/mentions"; +import { useSession } from "@/features/sessions/hooks/useSession"; +import { EmptyState } from "@/shared/ui/patterns/empty-state"; + +import type { TangentRuntime } from "../types"; + +interface EmbeddedChatProps { + sessionId: string; + runtime: TangentRuntime; + /** When set, render that agent's thread instead of Prime. */ + agentId?: string; + /** The host opens the resource however it wants (a tab, a drawer, ...). */ + onOpenArtifact?: (url: string, title: string) => void; + /** Fired when the user submits a prompt, so the host can react. */ + onSendPrompt?: (content: string) => void; +} + +function isPrimeAgent(agentId: string | undefined): boolean { + return !agentId || agentId === PI_AGENT.id; +} + +/** + * The embedded chat surface: Prime's (or a chosen sub-agent's) message list + * plus the composer. No dock windows, tab strip, or session card — the host + * owns all chrome and placement. + */ +export function EmbeddedChat({ + sessionId, + runtime, + agentId, + onOpenArtifact, + onSendPrompt, +}: EmbeddedChatProps) { + const chat = useSessionChat(sessionId); + const { data: session } = useSession(sessionId); + const { data: participants = [] } = useSessionParticipants(sessionId); + const bundleId = session?.config?.id; + const { historyLoaded, primaryConversationId, rosterReady } = chat; + const targetingPrime = isPrimeAgent(agentId); + + useEffect(() => { + if (!targetingPrime) return; + if (!rosterReady) return; + const pending = runtime.takePendingPrompt(sessionId); + if (!pending) return; + if (pending.model || pending.thinkingDepth) { + chat.setAgentModel(PI_AGENT.id, { + model: pending.model, + thinkingDepth: pending.thinkingDepth, + }); + } + if (pending.prompt || pending.attachments?.length) { + chat.send(pending.prompt ?? "", { + conversationId: primaryConversationId, + delivery: pending.delivery, + attachments: pending.attachments, + }); + } + // chat callbacks are recreated each render; keying on the roster gate is intended. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rosterReady, sessionId, targetingPrime]); + + const togglePinArtifact = (path: string, title: string) => { + if (chat.pinnedPaths.has(path)) { + chat.unpinArtifact(path); + } else { + chat.pinArtifact(path, title); + } + }; + + if (!targetingPrime && agentId) { + return ( + + ); + } + + const primeModel = chat.getAgentModel(PI_AGENT.id); + + return ( + + + + + chat.setAgentModel(PI_AGENT.id, selection)} + disabled={!chat.connected || !rosterReady} + /> + + + chat.abort(primaryConversationId)} + onSubmit={(content, { delivery, attachments }) => { + chat.send(content, { + conversationId: primaryConversationId, + delivery, + attachments, + }); + onSendPrompt?.(content); + }} + /> + + ); +} + +interface SubagentChatProps { + sessionId: string; + agentId: string; + bundleId?: string; + chat: ReturnType; + rosterReady: boolean; + participants: ParticipantWithMemberships[]; + onOpenArtifact?: (url: string, title: string) => void; + onSendPrompt?: (content: string) => void; + onTogglePinArtifact: (path: string, title: string) => void; +} + +function SubagentChat({ + sessionId, + agentId, + bundleId, + chat, + rosterReady, + participants, + onOpenArtifact, + onSendPrompt, + onTogglePinArtifact, +}: SubagentChatProps) { + if (!rosterReady) { + return ; + } + + const subagent = chat.subagents.find((s) => s.id === agentId); + if (!subagent) { + return ( + + + + ); + } + + const conversationId = chat.conversationForAgent(agentId); + const model = chat.getAgentModel(agentId); + const mentionCandidates = buildMentionCandidates( + chat.subagents, + participants, + ); + + return ( + chat.abort(conversationId)} + onRemove={() => chat.dismissSubagent(agentId)} + onSubmit={(content, { delivery, attachments }) => { + chat.send(content, { conversationId, delivery, attachments }); + onSendPrompt?.(content); + }} + model={model?.model} + thinkingDepth={model?.thinkingDepth} + onSetModel={(selection) => chat.setAgentModel(agentId, selection)} + onOpenArtifact={onOpenArtifact} + pinnedPaths={chat.pinnedPaths} + onTogglePinArtifact={onTogglePinArtifact} + mentionCandidates={mentionCandidates} + /> + ); +} diff --git a/apps/web/src/embed/components/EmbeddedParticipantList.tsx b/apps/web/src/embed/components/EmbeddedParticipantList.tsx new file mode 100644 index 0000000..269ef8d --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedParticipantList.tsx @@ -0,0 +1,54 @@ +import { PI_AGENT } from "@tangent/shared/contracts"; +import { Box } from "@tangent/ui-primitives/box"; + +import { ParticipantList } from "@/features/chat/components/sidebar/participants/ParticipantList"; +import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { + useMuteMembership, + useSessionParticipants, +} from "@/features/chat/hooks/useSessionParticipants"; +import { ScrollRegion } from "@/shared/ui/patterns/scroll-region"; + +import type { EmbedMuteTogglePayload } from "../types"; + +interface EmbeddedParticipantListProps { + sessionId: string; + /** The Conversation a mute acts on; defaults to Prime's thread. */ + agentId?: string; + onToggleMute: (toggle: EmbedMuteTogglePayload) => void; +} + +/** + * The embedded participant list: the session's roster with live presence and a + * mute toggle for an agent in the active Conversation. The toggle mutates the + * shared session state and notifies the host, mirroring the Participants window. + */ +export function EmbeddedParticipantList({ + sessionId, + agentId, + onToggleMute, +}: EmbeddedParticipantListProps) { + const chat = useSessionChat(sessionId); + const activeConversationId = chat.conversationForAgent( + agentId || PI_AGENT.id, + ); + + const { data: participants = [] } = useSessionParticipants(sessionId); + const muteMembership = useMuteMembership(sessionId); + + return ( + + + { + muteMembership.mutate({ participantId, conversationId, muted }); + onToggleMute({ participantId, conversationId, muted }); + }} + /> + + + ); +} diff --git a/apps/web/src/embed/components/EmbeddedResourceList.tsx b/apps/web/src/embed/components/EmbeddedResourceList.tsx new file mode 100644 index 0000000..af8f061 --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedResourceList.tsx @@ -0,0 +1,83 @@ +import type { Resource } from "@tangent/shared/contracts"; +import { PI_AGENT } from "@tangent/shared/contracts"; +import { Box } from "@tangent/ui-primitives/box"; + +import { ResourceList } from "@/features/chat/components/sidebar/resources/ResourceList"; +import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { useSessionParticipants } from "@/features/chat/hooks/useSessionParticipants"; +import { useSessionResources } from "@/features/chat/hooks/useSessionResources"; +import { apiUrl } from "@/shared/lib/basePath"; +import { resolveUrl } from "@/shared/lib/markdown/artifact"; +import { ScrollRegion } from "@/shared/ui/patterns/scroll-region"; + +import type { EmbedResourcePayload } from "../types"; + +interface EmbeddedResourceListProps { + sessionId: string; + /** Scopes the catalog to a sub-agent's Conversation; defaults to Prime's. */ + agentId?: string; + onOpen: (resource: EmbedResourcePayload) => void; +} + +function toPayload(resource: Resource, url: string): EmbedResourcePayload { + return { + id: resource.id, + kind: resource.kind, + name: resource.name, + uri: resource.uri, + url, + authorParticipantId: resource.authorParticipantId, + }; +} + +/** + * The embedded resource list: the session's catalogued content, read-only. + * Opening a viewable `file`/`artifact` resolves its uri to the file API url and + * emits `onOpen` so the host can place an artifact viewer; other kinds are + * inert, mirroring the Resources window. + */ +export function EmbeddedResourceList({ + sessionId, + agentId, + onOpen, +}: EmbeddedResourceListProps) { + const chat = useSessionChat(sessionId); + const activeConversationId = chat.conversationForAgent( + agentId || PI_AGENT.id, + ); + + const { data: participants = [] } = useSessionParticipants(sessionId); + + // Scope the catalog to the current human when they are an invited Participant, + // so surfacing consults their per-Conversation grants; the owner keeps the + // whole catalog. Mirrors SessionChat. + const currentParticipant = participants.find( + (p) => p.id === chat.currentAuthorId && !p.revokedAt, + ); + const resourceScope = currentParticipant + ? { + conversationId: activeConversationId, + participantId: currentParticipant.id, + } + : undefined; + + const { data: resources = [] } = useSessionResources( + sessionId, + resourceScope, + ); + + return ( + + + { + const base = apiUrl(`/api/sessions/${sessionId}/files`); + const url = resolveUrl(resource.uri, base) ?? resource.uri; + onOpen(toPayload(resource, url)); + }} + /> + + + ); +} diff --git a/apps/web/src/embed/components/EmbeddedSessionList.tsx b/apps/web/src/embed/components/EmbeddedSessionList.tsx new file mode 100644 index 0000000..b51ea87 --- /dev/null +++ b/apps/web/src/embed/components/EmbeddedSessionList.tsx @@ -0,0 +1,43 @@ +import { Box } from "@tangent/ui-primitives/box"; + +import { SessionSwitcherList } from "@/features/chat/components/sidebar/sessions/SessionSwitcherList"; +import { SessionStatusProvider } from "@/features/sessions/components/SessionStatusProvider"; +import { useSessions } from "@/features/sessions/hooks/useSessions"; +import { ScrollRegion } from "@/shared/ui/patterns/scroll-region"; + +interface EmbeddedSessionListProps { + /** Highlighted row; scrolled into view when set. */ + selectedId?: string; + /** A row was clicked; the host decides what selecting a session means. */ + onSelect: (id: string) => void; + /** A session was deleted from its row menu (host reacts if it was current). */ + onDeleted: (id: string) => void; +} + +/** + * The embedded session list: the shared `SessionSwitcherList` fed by its own + * `useSessions` query and wrapped in `SessionStatusProvider` for live run-status + * dots. Row actions (rename/archive/delete) come from `onDeleted` being set. + */ +export function EmbeddedSessionList({ + selectedId, + onSelect, + onDeleted, +}: EmbeddedSessionListProps) { + const { data: sessions } = useSessions(); + + return ( + + + + + + + + ); +} diff --git a/apps/web/src/embed/elements/agent-list-element.ts b/apps/web/src/embed/elements/agent-list-element.ts new file mode 100644 index 0000000..7e2707c --- /dev/null +++ b/apps/web/src/embed/elements/agent-list-element.ts @@ -0,0 +1,64 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedAgentList } from "../components/EmbeddedAgentList"; +import type { EmbedAgentPayload } from "../types"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-agent-list"; + +/** + * Mounts the embedded agent list. `sessionId` and `selectedId` are + * properties/attributes; `open-agent` and `remove-agent` are emitted as + * composed `CustomEvent`s for the npm wrapper to surface as `on*` props. + */ +export class TangentAgentListElement extends EmbeddedElement { + private currentSessionId = ""; + private currentSelectedId = ""; + + static get observedAttributes(): string[] { + return ["session-id", "selected-id"]; + } + + protected get tag(): string { + return TAG; + } + + set sessionId(value: string) { + this.currentSessionId = value ?? ""; + this.rerender(); + } + get sessionId(): string { + return this.currentSessionId; + } + + set selectedId(value: string) { + this.currentSelectedId = value ?? ""; + this.rerender(); + } + get selectedId(): string { + return this.currentSelectedId; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + if (name === "session-id") this.sessionId = next ?? ""; + if (name === "selected-id") this.selectedId = next ?? ""; + } + + protected renderContent(): ReactNode { + if (!this.currentSessionId) return null; + return createElement(EmbeddedAgentList, { + sessionId: this.currentSessionId, + selectedId: this.currentSelectedId || undefined, + onOpen: (agent: EmbedAgentPayload) => + this.emit("open-agent", { ...agent }), + onRemove: (id: string) => this.emit("remove-agent", { id }), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineAgentListElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentAgentListElement); + } +} diff --git a/apps/web/src/embed/elements/artifact-viewer-element.ts b/apps/web/src/embed/elements/artifact-viewer-element.ts new file mode 100644 index 0000000..8e1bcff --- /dev/null +++ b/apps/web/src/embed/elements/artifact-viewer-element.ts @@ -0,0 +1,74 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedArtifactViewer } from "../components/EmbeddedArtifactViewer"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-artifact-viewer"; + +/** + * Mounts the embedded artifact viewer. `sessionId`, `url`, and `title` are + * properties/attributes; `send-prompt` (a review submitted to Prime) is emitted + * as a composed `CustomEvent` for the npm wrapper to surface as an `on*` prop. + */ +export class TangentArtifactViewerElement extends EmbeddedElement { + private currentSessionId = ""; + private currentUrl = ""; + private currentTitle = ""; + + static get observedAttributes(): string[] { + return ["session-id", "url", "title"]; + } + + protected get tag(): string { + return TAG; + } + + set sessionId(value: string) { + this.currentSessionId = value ?? ""; + this.rerender(); + } + get sessionId(): string { + return this.currentSessionId; + } + + set url(value: string) { + this.currentUrl = value ?? ""; + this.rerender(); + } + get url(): string { + return this.currentUrl; + } + + set title(value: string) { + this.currentTitle = value ?? ""; + this.rerender(); + } + get title(): string { + return this.currentTitle; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + const value = next ?? ""; + if (name === "session-id") this.sessionId = value; + if (name === "url") this.url = value; + if (name === "title") this.title = value; + } + + protected renderContent(): ReactNode { + if (!this.currentSessionId || !this.currentUrl) return null; + return createElement(EmbeddedArtifactViewer, { + sessionId: this.currentSessionId, + url: this.currentUrl, + title: this.currentTitle, + onSendPrompt: (content, attachments) => + this.emit("send-prompt", { content, attachments }), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineArtifactViewerElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentArtifactViewerElement); + } +} diff --git a/apps/web/src/embed/elements/asset-list-element.ts b/apps/web/src/embed/elements/asset-list-element.ts new file mode 100644 index 0000000..b5736f1 --- /dev/null +++ b/apps/web/src/embed/elements/asset-list-element.ts @@ -0,0 +1,64 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedAssetList } from "../components/EmbeddedAssetList"; +import type { EmbedAssetPayload } from "../types"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-asset-list"; + +/** + * Mounts the embedded asset list. `sessionId` and `selectedId` are + * properties/attributes; `open-asset` and `unpin-asset` are emitted as + * composed `CustomEvent`s for the npm wrapper to surface as `on*` props. + */ +export class TangentAssetListElement extends EmbeddedElement { + private currentSessionId = ""; + private currentSelectedId = ""; + + static get observedAttributes(): string[] { + return ["session-id", "selected-id"]; + } + + protected get tag(): string { + return TAG; + } + + set sessionId(value: string) { + this.currentSessionId = value ?? ""; + this.rerender(); + } + get sessionId(): string { + return this.currentSessionId; + } + + set selectedId(value: string) { + this.currentSelectedId = value ?? ""; + this.rerender(); + } + get selectedId(): string { + return this.currentSelectedId; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + if (name === "session-id") this.sessionId = next ?? ""; + if (name === "selected-id") this.selectedId = next ?? ""; + } + + protected renderContent(): ReactNode { + if (!this.currentSessionId) return null; + return createElement(EmbeddedAssetList, { + sessionId: this.currentSessionId, + selectedId: this.currentSelectedId || undefined, + onOpen: (asset: EmbedAssetPayload) => + this.emit("open-asset", { ...asset }), + onUnpin: (path: string) => this.emit("unpin-asset", { path }), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineAssetListElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentAssetListElement); + } +} diff --git a/apps/web/src/embed/elements/bundled-ui-element.ts b/apps/web/src/embed/elements/bundled-ui-element.ts new file mode 100644 index 0000000..386b47f --- /dev/null +++ b/apps/web/src/embed/elements/bundled-ui-element.ts @@ -0,0 +1,87 @@ +import { createElement, type ReactNode } from "react"; + +import type { BundleUiKind } from "@/features/bundle-ui/types"; + +import { EmbeddedBundledUi } from "../components/EmbeddedBundledUi"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-bundled-ui"; + +/** + * Mounts a sandboxed bundle-UI component. `moduleUrl`, `kind`, `props`, and + * `stateNamespace` are properties/attributes; `send-prompt` and `collapse` are + * emitted as composed `CustomEvent`s for the npm wrapper to surface as `on*` + * props. + */ +export class TangentBundledUiElement extends EmbeddedElement { + private currentModuleUrl = ""; + private currentKind: BundleUiKind = "panel"; + private currentProps: Record | undefined; + private currentStateNamespace = ""; + + static get observedAttributes(): string[] { + return ["module-url", "kind", "state-namespace"]; + } + + protected get tag(): string { + return TAG; + } + + set moduleUrl(value: string) { + this.currentModuleUrl = value ?? ""; + this.rerender(); + } + get moduleUrl(): string { + return this.currentModuleUrl; + } + + set kind(value: BundleUiKind) { + this.currentKind = value === "message" ? "message" : "panel"; + this.rerender(); + } + get kind(): BundleUiKind { + return this.currentKind; + } + + set props(value: Record | undefined) { + this.currentProps = value ?? undefined; + this.rerender(); + } + get props(): Record | undefined { + return this.currentProps; + } + + set stateNamespace(value: string) { + this.currentStateNamespace = value ?? ""; + this.rerender(); + } + get stateNamespace(): string { + return this.currentStateNamespace; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + const value = next ?? ""; + if (name === "module-url") this.moduleUrl = value; + if (name === "kind") this.kind = value as BundleUiKind; + if (name === "state-namespace") this.stateNamespace = value; + } + + protected renderContent(): ReactNode { + if (!this.currentModuleUrl) return null; + return createElement(EmbeddedBundledUi, { + moduleUrl: this.currentModuleUrl, + kind: this.currentKind, + props: this.currentProps, + stateNamespace: this.currentStateNamespace || undefined, + onSendPrompt: (text: string) => this.emit("send-prompt", { text }), + onCollapse: () => this.emit("collapse", {}), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineBundledUiElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentBundledUiElement); + } +} diff --git a/apps/web/src/embed/elements/chat-element.ts b/apps/web/src/embed/elements/chat-element.ts new file mode 100644 index 0000000..930ede0 --- /dev/null +++ b/apps/web/src/embed/elements/chat-element.ts @@ -0,0 +1,82 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedChat } from "../components/EmbeddedChat"; +import type { TangentRuntime } from "../types"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-chat"; + +/** + * Mounts the embedded chat surface. `sessionId`, `agentId`, and `initialPrompt` + * are properties; `open-artifact` and `send-prompt` are emitted as composed + * `CustomEvent`s for the npm wrapper to surface as `on*` props. + */ +export class TangentChatElement extends EmbeddedElement { + private currentSessionId = ""; + private currentAgentId = ""; + private queuedInitialPrompt: string | undefined; + + static get observedAttributes(): string[] { + return ["session-id", "agent-id"]; + } + + protected get tag(): string { + return TAG; + } + + set sessionId(value: string) { + this.currentSessionId = value ?? ""; + this.rerender(); + } + get sessionId(): string { + return this.currentSessionId; + } + + set agentId(value: string) { + this.currentAgentId = value ?? ""; + this.rerender(); + } + get agentId(): string { + return this.currentAgentId; + } + + set initialPrompt(value: string | undefined) { + this.queuedInitialPrompt = value || undefined; + this.rerender(); + } + get initialPrompt(): string | undefined { + return this.queuedInitialPrompt; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + if (name === "session-id") this.sessionId = next ?? ""; + if (name === "agent-id") this.agentId = next ?? ""; + } + + protected renderContent(runtime: TangentRuntime): ReactNode { + if (!this.currentSessionId) return null; + + if (this.queuedInitialPrompt) { + runtime.queuePrompt(this.currentSessionId, { + prompt: this.queuedInitialPrompt, + }); + this.queuedInitialPrompt = undefined; + } + + return createElement(EmbeddedChat, { + sessionId: this.currentSessionId, + agentId: this.currentAgentId || undefined, + runtime, + onOpenArtifact: (url: string, title: string) => + this.emit("open-artifact", { url, title }), + onSendPrompt: (content: string) => this.emit("send-prompt", { content }), + }); + } +} + +/** Idempotently registers `` (safe across HMR/re-import). */ +export function defineChatElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentChatElement); + } +} diff --git a/apps/web/src/embed/elements/embeddedElement.ts b/apps/web/src/embed/elements/embeddedElement.ts new file mode 100644 index 0000000..4287240 --- /dev/null +++ b/apps/web/src/embed/elements/embeddedElement.ts @@ -0,0 +1,99 @@ +import { createElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { EmbedRoot } from "../components/EmbedRoot"; +import { resolveRuntime } from "../runtime"; +import { + adoptEmbedStyles, + applyEmbedTheme, + ensureOverlayContainer, +} from "../styles"; +import type { TangentRuntime } from "../types"; + +type ProviderHost = Element & { runtime?: TangentRuntime | null }; + +/** + * Base for the embed custom elements. Owns the shadow root, adopted styles, + * runtime resolution (climbing to the nearest ``), theme + * application + subscription, and the React root lifecycle. Subclasses implement + * {@link renderContent} and call {@link rerender} when their inputs change; the + * subtree is always wrapped in {@link EmbedRoot} so it shares the query client + * and portals menus/tooltips to the overlay root. + */ +export abstract class EmbeddedElement extends HTMLElement { + private root: Root | null = null; + private mountPoint: HTMLDivElement | null = null; + private themeUnsubscribe: (() => void) | null = null; + protected runtime: TangentRuntime | null = null; + + /** Tag name, used only for missing-provider diagnostics. */ + protected abstract get tag(): string; + + /** The subtree to render inside `EmbedRoot`, or null to render nothing yet. */ + protected abstract renderContent(runtime: TangentRuntime): ReactNode; + + connectedCallback(): void { + if (!this.shadowRoot) { + const shadow = this.attachShadow({ mode: "open" }); + adoptEmbedStyles(shadow); + const mountPoint = document.createElement("div"); + mountPoint.style.height = "100%"; + mountPoint.style.minHeight = "0"; + mountPoint.style.display = "flex"; + mountPoint.style.flexDirection = "column"; + shadow.append(mountPoint); + this.mountPoint = mountPoint; + } + this.runtime = this.resolveRuntime(); + this.applyTheme(); + this.themeUnsubscribe = + this.runtime?.subscribeTheme(() => this.applyTheme()) ?? null; + this.rerender(); + } + + disconnectedCallback(): void { + this.themeUnsubscribe?.(); + this.themeUnsubscribe = null; + this.root?.unmount(); + this.root = null; + } + + private resolveRuntime(): TangentRuntime | null { + const provider = this.closest("tangent-provider") as ProviderHost | null; + return provider?.runtime ?? resolveRuntime(this.getAttribute("instance")); + } + + private applyTheme(): void { + if (!this.runtime || !this.mountPoint) return; + applyEmbedTheme(this.runtime.theme, this.mountPoint); + applyEmbedTheme(this.runtime.theme, ensureOverlayContainer()); + } + + /** (Re)renders the element's React subtree over the resolved runtime. */ + protected rerender(): void { + if (!this.mountPoint) return; + const runtime = this.runtime ?? this.resolveRuntime(); + if (!runtime) { + console.error(`[${this.tag}] no ancestor found`); + return; + } + this.runtime = runtime; + const content = this.renderContent(runtime); + if (content == null) return; + if (!this.root) this.root = createRoot(this.mountPoint); + this.root.render( + createElement( + EmbedRoot, + { portalContainer: ensureOverlayContainer() }, + content, + ), + ); + } + + /** Dispatches a composed `CustomEvent` for the npm wrapper to surface. */ + protected emit(type: string, detail: object): void { + this.dispatchEvent( + new CustomEvent(type, { detail, bubbles: true, composed: true }), + ); + } +} diff --git a/apps/web/src/embed/elements/participant-list-element.ts b/apps/web/src/embed/elements/participant-list-element.ts new file mode 100644 index 0000000..d137500 --- /dev/null +++ b/apps/web/src/embed/elements/participant-list-element.ts @@ -0,0 +1,63 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedParticipantList } from "../components/EmbeddedParticipantList"; +import type { EmbedMuteTogglePayload } from "../types"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-participant-list"; + +/** + * Mounts the embedded participant list. `sessionId` and `agentId` are + * properties/attributes; `toggle-mute` is emitted as a composed `CustomEvent` + * for the npm wrapper to surface as an `on*` prop. + */ +export class TangentParticipantListElement extends EmbeddedElement { + private currentSessionId = ""; + private currentAgentId = ""; + + static get observedAttributes(): string[] { + return ["session-id", "agent-id"]; + } + + protected get tag(): string { + return TAG; + } + + set sessionId(value: string) { + this.currentSessionId = value ?? ""; + this.rerender(); + } + get sessionId(): string { + return this.currentSessionId; + } + + set agentId(value: string) { + this.currentAgentId = value ?? ""; + this.rerender(); + } + get agentId(): string { + return this.currentAgentId; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + if (name === "session-id") this.sessionId = next ?? ""; + if (name === "agent-id") this.agentId = next ?? ""; + } + + protected renderContent(): ReactNode { + if (!this.currentSessionId) return null; + return createElement(EmbeddedParticipantList, { + sessionId: this.currentSessionId, + agentId: this.currentAgentId || undefined, + onToggleMute: (toggle: EmbedMuteTogglePayload) => + this.emit("toggle-mute", { ...toggle }), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineParticipantListElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentParticipantListElement); + } +} diff --git a/apps/web/src/embed/elements/provider-element.ts b/apps/web/src/embed/elements/provider-element.ts new file mode 100644 index 0000000..ceb5ec6 --- /dev/null +++ b/apps/web/src/embed/elements/provider-element.ts @@ -0,0 +1,60 @@ +import type { EmbedApiConfig } from "@/shared/lib/basePath"; + +import { createRuntime, registerRuntime } from "../runtime"; +import type { EmbedTheme, TangentRuntime } from "../types"; + +const TAG = "tangent-provider"; + +/** + * Owns the shared {@link TangentRuntime} for its subtree. Renders nothing of its + * own (`display: contents`) and keeps its light-DOM children — the host app, + * including any `` — in the normal flow, so children resolve the + * runtime by climbing to `closest("tangent-provider")`. + */ +export class TangentProviderElement extends HTMLElement { + runtime: TangentRuntime | null = null; + private currentConfig: EmbedApiConfig = {}; + private currentTheme: EmbedTheme = {}; + + set config(value: EmbedApiConfig) { + this.currentConfig = value ?? {}; + this.sync(); + } + get config(): EmbedApiConfig { + return this.currentConfig; + } + + set theme(value: EmbedTheme) { + this.currentTheme = value ?? {}; + this.runtime?.setTheme(this.currentTheme); + } + get theme(): EmbedTheme { + return this.currentTheme; + } + + connectedCallback(): void { + this.style.display = "contents"; + this.sync(); + } + + private sync(): void { + if (!this.isConnected) return; + if (this.runtime) { + this.runtime.setConfig(this.currentConfig); + this.runtime.setTheme(this.currentTheme); + return; + } + this.runtime = createRuntime({ + config: this.currentConfig, + theme: this.currentTheme, + }); + registerRuntime(this.getAttribute("instance"), this.runtime); + } +} + +/** Idempotently registers `` (safe across HMR/re-import). */ +export function defineProviderElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentProviderElement); + } +} diff --git a/apps/web/src/embed/elements/resource-list-element.ts b/apps/web/src/embed/elements/resource-list-element.ts new file mode 100644 index 0000000..6fb881d --- /dev/null +++ b/apps/web/src/embed/elements/resource-list-element.ts @@ -0,0 +1,63 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedResourceList } from "../components/EmbeddedResourceList"; +import type { EmbedResourcePayload } from "../types"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-resource-list"; + +/** + * Mounts the embedded resource list. `sessionId` and `agentId` are + * properties/attributes; `open-resource` is emitted as a composed `CustomEvent` + * for the npm wrapper to surface as an `on*` prop. + */ +export class TangentResourceListElement extends EmbeddedElement { + private currentSessionId = ""; + private currentAgentId = ""; + + static get observedAttributes(): string[] { + return ["session-id", "agent-id"]; + } + + protected get tag(): string { + return TAG; + } + + set sessionId(value: string) { + this.currentSessionId = value ?? ""; + this.rerender(); + } + get sessionId(): string { + return this.currentSessionId; + } + + set agentId(value: string) { + this.currentAgentId = value ?? ""; + this.rerender(); + } + get agentId(): string { + return this.currentAgentId; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + if (name === "session-id") this.sessionId = next ?? ""; + if (name === "agent-id") this.agentId = next ?? ""; + } + + protected renderContent(): ReactNode { + if (!this.currentSessionId) return null; + return createElement(EmbeddedResourceList, { + sessionId: this.currentSessionId, + agentId: this.currentAgentId || undefined, + onOpen: (resource: EmbedResourcePayload) => + this.emit("open-resource", { ...resource }), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineResourceListElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentResourceListElement); + } +} diff --git a/apps/web/src/embed/elements/session-list-element.ts b/apps/web/src/embed/elements/session-list-element.ts new file mode 100644 index 0000000..1dc9126 --- /dev/null +++ b/apps/web/src/embed/elements/session-list-element.ts @@ -0,0 +1,50 @@ +import { createElement, type ReactNode } from "react"; + +import { EmbeddedSessionList } from "../components/EmbeddedSessionList"; +import { EmbeddedElement } from "./embeddedElement"; + +const TAG = "tangent-session-list"; + +/** + * Mounts the embedded session list. `selectedId` is a property/attribute; + * `select-session` and `session-deleted` are emitted as composed `CustomEvent`s + * for the npm wrapper to surface as `on*` props. + */ +export class TangentSessionListElement extends EmbeddedElement { + private currentSelectedId = ""; + + static get observedAttributes(): string[] { + return ["selected-id"]; + } + + protected get tag(): string { + return TAG; + } + + set selectedId(value: string) { + this.currentSelectedId = value ?? ""; + this.rerender(); + } + get selectedId(): string { + return this.currentSelectedId; + } + + attributeChangedCallback(name: string, _prev: string, next: string): void { + if (name === "selected-id") this.selectedId = next ?? ""; + } + + protected renderContent(): ReactNode { + return createElement(EmbeddedSessionList, { + selectedId: this.currentSelectedId || undefined, + onSelect: (id: string) => this.emit("select-session", { id }), + onDeleted: (id: string) => this.emit("session-deleted", { id }), + }); + } +} + +/** Idempotently registers `` (safe across re-import). */ +export function defineSessionListElement(): void { + if (!customElements.get(TAG)) { + customElements.define(TAG, TangentSessionListElement); + } +} diff --git a/apps/web/src/embed/index.ts b/apps/web/src/embed/index.ts new file mode 100644 index 0000000..3860d6b --- /dev/null +++ b/apps/web/src/embed/index.ts @@ -0,0 +1,50 @@ +import { defineAgentListElement } from "./elements/agent-list-element"; +import { defineArtifactViewerElement } from "./elements/artifact-viewer-element"; +import { defineAssetListElement } from "./elements/asset-list-element"; +import { defineBundledUiElement } from "./elements/bundled-ui-element"; +import { defineChatElement } from "./elements/chat-element"; +import { defineParticipantListElement } from "./elements/participant-list-element"; +import { defineProviderElement } from "./elements/provider-element"; +import { defineResourceListElement } from "./elements/resource-list-element"; +import { defineSessionListElement } from "./elements/session-list-element"; + +/** + * Protocol version reported to the npm wrapper at registration. The wrapper + * declares the range it supports and warns on mismatch; a breaking contract + * change bumps the served channel to `/embed/v2/` rather than this number. + */ +export const EMBED_PROTOCOL_VERSION = 1; + +declare global { + interface Window { + __TANGENT_EMBED__?: { protocolVersion: number }; + } +} + +defineProviderElement(); +defineChatElement(); +defineSessionListElement(); +defineAgentListElement(); +defineAssetListElement(); +defineResourceListElement(); +defineParticipantListElement(); +defineArtifactViewerElement(); +defineBundledUiElement(); + +window.__TANGENT_EMBED__ = { protocolVersion: EMBED_PROTOCOL_VERSION }; + +export { TangentAgentListElement } from "./elements/agent-list-element"; +export { TangentArtifactViewerElement } from "./elements/artifact-viewer-element"; +export { TangentAssetListElement } from "./elements/asset-list-element"; +export { TangentBundledUiElement } from "./elements/bundled-ui-element"; +export { TangentChatElement } from "./elements/chat-element"; +export { TangentParticipantListElement } from "./elements/participant-list-element"; +export { TangentProviderElement } from "./elements/provider-element"; +export { TangentResourceListElement } from "./elements/resource-list-element"; +export { TangentSessionListElement } from "./elements/session-list-element"; +export type { + EmbedTheme, + NewSessionOptions, + NewSessionResult, + TangentRuntime, +} from "./types"; diff --git a/apps/web/src/embed/runtime.ts b/apps/web/src/embed/runtime.ts new file mode 100644 index 0000000..855e5b9 --- /dev/null +++ b/apps/web/src/embed/runtime.ts @@ -0,0 +1,109 @@ +import { + addResource, + createSession, + listResources, + removeResource, +} from "@/features/sessions/api/sessionsApi"; +import { configureEmbedApi, type EmbedApiConfig } from "@/shared/lib/basePath"; + +import type { + EmbedTheme, + NewSessionOptions, + NewSessionResult, + PendingPrompt, + TangentRuntime, +} from "./types"; + +/** + * Builds a runtime for one ``. Applies the API config + * globally (single-provider is the common case; last write wins) and owns the + * pending-prompt store plus the `newSession` composition. + */ +export function createRuntime(init: { + config: EmbedApiConfig; + theme: EmbedTheme; +}): TangentRuntime { + let config = init.config; + let theme = init.theme; + const pending = new Map(); + const themeListeners = new Set<(theme: EmbedTheme) => void>(); + + configureEmbedApi(config); + + return { + get config() { + return config; + }, + get theme() { + return theme; + }, + setConfig(next) { + config = next; + configureEmbedApi(next); + }, + setTheme(next) { + theme = next; + for (const listener of themeListeners) listener(next); + }, + subscribeTheme(listener) { + themeListeners.add(listener); + return () => themeListeners.delete(listener); + }, + queuePrompt(sessionId, prompt) { + pending.set(sessionId, prompt); + }, + takePendingPrompt(sessionId) { + const value = pending.get(sessionId); + pending.delete(sessionId); + return value; + }, + async newSession( + prompt: string, + bundleId: string, + options?: NewSessionOptions, + ): Promise { + const session = await createSession({ + bundleId, + name: options?.name, + resources: options?.resources, + }); + pending.set(session.id, { + prompt, + delivery: options?.delivery, + attachments: options?.attachments, + model: options?.model, + thinkingDepth: options?.thinkingDepth, + }); + return { sessionId: session.id }; + }, + listResources(sessionId) { + return listResources(sessionId); + }, + addResource(sessionId, input) { + return addResource(sessionId, input); + }, + removeResource(sessionId, uri) { + return removeResource(sessionId, uri); + }, + }; +} + +const registry = new Map(); +let defaultRuntime: TangentRuntime | null = null; + +/** Registers a runtime under an optional `instance` id and as the default. */ +export function registerRuntime( + instance: string | null, + runtime: TangentRuntime, +): void { + if (instance) registry.set(instance, runtime); + defaultRuntime = runtime; +} + +/** Resolves a runtime by `instance` id, falling back to the default. */ +export function resolveRuntime( + instance?: string | null, +): TangentRuntime | null { + if (instance) return registry.get(instance) ?? null; + return defaultRuntime; +} diff --git a/apps/web/src/embed/styles.ts b/apps/web/src/embed/styles.ts new file mode 100644 index 0000000..4f51a39 --- /dev/null +++ b/apps/web/src/embed/styles.ts @@ -0,0 +1,114 @@ +import memoryMessageCss from "@/features/chat/components/message/MemoryMessage.css?inline"; +import embedCss from "@/index.css?inline"; +import { applyTheme, type Theme } from "@/shared/theme/theme"; + +import type { EmbedTheme } from "./types"; + +/** + * `:host` supplies what the standalone app's `html`/`body` normally would: the + * base layer targets `body` (background/foreground) and preflight targets + * `html` (font stack), and neither selector matches inside a shadow root. + */ +const HOST_BASE = ` +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background-color: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +`; + +let sharedSheet: CSSStyleSheet | null = null; +let propertiesHoisted = false; + +/** Token blocks use `:root {}`; map them onto the shadow host. */ +function rewriteRootToHost(css: string): string { + return css.replace(/:root\b/g, ":host"); +} + +/** + * Tailwind v4 declares its `--tw-*` defaults with `@property`, which is + * document-scoped and silently ignored inside a shadow root + * (tailwindcss#15005). Hoist those rules into the document so transforms, + * gradients, and shadows work. They are all `--tw-*` prefixed, so they cannot + * collide with host tokens. + */ +function hoistPropertyRules(sheet: CSSStyleSheet): void { + if (propertiesHoisted) return; + propertiesHoisted = true; + const chunks: string[] = []; + for (const rule of Array.from(sheet.cssRules)) { + if (rule.constructor.name === "CSSPropertyRule") chunks.push(rule.cssText); + } + if (chunks.length === 0) return; + const docSheet = new CSSStyleSheet(); + docSheet.replaceSync(chunks.join("\n")); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, docSheet]; +} + +/** Builds (once) and returns the shared adopted stylesheet for embed shadows. */ +export function getEmbedStyleSheet(): CSSStyleSheet { + if (sharedSheet) return sharedSheet; + const sheet = new CSSStyleSheet(); + sheet.replaceSync(rewriteRootToHost(embedCss) + memoryMessageCss + HOST_BASE); + hoistPropertyRules(sheet); + sharedSheet = sheet; + return sheet; +} + +/** Adopts the shared embed stylesheet into a shadow root (idempotent). */ +export function adoptEmbedStyles(root: ShadowRoot): void { + const sheet = getEmbedStyleSheet(); + if (!root.adoptedStyleSheets.includes(sheet)) { + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + } +} + +function resolveColorScheme(scheme: EmbedTheme["colorScheme"]): Theme { + if (scheme === "dark") return "dark"; + if (scheme === "system") { + return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return "light"; +} + +/** Applies the resolved color scheme and any token overrides to `target`. */ +export function applyEmbedTheme(theme: EmbedTheme, target: HTMLElement): void { + applyTheme(resolveColorScheme(theme.colorScheme), target); + for (const [name, value] of Object.entries(theme.tokens ?? {})) { + target.style.setProperty(name.startsWith("--") ? name : `--${name}`, value); + } +} + +let overlayWrapper: HTMLDivElement | null = null; + +/** + * A shared, document-level overlay layer with its own shadow root and the same + * adopted styles, so portalled menus/tooltips paint over host chrome while + * staying styled and isolated. Radix positions content with `position: fixed`, + * so the zero-size wrapper does not need to fill the viewport. + */ +export function ensureOverlayContainer(): HTMLElement { + if (overlayWrapper?.isConnected) return overlayWrapper; + const host = document.createElement("tangent-overlay-root"); + host.setAttribute( + "style", + "position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;", + ); + const shadow = host.attachShadow({ mode: "open" }); + adoptEmbedStyles(shadow); + const wrapper = document.createElement("div"); + shadow.append(wrapper); + document.body.append(host); + overlayWrapper = wrapper; + return wrapper; +} diff --git a/apps/web/src/embed/types.ts b/apps/web/src/embed/types.ts new file mode 100644 index 0000000..b42e9f6 --- /dev/null +++ b/apps/web/src/embed/types.ts @@ -0,0 +1,117 @@ +import type { + Attachment, + HostResourceInput, + MessageDelivery, + Resource, + ResourceKind, + ThinkingLevel, +} from "@tangent/shared/contracts"; + +import type { EmbedApiConfig } from "@/shared/lib/basePath"; + +/** Host-driven theme inputs. Kept deliberately small (see tech design 6.4). */ +export interface EmbedTheme { + /** `system` follows the host's `prefers-color-scheme`. Defaults to `light`. */ + colorScheme?: "light" | "dark" | "system"; + /** + * Escape hatch for one-off token overrides, keyed by Tangent's internal + * custom-property names. Unstable — pins the host to our token names. + */ + tokens?: Record; +} + +/** Options for {@link TangentRuntime.newSession}. */ +export interface NewSessionOptions { + /** Session display name; the server defaults to `Session N` when omitted. */ + name?: string; + /** Initial model for Prime, applied via `agent:set-model` after join. */ + model?: string; + /** Initial thinking depth for Prime. */ + thinkingDepth?: ThinkingLevel; + /** Delivery routing for the opening prompt. */ + delivery?: MessageDelivery; + /** Attachments to send with the opening prompt. */ + attachments?: Attachment[]; + /** Resources to seed the session with, applied before the agent spawns. */ + resources?: HostResourceInput[]; +} + +export interface NewSessionResult { + sessionId: string; +} + +/** Serializable agent row handed to the host via `open-agent`. */ +export interface EmbedAgentPayload { + id: string; + name: string; + kind: "prime" | "subagent"; + status: string; + conversationId: string; +} + +/** Serializable asset row handed to the host via `open-asset`. */ +export type EmbedAssetPayload = + | { + kind: "page" | "file"; + id: string; + title: string; + url: string; + path: string; + } + | { + kind: "trigger"; + id: string; + title: string; + triggerKind: string; + enabled: boolean; + }; + +/** Serializable resource row handed to the host via `open-resource`. */ +export interface EmbedResourcePayload { + id: string; + kind: ResourceKind; + name: string; + uri: string; + /** The viewable file API url, resolved from `uri` for a `file`/`artifact`. */ + url: string; + authorParticipantId?: string; +} + +/** Serializable mute toggle handed to the host via `toggle-mute`. */ +export interface EmbedMuteTogglePayload { + participantId: string; + conversationId: string; + muted: boolean; +} + +/** A prompt queued for a session, drained by `` once joined. */ +export interface PendingPrompt { + prompt?: string; + delivery?: MessageDelivery; + attachments?: Attachment[]; + model?: string; + thinkingDepth?: ThinkingLevel; +} + +/** + * The shared, module-side object a `` owns and its child + * `` resolves. Holds API config, theme inputs, the pending-prompt + * store, and the `newSession` composition. + */ +export interface TangentRuntime { + readonly config: EmbedApiConfig; + readonly theme: EmbedTheme; + setConfig(config: EmbedApiConfig): void; + setTheme(theme: EmbedTheme): void; + subscribeTheme(listener: (theme: EmbedTheme) => void): () => void; + queuePrompt(sessionId: string, pending: PendingPrompt): void; + takePendingPrompt(sessionId: string): PendingPrompt | undefined; + newSession( + prompt: string, + bundleId: string, + options?: NewSessionOptions, + ): Promise; + listResources(sessionId: string): Promise; + addResource(sessionId: string, input: HostResourceInput): Promise; + removeResource(sessionId: string, uri: string): Promise; +} diff --git a/apps/web/src/features/agent-bundles/api/agentBundlesApi.ts b/apps/web/src/features/agent-bundles/api/agentBundlesApi.ts index 385ed11..5d2cfe7 100644 --- a/apps/web/src/features/agent-bundles/api/agentBundlesApi.ts +++ b/apps/web/src/features/agent-bundles/api/agentBundlesApi.ts @@ -3,6 +3,7 @@ import type { ListAgentBundlesResponse, } from "@tangent/shared/contracts"; +import { apiFetch } from "@/shared/lib/apiFetch"; import { apiUrl } from "@/shared/lib/basePath"; async function parseJson(res: Response): Promise { @@ -15,7 +16,7 @@ async function parseJson(res: Response): Promise { export async function listAgentBundles(): Promise { const data = await parseJson( - await fetch(apiUrl("/api/agent-bundles")), + await apiFetch("/api/agent-bundles"), ); return data.bundles; } @@ -23,7 +24,7 @@ export async function listAgentBundles(): Promise { /** Fetches a single bundle's metadata, including its UI `components`. */ export async function getAgentBundle(id: string): Promise { const data = await parseJson<{ bundle: AgentBundleMeta }>( - await fetch(apiUrl(`/api/agent-bundles/${id}`)), + await apiFetch(`/api/agent-bundles/${id}`), ); return data.bundle; } @@ -34,13 +35,13 @@ export async function uploadAgentBundle(file: File): Promise { form.append("bundle", file); const data = await parseJson<{ bundle: AgentBundleMeta }>( - await fetch(apiUrl("/api/agent-bundles"), { method: "POST", body: form }), + await apiFetch("/api/agent-bundles", { method: "POST", body: form }), ); return data.bundle; } export async function deleteAgentBundle(id: string): Promise { - const res = await fetch(apiUrl(`/api/agent-bundles/${id}`), { + const res = await apiFetch(`/api/agent-bundles/${id}`, { method: "DELETE", }); if (!res.ok) { diff --git a/apps/web/src/features/bundle-ui/BundleUiHost.tsx b/apps/web/src/features/bundle-ui/BundleUiHost.tsx index 605129a..a534d80 100644 --- a/apps/web/src/features/bundle-ui/BundleUiHost.tsx +++ b/apps/web/src/features/bundle-ui/BundleUiHost.tsx @@ -26,6 +26,7 @@ import { BUNDLE_UI_ELEMENT_NAMES, hostAdapters, } from "./components/host-registry"; +import { createBundleUiWorker } from "./createBundleUiWorker"; import { createHostBridge, TARGET_URL_ENDPOINT } from "./hostBridge"; import type { BundleUiKind, HostBridge, UICommand, WorkerApi } from "./types"; @@ -137,10 +138,7 @@ export function BundleUiHost({ useEffect(() => { let cancelled = false; - const worker = new Worker( - new URL("./bundle-ui.worker.ts", import.meta.url), - { type: "module" }, - ); + const worker = createBundleUiWorker(); worker.addEventListener("error", (event) => { if (!cancelled) { diff --git a/apps/web/src/features/bundle-ui/createBundleUiWorker.dev.ts b/apps/web/src/features/bundle-ui/createBundleUiWorker.dev.ts new file mode 100644 index 0000000..a2c91b0 --- /dev/null +++ b/apps/web/src/features/bundle-ui/createBundleUiWorker.dev.ts @@ -0,0 +1,20 @@ +// Dev-only variant (aliased in `apps/web/vite.config.ts` for `serve`). Vite does +// not inline workers in dev, so `?worker&inline` would emit a bare root-relative +// URL that a cross-origin embed host resolves against its own origin. Instead, +// boot from a same-origin blob whose first import is the worker module's +// absolute Shell URL: worker scripts must be same-origin with the page, while +// the blob's absolute import lets every transitive worker import resolve against +// the Shell via CORS (the same mechanism the rest of the embed runtime uses). +import workerUrl from "./bundle-ui.worker.ts?worker&url"; + +/** Boots the bundle-UI sandbox worker from a same-origin blob bootstrap. */ +export function createBundleUiWorker(): Worker { + const absolute = new URL(workerUrl, import.meta.url).href; + const blob = new Blob([`import ${JSON.stringify(absolute)};`], { + type: "text/javascript", + }); + const objectUrl = URL.createObjectURL(blob); + const worker = new Worker(objectUrl, { type: "module" }); + worker.addEventListener("error", () => URL.revokeObjectURL(objectUrl)); + return worker; +} diff --git a/apps/web/src/features/bundle-ui/createBundleUiWorker.embed.ts b/apps/web/src/features/bundle-ui/createBundleUiWorker.embed.ts new file mode 100644 index 0000000..42d66fb --- /dev/null +++ b/apps/web/src/features/bundle-ui/createBundleUiWorker.embed.ts @@ -0,0 +1,9 @@ +// Embed-only variant (aliased in `apps/web/vite.embed.config.ts`). The +// `?worker&inline` suffix bundles the worker as a base64 blob so the embed +// runtime stays a single self-contained file. +import BundleUiWorker from "./bundle-ui.worker.ts?worker&inline"; + +/** Boots the bundle-UI sandbox worker from an inlined blob. */ +export function createBundleUiWorker(): Worker { + return new BundleUiWorker(); +} diff --git a/apps/web/src/features/bundle-ui/createBundleUiWorker.ts b/apps/web/src/features/bundle-ui/createBundleUiWorker.ts new file mode 100644 index 0000000..07203ba --- /dev/null +++ b/apps/web/src/features/bundle-ui/createBundleUiWorker.ts @@ -0,0 +1,12 @@ +/** + * Boots the bundle-UI sandbox worker from its module URL — the app default. + * + * The embed build aliases this module to `createBundleUiWorker.embed.ts`, which + * inlines the worker so the runtime stays a single self-contained file (the lib + * build cannot emit a separate worker chunk). See `apps/web/vite.embed.config.ts`. + */ +export function createBundleUiWorker(): Worker { + return new Worker(new URL("./bundle-ui.worker.ts", import.meta.url), { + type: "module", + }); +} diff --git a/apps/web/src/features/chat/components/sidebar/assets/AssetList.tsx b/apps/web/src/features/chat/components/sidebar/assets/AssetList.tsx index 5cf7d6d..e850490 100644 --- a/apps/web/src/features/chat/components/sidebar/assets/AssetList.tsx +++ b/apps/web/src/features/chat/components/sidebar/assets/AssetList.tsx @@ -42,12 +42,10 @@ export function AssetList({ const copyCallback = (trigger: Trigger): void => { if (!trigger.callbackPath) return; - // In dev the API lives behind the Vite proxy at API_TARGET, so the copied - // callback URL must use that origin (not the dev server) to be reachable by - // external callers. In production __API_ORIGIN__ is empty and the app is - // served from the backend, so the current origin is correct. - const base = __API_ORIGIN__ || window.location.origin; - const url = `${base}${apiUrl(trigger.callbackPath)}`; + const path = apiUrl(trigger.callbackPath); + const url = /^https?:\/\//.test(path) + ? path + : `${__API_ORIGIN__ || window.location.origin}${path}`; void navigator.clipboard?.writeText(url); }; diff --git a/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx b/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx index 4196cb7..9ca49b1 100644 --- a/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx +++ b/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx @@ -57,7 +57,7 @@ interface SubagentTabViewProps { /** Changes this sub-agent's model and/or thinking depth. */ onSetModel: (selection: AgentModelPickerValue) => void; /** Opens a browser-viewable artifact referenced in a message. */ - onOpenArtifact: (url: string, title: string) => void; + onOpenArtifact?: (url: string, title: string) => void; pinnedPaths: Set; onTogglePinArtifact: (path: string, title: string) => void; /** People/agents the `@mention` picker can address in this sub-agent's composer. */ diff --git a/apps/web/src/features/chat/hooks/sessionChatMessages.ts b/apps/web/src/features/chat/hooks/sessionChatMessages.ts new file mode 100644 index 0000000..d90b65e --- /dev/null +++ b/apps/web/src/features/chat/hooks/sessionChatMessages.ts @@ -0,0 +1,84 @@ +import type { ChatMessage } from "@tangent/shared/contracts"; + +/** Messages bucketed by the `conversationId` they belong to. */ +export type MessageMap = Map; + +/** Stable empty result so an unknown Conversation doesn't churn renders. */ +export const NO_MESSAGES: ChatMessage[] = []; + +/** Within one Conversation, order by `seq`; `id` breaks a tie deterministically. */ +function bySeq(a: ChatMessage, b: ChatMessage): number { + return a.seq - b.seq || a.id.localeCompare(b.id); +} + +/** Groups a flat history into per-Conversation buckets, each sorted by `seq`. */ +export function groupByConversation(history: ChatMessage[]): MessageMap { + const map: MessageMap = new Map(); + for (const message of history) { + const bucket = map.get(message.conversationId); + if (bucket) bucket.push(message); + else map.set(message.conversationId, [message]); + } + for (const bucket of map.values()) bucket.sort(bySeq); + return map; +} + +/** Merges one Conversation's history in, deduped by id (incoming wins). */ +export function mergeConversation( + prev: MessageMap, + conversationId: string, + incoming: ChatMessage[], +): MessageMap { + const byId = new Map(); + for (const message of prev.get(conversationId) ?? []) + byId.set(message.id, message); + for (const message of incoming) byId.set(message.id, message); + const next = new Map(prev); + next.set(conversationId, [...byId.values()].sort(bySeq)); + return next; +} + +/** + * Inserts a message into an already-`seq`-sorted bucket at its ordered slot. + * A live append is usually the newest, so scan from the end — but two humans + * typing while an agent streams can interleave, and the tiebreak keeps every + * client rendering the same order regardless of arrival order. + */ +function insertBySeq( + bucket: ChatMessage[], + message: ChatMessage, +): ChatMessage[] { + let i = bucket.length; + while (i > 0 && bySeq(bucket[i - 1], message) > 0) i--; + const next = bucket.slice(); + next.splice(i, 0, message); + return next; +} + +/** Appends a message to its Conversation bucket in `seq` order. */ +export function appendToConversation( + prev: MessageMap, + message: ChatMessage, +): MessageMap { + const next = new Map(prev); + const bucket = next.get(message.conversationId) ?? NO_MESSAGES; + next.set(message.conversationId, insertBySeq(bucket, message)); + return next; +} + +/** Replaces one message by id in a Conversation bucket via an updater. */ +export function editInConversation( + prev: MessageMap, + conversationId: string, + messageId: string, + update: (message: ChatMessage) => ChatMessage, +): MessageMap { + const bucket = prev.get(conversationId); + if (!bucket) return prev; + const next = new Map(prev); + next.set( + conversationId, + bucket.map((m) => (m.id === messageId ? update(m) : m)), + ); + return next; +} diff --git a/apps/web/src/features/chat/hooks/sessionChatRoom.ts b/apps/web/src/features/chat/hooks/sessionChatRoom.ts new file mode 100644 index 0000000..81867f8 --- /dev/null +++ b/apps/web/src/features/chat/hooks/sessionChatRoom.ts @@ -0,0 +1,732 @@ +import { + type AgentAbortPayload, + type AgentActivity, + type AgentActivityPayload, + type AgentDeltaPayload, + type AgentEndPayload, + type AgentErrorPayload, + type AgentModelPayload, + type AgentSetModelPayload, + type AgentStartPayload, + type AgentThinkingPayload, + type ArtifactPinPayload, + type ArtifactUnpinPayload, + type Attachment, + type ChatMessage, + type ChatMessagePayload, + type ConversationHistoryPayload, + type ConversationSubscribePayload, + type MemoryConfirmPayload, + type MemoryDismissPayload, + type MemorySuggestionPayload, + type MessageDelivery, + PI_AGENT, + type PinnedArtifact, + type RunId, + type Session, + SocketEvents, + type SubagentInfo, + type SubagentRosterPayload, + type SubagentStatus, + type SubagentUpdatePayload, + type ThinkingLevel, + type Trigger, + type TriggerRosterPayload, + type TriggerUpdatePayload, + type UiCommand, + type UiCommandPayload, +} from "@tangent/shared/contracts"; +import { type Socket } from "socket.io-client"; + +import { + type AgentLiveStatus, + AgentStatusQueryKeys, +} from "@/features/chat/model/agentStatusQueryKeys"; +import { SessionQueryKeys } from "@/features/sessions/model/sessionQueryKeys"; +import { queryClient } from "@/shared/api/queryClient"; +import { createSocket } from "@/shared/lib/socket"; + +import { + appendToConversation, + editInConversation, + groupByConversation, + mergeConversation, + type MessageMap, + NO_MESSAGES, +} from "./sessionChatMessages"; + +/** An agent's current model/thinking selection (absent fields = server default). */ +export interface AgentModelSelection { + model?: string; + thinkingDepth?: ThinkingLevel; +} + +export interface SessionChatSnapshot { + messagesByConversation: MessageMap; + subagents: SubagentInfo[]; + primaryConversationId: string; + conversationByAgent: Map; + modelByAgent: Map; + triggers: Trigger[]; + artifacts: PinnedArtifact[]; + connected: boolean; + historyLoaded: boolean; + rosterReady: boolean; + memorySuggestions: MemorySuggestionPayload[]; + streamingConversations: Set; + streamingMessageIds: Set; + activityByConversation: Map; +} + +function emptySessionChat(): SessionChatSnapshot { + return { + messagesByConversation: new Map(), + subagents: [], + primaryConversationId: PI_AGENT.id, + conversationByAgent: new Map(), + modelByAgent: new Map(), + triggers: [], + artifacts: [], + connected: false, + historyLoaded: false, + rosterReady: false, + memorySuggestions: [], + streamingConversations: new Set(), + streamingMessageIds: new Set(), + activityByConversation: new Map(), + }; +} + +export const EMPTY_SESSION_CHAT: SessionChatSnapshot = emptySessionChat(); + +function dispatchUiCommand(command: UiCommand): void { + if (command.kind === "session.update") applySessionUpdate(command.session); +} + +function applySessionUpdate(session: Session): void { + queryClient.setQueryData(SessionQueryKeys.Id(session.id), session); + queryClient.setQueryData(SessionQueryKeys.All(), (prev) => + prev?.map((s) => (s.id === session.id ? session : s)), + ); +} + +function addToSet(prev: Set, value: T): Set { + const next = new Set(prev); + next.add(value); + return next; +} + +function removeFromSet(prev: Set, value: T): Set { + if (!prev.has(value)) return prev; + const next = new Set(prev); + next.delete(value); + return next; +} + +/** + * One Socket.IO connection for a session's chat room, shared across React + * trees (SPA chat, embed chat, agent/asset lists) via refcounted acquire/release. + */ +class SessionChatRoom { + snapshot: SessionChatSnapshot; + private readonly listeners = new Set<() => void>(); + private socket: Socket | null = null; + refCount = 0; + + private subscribedConversations = new Set(); + private agentByConversation = new Map(); + private conversationByMessageId = new Map(); + private runIdByConversation = new Map(); + private streamingRuns = new Set(); + private streaming = new Set(); + private activities = new Map(); + private statuses = new Map(); + + readonly sessionId: string; + + constructor(sessionId: string) { + this.sessionId = sessionId; + this.snapshot = emptySessionChat(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + connect(): void { + if (this.socket) return; + const socket = createSocket(); + this.socket = socket; + this.wire(socket); + } + + disconnect(): void { + this.socket?.removeAllListeners(); + this.socket?.disconnect(); + this.socket = null; + queryClient.removeQueries({ + queryKey: AgentStatusQueryKeys.Session(this.sessionId), + }); + } + + send( + content: string, + options?: { + conversationId?: string; + delivery?: MessageDelivery; + attachments?: Attachment[]; + }, + ): void { + const trimmed = content.trim(); + const socket = this.socket; + const attachments = options?.attachments; + const hasAttachments = Boolean(attachments && attachments.length); + if ((!trimmed && !hasAttachments) || !socket) return; + + const payload: ChatMessagePayload = { + sessionId: this.sessionId, + content: trimmed, + conversationId: + options?.conversationId ?? this.snapshot.primaryConversationId, + delivery: options?.delivery ?? "auto", + ...(hasAttachments ? { attachments } : {}), + }; + socket.emit(SocketEvents.ChatMessage, payload); + } + + abort(conversationId: string): void { + const socket = this.socket; + if (!socket) return; + const payload: AgentAbortPayload = { + sessionId: this.sessionId, + conversationId, + runId: this.runIdByConversation.get(conversationId), + }; + socket.emit(SocketEvents.AgentAbort, payload); + } + + setAgentModel(agentId: string, selection: AgentModelSelection): void { + const socket = this.socket; + if (!socket) return; + const payload: AgentSetModelPayload = { + sessionId: this.sessionId, + agentId, + model: selection.model, + thinkingDepth: selection.thinkingDepth, + }; + socket.emit(SocketEvents.AgentSetModel, payload); + } + + confirmMemory(suggestionId: string): void { + this.resolveSuggestion(suggestionId, true); + } + + dismissMemory(suggestionId: string): void { + this.resolveSuggestion(suggestionId, false); + } + + pinArtifact(path: string, title: string): void { + const socket = this.socket; + if (!socket) return; + const payload: ArtifactPinPayload = { + sessionId: this.sessionId, + path, + title, + }; + socket.emit(SocketEvents.ArtifactPin, payload); + } + + unpinArtifact(path: string): void { + const socket = this.socket; + if (!socket) return; + const payload: ArtifactUnpinPayload = { sessionId: this.sessionId, path }; + socket.emit(SocketEvents.ArtifactUnpin, payload); + } + + dismissSubagent(id: string): void { + this.patch((prev) => ({ + ...prev, + subagents: prev.subagents.filter((s) => s.id !== id), + })); + } + + private resolveSuggestion(suggestionId: string, accept: boolean): void { + const socket = this.socket; + if (!socket) return; + const event = accept + ? SocketEvents.MemoryConfirm + : SocketEvents.MemoryDismiss; + const payload: MemoryConfirmPayload | MemoryDismissPayload = { + sessionId: this.sessionId, + suggestionId, + }; + socket.emit(event, payload); + this.patch((prev) => ({ + ...prev, + memorySuggestions: prev.memorySuggestions.filter( + (s) => s.suggestionId !== suggestionId, + ), + })); + } + + private patch( + update: + | Partial + | ((prev: SessionChatSnapshot) => SessionChatSnapshot), + ): void { + this.snapshot = + typeof update === "function" + ? update(this.snapshot) + : { ...this.snapshot, ...update }; + for (const listener of this.listeners) listener(); + } + + private publish(agentId: string): void { + const status: AgentLiveStatus = { + status: + agentId === PI_AGENT.id + ? "active" + : (this.statuses.get(agentId) ?? "active"), + busy: this.streaming.has(agentId) || this.activities.has(agentId), + activity: this.activities.get(agentId) ?? null, + }; + queryClient.setQueryData( + AgentStatusQueryKeys.Detail(this.sessionId, agentId), + status, + ); + } + + private publishAll(): void { + this.publish(PI_AGENT.id); + for (const id of this.statuses.keys()) this.publish(id); + } + + private agentIdOf(conversationId: string): string { + return this.agentByConversation.get(conversationId) ?? conversationId; + } + + private trackRunActivity( + conversationId: string, + activity: AgentActivity | null, + runId: RunId | undefined, + ): void { + if (activity) { + if (runId) this.runIdByConversation.set(conversationId, runId); + return; + } + const current = runId ?? this.runIdByConversation.get(conversationId); + if (!current || this.streamingRuns.has(current)) return; + this.runIdByConversation.delete(conversationId); + } + + private resetLiveMaps(): void { + this.conversationByMessageId.clear(); + this.runIdByConversation.clear(); + this.streamingRuns.clear(); + this.streaming.clear(); + this.activities.clear(); + } + + private wire(socket: Socket): void { + const { sessionId } = this; + + socket.on("connect", () => { + this.agentByConversation.clear(); + this.subscribedConversations = new Set([PI_AGENT.id]); + this.resetLiveMaps(); + this.statuses.clear(); + this.publish(PI_AGENT.id); + this.patch({ + ...emptySessionChat(), + connected: true, + }); + socket.emit(SocketEvents.ChatJoin, { sessionId }); + }); + + socket.on("disconnect", () => { + this.resetLiveMaps(); + this.subscribedConversations.clear(); + this.publishAll(); + this.patch({ + connected: false, + artifacts: [], + streamingConversations: new Set(), + streamingMessageIds: new Set(), + activityByConversation: new Map(), + memorySuggestions: [], + }); + }); + + socket.on(SocketEvents.ChatHistory, (history: ChatMessage[]) => { + this.patch({ + messagesByConversation: groupByConversation(history), + historyLoaded: true, + }); + }); + + socket.on( + SocketEvents.ConversationHistory, + ({ conversationId, messages }: ConversationHistoryPayload) => { + this.patch((prev) => ({ + ...prev, + messagesByConversation: mergeConversation( + prev.messagesByConversation, + conversationId, + messages, + ), + })); + }, + ); + + socket.on(SocketEvents.ChatMessage, (message: ChatMessage) => { + this.patch((prev) => ({ + ...prev, + messagesByConversation: appendToConversation( + prev.messagesByConversation, + message, + ), + })); + if (message.attachments?.length || message.memory) { + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Resources(sessionId), + }); + } + }); + + socket.on( + SocketEvents.AgentStart, + ({ message, runId }: AgentStartPayload) => { + this.conversationByMessageId.set(message.id, message.conversationId); + if (runId) { + this.runIdByConversation.set(message.conversationId, runId); + this.streamingRuns.add(runId); + } + this.patch((prev) => ({ + ...prev, + streamingConversations: addToSet( + prev.streamingConversations, + message.conversationId, + ), + streamingMessageIds: addToSet(prev.streamingMessageIds, message.id), + messagesByConversation: appendToConversation( + prev.messagesByConversation, + message, + ), + })); + const agentId = this.agentIdOf(message.conversationId); + this.streaming.add(agentId); + this.publish(agentId); + }, + ); + + socket.on( + SocketEvents.AgentDelta, + ({ messageId, delta }: AgentDeltaPayload) => { + const conversationId = this.conversationByMessageId.get(messageId); + if (!conversationId) return; + this.patch((prev) => ({ + ...prev, + messagesByConversation: editInConversation( + prev.messagesByConversation, + conversationId, + messageId, + (m) => ({ ...m, content: m.content + delta }), + ), + })); + }, + ); + + socket.on( + SocketEvents.AgentThinking, + ({ messageId, delta }: AgentThinkingPayload) => { + const conversationId = this.conversationByMessageId.get(messageId); + if (!conversationId) return; + this.patch((prev) => ({ + ...prev, + messagesByConversation: editInConversation( + prev.messagesByConversation, + conversationId, + messageId, + (m) => ({ ...m, thinking: (m.thinking ?? "") + delta }), + ), + })); + }, + ); + + socket.on(SocketEvents.AgentEnd, ({ message, runId }: AgentEndPayload) => { + this.conversationByMessageId.delete(message.id); + if (runId) this.streamingRuns.delete(runId); + this.patch((prev) => ({ + ...prev, + streamingConversations: removeFromSet( + prev.streamingConversations, + message.conversationId, + ), + streamingMessageIds: removeFromSet( + prev.streamingMessageIds, + message.id, + ), + messagesByConversation: editInConversation( + prev.messagesByConversation, + message.conversationId, + message.id, + () => message, + ), + })); + const agentId = this.agentIdOf(message.conversationId); + this.streaming.delete(agentId); + this.publish(agentId); + }); + + socket.on( + SocketEvents.AgentActivity, + ({ conversationId, activity, runId }: AgentActivityPayload) => { + this.trackRunActivity(conversationId, activity, runId); + this.patch((prev) => { + const next = new Map(prev.activityByConversation); + if (activity) next.set(conversationId, activity); + else next.delete(conversationId); + return { ...prev, activityByConversation: next }; + }); + const agentId = this.agentIdOf(conversationId); + if (activity) this.activities.set(agentId, activity); + else this.activities.delete(agentId); + this.publish(agentId); + }, + ); + + socket.on( + SocketEvents.AgentError, + ({ messageId, message, runId }: AgentErrorPayload) => { + const conversationId = messageId + ? this.conversationByMessageId.get(messageId) + : undefined; + if (messageId) this.conversationByMessageId.delete(messageId); + if (runId) this.streamingRuns.delete(runId); + if (conversationId) this.runIdByConversation.delete(conversationId); + this.patch((prev) => ({ + ...prev, + streamingMessageIds: messageId + ? removeFromSet(prev.streamingMessageIds, messageId) + : prev.streamingMessageIds, + streamingConversations: conversationId + ? removeFromSet(prev.streamingConversations, conversationId) + : prev.streamingConversations, + })); + if (conversationId) { + const agentId = this.agentIdOf(conversationId); + this.streaming.delete(agentId); + this.activities.delete(agentId); + this.publish(agentId); + } + console.error("[chat] agent error:", message); + }, + ); + + socket.on( + SocketEvents.SubagentRoster, + ({ + subagents: roster, + primaryConversationId: primary, + }: SubagentRosterPayload) => { + this.agentByConversation = new Map([[primary, PI_AGENT.id]]); + for (const s of roster) + this.agentByConversation.set(s.conversationId, s.id); + this.subscribedConversations.add(primary); + for (const s of roster) { + this.statuses.set(s.id, s.status); + this.subscribedConversations.add(s.conversationId); + this.publish(s.id); + } + this.patch((prev) => { + const conversationByAgent = new Map([ + [PI_AGENT.id, primary], + ]); + const modelByAgent = new Map(prev.modelByAgent); + for (const s of roster) { + conversationByAgent.set(s.id, s.conversationId); + modelByAgent.set(s.id, { + model: s.model, + thinkingDepth: s.thinkingDepth, + }); + } + return { + ...prev, + subagents: roster, + primaryConversationId: primary, + rosterReady: true, + conversationByAgent, + modelByAgent, + }; + }); + }, + ); + + socket.on( + SocketEvents.SubagentUpdate, + ({ subagent }: SubagentUpdatePayload) => { + if (!this.subscribedConversations.has(subagent.conversationId)) { + this.subscribedConversations.add(subagent.conversationId); + const payload: ConversationSubscribePayload = { + sessionId, + conversationId: subagent.conversationId, + }; + socket.emit(SocketEvents.ConversationSubscribe, payload); + } + this.agentByConversation.set(subagent.conversationId, subagent.id); + this.statuses.set(subagent.id, subagent.status); + this.publish(subagent.id); + this.patch((prev) => { + const conversationByAgent = new Map(prev.conversationByAgent).set( + subagent.id, + subagent.conversationId, + ); + const modelByAgent = new Map(prev.modelByAgent).set(subagent.id, { + model: subagent.model, + thinkingDepth: subagent.thinkingDepth, + }); + const next = prev.subagents.filter((s) => s.id !== subagent.id); + next.push(subagent); + return { + ...prev, + conversationByAgent, + modelByAgent, + subagents: next, + }; + }); + }, + ); + + socket.on( + SocketEvents.AgentModel, + ({ agentId, model, thinkingDepth }: AgentModelPayload) => { + this.patch((prev) => ({ + ...prev, + modelByAgent: new Map(prev.modelByAgent).set(agentId, { + model, + thinkingDepth, + }), + })); + }, + ); + + socket.on(SocketEvents.ParticipantPresence, () => { + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Participants(sessionId), + }); + }); + + socket.on(SocketEvents.ResourcesUpdated, () => { + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Resources(sessionId), + }); + }); + + socket.on( + SocketEvents.MemorySuggestion, + (suggestion: MemorySuggestionPayload) => { + this.patch((prev) => ({ + ...prev, + memorySuggestions: [...prev.memorySuggestions, suggestion], + })); + }, + ); + + socket.on( + SocketEvents.TriggerRoster, + ({ triggers: roster }: TriggerRosterPayload) => { + this.patch({ triggers: roster }); + }, + ); + + socket.on( + SocketEvents.TriggerUpdate, + ({ trigger }: TriggerUpdatePayload) => { + this.patch((prev) => { + const next = prev.triggers.filter((t) => t.id !== trigger.id); + next.push(trigger); + return { ...prev, triggers: next }; + }); + }, + ); + + socket.on(SocketEvents.UiCommand, ({ command }: UiCommandPayload) => { + if (command.kind !== "artifacts.update") { + dispatchUiCommand(command); + return; + } + this.patch({ artifacts: command.artifacts }); + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Resources(sessionId), + }); + }); + } +} + +const rooms = new Map(); +const subscribeFns = new Map< + string, + (onStoreChange: () => void) => () => void +>(); + +function getOrCreateRoom(sessionId: string): SessionChatRoom { + const existing = rooms.get(sessionId); + if (existing) return existing; + const room = new SessionChatRoom(sessionId); + rooms.set(sessionId, room); + return room; +} + +/** Acquire a shared room; the first subscriber opens the socket. */ +export function acquireSessionChatRoom(sessionId: string): SessionChatRoom { + const room = getOrCreateRoom(sessionId); + room.refCount += 1; + if (room.refCount === 1) room.connect(); + return room; +} + +/** Release a shared room; the last subscriber closes the socket. */ +export function releaseSessionChatRoom(sessionId: string): void { + const room = rooms.get(sessionId); + if (!room) return; + room.refCount -= 1; + if (room.refCount > 0) return; + room.disconnect(); + rooms.delete(sessionId); + subscribeFns.delete(sessionId); +} + +/** + * Stable `useSyncExternalStore` subscribe for a session. Identity is cached + * per `sessionId` so a re-render does not flap acquire/release (which would + * tear down the socket). + */ +export function subscribeToSessionChat( + sessionId: string, +): (onStoreChange: () => void) => () => void { + const cached = subscribeFns.get(sessionId); + if (cached) return cached; + const subscribe = (onStoreChange: () => void) => { + const room = acquireSessionChatRoom(sessionId); + const unsub = room.subscribe(onStoreChange); + return () => { + unsub(); + releaseSessionChatRoom(sessionId); + }; + }; + subscribeFns.set(sessionId, subscribe); + return subscribe; +} + +export function peekSessionChat(sessionId: string): SessionChatSnapshot { + return rooms.get(sessionId)?.snapshot ?? EMPTY_SESSION_CHAT; +} + +export function peekSessionChatRoom(sessionId: string): SessionChatRoom | null { + return rooms.get(sessionId) ?? null; +} + +export { NO_MESSAGES }; diff --git a/apps/web/src/features/chat/hooks/useSessionChat.ts b/apps/web/src/features/chat/hooks/useSessionChat.ts index 0261cce..dadfa69 100644 --- a/apps/web/src/features/chat/hooks/useSessionChat.ts +++ b/apps/web/src/features/chat/hooks/useSessionChat.ts @@ -1,858 +1,114 @@ import { - type AgentAbortPayload, type AgentActivity, - type AgentActivityPayload, - type AgentDeltaPayload, - type AgentEndPayload, - type AgentErrorPayload, - type AgentModelPayload, - type AgentSetModelPayload, - type AgentStartPayload, - type AgentThinkingPayload, - type ArtifactPinPayload, - type ArtifactUnpinPayload, type Attachment, type ChatMessage, - type ChatMessagePayload, - type ConversationHistoryPayload, - type ConversationSubscribePayload, humanAuthor, - type MemoryConfirmPayload, - type MemoryDismissPayload, - type MemorySuggestionPayload, type MessageDelivery, - PI_AGENT, - type PinnedArtifact, - type RunId, - type Session, - SocketEvents, - type SubagentInfo, - type SubagentRosterPayload, - type SubagentStatus, - type SubagentUpdatePayload, - type ThinkingLevel, - type Trigger, - type TriggerRosterPayload, - type TriggerUpdatePayload, - type UiCommand, - type UiCommandPayload, } from "@tangent/shared/contracts"; -import { useEffect, useRef, useState } from "react"; -import { io, type Socket } from "socket.io-client"; +import { useSyncExternalStore } from "react"; -import { - type AgentLiveStatus, - AgentStatusQueryKeys, -} from "@/features/chat/model/agentStatusQueryKeys"; -import { SessionQueryKeys } from "@/features/sessions/model/sessionQueryKeys"; import { useCurrentUser } from "@/features/user/hooks/useCurrentUser"; -import { queryClient } from "@/shared/api/queryClient"; -import { BASE_PREFIX } from "@/shared/lib/basePath"; - -/** - * Applies an agent-issued UI directive. New `UiCommand` variants add a `case` - * here; unrecognized kinds are ignored so older clients stay forward-compatible. - */ -function dispatchUiCommand(command: UiCommand): void { - switch (command.kind) { - case "session.update": - return applySessionUpdate(command.session); - } -} - -/** - * Reflects a renamed (or otherwise updated) session in the query cache - * immediately: the individual-session entry drives the chat header, and the - * list entry keeps the sessions table current on its next visit. Writing the - * cache directly avoids the list's `staleTime` delaying the header update. - */ -function applySessionUpdate(session: Session): void { - queryClient.setQueryData(SessionQueryKeys.Id(session.id), session); - queryClient.setQueryData(SessionQueryKeys.All(), (prev) => - prev?.map((s) => (s.id === session.id ? session : s)), - ); -} -/** An agent's current model/thinking selection (absent fields = server default). */ -export interface AgentModelSelection { - model?: string; - thinkingDepth?: ThinkingLevel; -} - -/** Messages bucketed by the `conversationId` they belong to. */ -type MessageMap = Map; - -/** Stable empty result so an unknown Conversation doesn't churn renders. */ -const NO_MESSAGES: ChatMessage[] = []; - -/** Within one Conversation, order by `seq`; `id` breaks a tie deterministically. */ -function bySeq(a: ChatMessage, b: ChatMessage): number { - return a.seq - b.seq || a.id.localeCompare(b.id); -} - -/** Groups a flat history into per-Conversation buckets, each sorted by `seq`. */ -function groupByConversation(history: ChatMessage[]): MessageMap { - const map: MessageMap = new Map(); - for (const message of history) { - const bucket = map.get(message.conversationId); - if (bucket) bucket.push(message); - else map.set(message.conversationId, [message]); - } - for (const bucket of map.values()) bucket.sort(bySeq); - return map; -} - -/** Merges one Conversation's history in, deduped by id (incoming wins). */ -function mergeConversation( - prev: MessageMap, - conversationId: string, - incoming: ChatMessage[], -): MessageMap { - const byId = new Map(); - for (const message of prev.get(conversationId) ?? []) - byId.set(message.id, message); - for (const message of incoming) byId.set(message.id, message); - const next = new Map(prev); - next.set(conversationId, [...byId.values()].sort(bySeq)); - return next; -} +import { + type AgentModelSelection, + EMPTY_SESSION_CHAT, + NO_MESSAGES, + peekSessionChat, + peekSessionChatRoom, + type SessionChatSnapshot, + subscribeToSessionChat, +} from "./sessionChatRoom"; -/** - * Inserts a message into an already-`seq`-sorted bucket at its ordered slot. - * A live append is usually the newest, so scan from the end — but two humans - * typing while an agent streams can interleave, and the tiebreak keeps every - * client rendering the same order regardless of arrival order. - */ -function insertBySeq( - bucket: ChatMessage[], - message: ChatMessage, -): ChatMessage[] { - let i = bucket.length; - while (i > 0 && bySeq(bucket[i - 1], message) > 0) i--; - const next = bucket.slice(); - next.splice(i, 0, message); - return next; -} +export type { AgentModelSelection }; -/** Appends a message to its Conversation bucket in `seq` order. */ -function appendToConversation( - prev: MessageMap, - message: ChatMessage, -): MessageMap { - const next = new Map(prev); - const bucket = next.get(message.conversationId) ?? NO_MESSAGES; - next.set(message.conversationId, insertBySeq(bucket, message)); - return next; -} +const NOOP_SUBSCRIBE = () => () => {}; -/** Replaces one message by id in a Conversation bucket via an updater. */ -function editInConversation( - prev: MessageMap, +function isConversationBusy( + snapshot: SessionChatSnapshot, conversationId: string, - messageId: string, - update: (message: ChatMessage) => ChatMessage, -): MessageMap { - const bucket = prev.get(conversationId); - if (!bucket) return prev; - const next = new Map(prev); - next.set( - conversationId, - bucket.map((m) => (m.id === messageId ? update(m) : m)), +): boolean { + return ( + snapshot.streamingConversations.has(conversationId) || + snapshot.activityByConversation.has(conversationId) ); - return next; } /** - * Manages a single Socket.IO connection for one session's chat room. - * - * The connection is created in an effect keyed on `sessionId` and torn down on - * unmount or when the session changes, so the socket identity stays stable for - * a given room. + * Shared session-room state for one chat: messages, roster, artifacts, and + * triggers. Multiple callers (SPA chat, embed chat, agent/asset lists) share + * one Socket.IO connection via {@link subscribeToSessionChat}. */ export function useSessionChat(sessionId: string) { - // Messages bucketed per Conversation. The server delivers each Conversation's - // Messages to a room the socket joins only if authorized, so a bucket exists - // only for a Conversation this client may see — the client no longer filters - // one shared stream. - const [messagesByConversation, setMessagesByConversation] = - useState(() => new Map()); - const [subagents, setSubagents] = useState([]); - // The orchestrator's home Conversation — the primary ("Prime") thread the main - // Chat tab renders. Server-derived from the `orchestrator` capability (roster - // payload) so the client no longer privileges the reserved `"prime"` id. - // Seeded with `PI_AGENT.id` for a legacy session whose Prime keeps that id. - const [primaryConversationId, setPrimaryConversationId] = useState( - PI_AGENT.id, - ); - // Maps an agent id to the Conversation its thread lives in (`SubagentInfo.id → - // conversationId`, plus Prime → `primaryConversationId`). Components resolve an - // agent tab/card to its Conversation through this rather than assuming equality. - const [conversationByAgent, setConversationByAgent] = useState< - Map - >(() => new Map()); - // Per-agent model/thinking selection, keyed by agent id (`"prime"` or a - // sub-agent id). Seeded from the roster (sub-agents) and the `agent:model` - // event (Prime), and updated as either changes. - const [modelByAgent, setModelByAgent] = useState< - Map - >(() => new Map()); - const [triggers, setTriggers] = useState([]); - // Artifacts the user (or an agent) pinned for quick access, kept in sync with - // the room via the `artifacts.update` UI directive. - const [artifacts, setArtifacts] = useState([]); - const [connected, setConnected] = useState(false); - const [historyLoaded, setHistoryLoaded] = useState(false); - // Pending agent-initiated memory suggestions awaiting the user's confirmation. - const [memorySuggestions, setMemorySuggestions] = useState< - MemorySuggestionPayload[] - >([]); - // Conversations (keyed by `conversationId`) with a message actively - // streaming, i.e. between `agent:start` and `agent:end` for that message. - const [streamingConversations, setStreamingConversations] = useState< - Set - >(() => new Set()); - // In-flight message ids (between `agent:start` and `agent:end` / `agent:error`). - const [streamingMessageIds, setStreamingMessageIds] = useState>( - () => new Set(), - ); - // The current ephemeral activity per conversation (tool call / "thinking" - // between messages). Cleared when a message streams in or the run ends. - const [activityByConversation, setActivityByConversation] = useState< - Map - >(() => new Map()); - const socketRef = useRef(null); - // Conversations this socket has already been subscribed to since the last - // connect (seeded from the join-time roster). A sub-agent that spawns later - // arrives as a `subagent:update` for an unseen id, which triggers a - // `conversation:subscribe` so its room and history are joined on demand. - const subscribedConversations = useRef>(new Set()); - // Reverse of `conversationByAgent` (conversationId → agent id), used only to - // key the shared agent-status cache — which `useAgentStatus` reads by agent id - // — from socket events that now carry a decoupled conversation id. A ref: no - // render depends on it; it mirrors the roster as it arrives. - const agentByConversation = useRef>(new Map()); - // Maps an in-flight message id to its conversation so `agent:delta` / - // `agent:thinking` / `agent:error` (which only carry a messageId) can reach - // the right thread's bucket and streaming state. - const conversationByMessageId = useRef>(new Map()); - // The run each conversation is currently working under, so `abort` can name - // the run it means. Refs, not state: nothing renders from either, and the - // conversation-keyed state above already drives every visual. - const runIdByConversation = useRef>(new Map()); - // Runs with a message mid-stream. `agent:activity` going null also happens - // between messages within a run, so this is what distinguishes "the run's last - // event" from "the spinner cleared because text started arriving". - const streamingRuns = useRef>(new Set()); - - // The current human's chat identity. Only used to recognise our own messages - // in the transcript — the server authors what it persists, from the socket's - // own cookie, so this shares `humanAuthor` with it rather than guessing. const user = useCurrentUser(); const author = humanAuthor(user); - useEffect(() => { - if (!sessionId) return; - - // Connects to the same origin; Vite proxies /socket.io to the dev server. - // The path is mount-prefix aware so it works behind the tangle pod-proxy - // sub-path (`${BASE_PREFIX}socket.io`, i.e. `/socket.io` at the origin root). - const socket = io({ autoConnect: true, path: `${BASE_PREFIX}socket.io` }); - socketRef.current = socket; - - // Status inputs mirrored alongside the React state, so each socket handler - // can publish an agent's live status to the query cache the moment it - // changes — in lockstep with the state that drives the chat (rather than via - // a post-commit effect that a one-shot replay or cache eviction could miss). - const streaming = new Set(); - const activities = new Map(); - const statuses = new Map(); - - // Publishes one agent's derived live status. Prime has no lifecycle of its - // own, so it always reads as `active`; busy spans message streaming and any - // run-level activity, matching `isConversationBusy`. - const publish = (agentId: string) => { - const status: AgentLiveStatus = { - status: - agentId === PI_AGENT.id - ? "active" - : (statuses.get(agentId) ?? "active"), - busy: streaming.has(agentId) || activities.has(agentId), - activity: activities.get(agentId) ?? null, - }; - queryClient.setQueryData( - AgentStatusQueryKeys.Detail(sessionId, agentId), - status, - ); - }; - - // Resolves the agent that owns a Conversation, so a socket event carrying a - // decoupled conversation id keys the agent-status cache (read by agent id). - // Falls back to the id itself for a legacy agent whose ids still coincide. - const agentIdOf = (conversationId: string) => - agentByConversation.current.get(conversationId) ?? conversationId; - - // Republishes Prime plus every known sub-agent (e.g. after a (re)connect or - // disconnect, when the busy/activity inputs reset for all of them at once). - const publishAll = () => { - publish(PI_AGENT.id); - for (const id of statuses.keys()) publish(id); - }; - - // Follows a conversation's run through the activity indicator: a non-null - // activity records which run is working, and a null one with nothing - // streaming is the run's last event, so the conversation has no run again. - const trackRunActivity = ( - conversationId: string, - activity: AgentActivity | null, - runId: RunId | undefined, - ) => { - if (activity) { - if (runId) runIdByConversation.current.set(conversationId, runId); - return; - } - const current = runId ?? runIdByConversation.current.get(conversationId); - if (!current || streamingRuns.current.has(current)) return; - runIdByConversation.current.delete(conversationId); - }; - - socket.on("connect", () => { - // Reset on (re)connect rather than synchronously in the effect body so we - // don't trigger cascading renders; history and roster repopulate via the - // ChatHistory and SubagentRoster events the server sends on join. - setMessagesByConversation(new Map()); - setHistoryLoaded(false); - setSubagents([]); - setPrimaryConversationId(PI_AGENT.id); - setConversationByAgent(new Map()); - setModelByAgent(new Map()); - setTriggers([]); - setArtifacts([]); - setConnected(true); - setStreamingConversations(new Set()); - setStreamingMessageIds(new Set()); - setActivityByConversation(new Map()); - setMemorySuggestions([]); - conversationByMessageId.current.clear(); - runIdByConversation.current.clear(); - streamingRuns.current.clear(); - agentByConversation.current.clear(); - // Prime is joined server-side at chat:join; seed its id so a later update - // for it doesn't re-subscribe. The roster replaces this with Prime's real - // home conversation once it arrives. - subscribedConversations.current = new Set([PI_AGENT.id]); - // Reset the published statuses; the join snapshot (roster + replayed - // activity) republishes them. Prime is present immediately. - streaming.clear(); - activities.clear(); - statuses.clear(); - publish(PI_AGENT.id); - socket.emit(SocketEvents.ChatJoin, { sessionId }); - }); - socket.on("disconnect", () => { - setConnected(false); - setArtifacts([]); - setStreamingConversations(new Set()); - setStreamingMessageIds(new Set()); - setActivityByConversation(new Map()); - setMemorySuggestions([]); - conversationByMessageId.current.clear(); - runIdByConversation.current.clear(); - streamingRuns.current.clear(); - subscribedConversations.current.clear(); - // Nothing is running while disconnected; clear the busy inputs and - // republish every known agent as idle (keeping their lifecycle status). - streaming.clear(); - activities.clear(); - publishAll(); - }); - - // Join-time seed of every authorized Conversation, grouped into buckets. - socket.on(SocketEvents.ChatHistory, (history: ChatMessage[]) => { - setMessagesByConversation(groupByConversation(history)); - setHistoryLoaded(true); - }); - // A Conversation subscribed to after join (a newly spawned sub-agent): - // merge its log into its bucket without disturbing the others. - socket.on( - SocketEvents.ConversationHistory, - ({ conversationId, messages }: ConversationHistoryPayload) => { - setMessagesByConversation((prev) => - mergeConversation(prev, conversationId, messages), - ); - }, - ); - socket.on(SocketEvents.ChatMessage, (message: ChatMessage) => { - setMessagesByConversation((prev) => appendToConversation(prev, message)); - // A message carrying an attachment or a memory write is catalogued - // server-side; refetch the resource list so the catalog view reflects it. - if (message.attachments?.length || message.memory) { - void queryClient.invalidateQueries({ - queryKey: SessionQueryKeys.Resources(sessionId), - }); - } - }); - - // An agent begins a (new) message: append an empty placeholder we fill via - // deltas and mark that conversation's message stream in flight. - socket.on( - SocketEvents.AgentStart, - ({ message, runId }: AgentStartPayload) => { - conversationByMessageId.current.set(message.id, message.conversationId); - if (runId) { - runIdByConversation.current.set(message.conversationId, runId); - streamingRuns.current.add(runId); - } - setStreamingConversations((prev) => { - const next = new Set(prev); - next.add(message.conversationId); - return next; - }); - setStreamingMessageIds((prev) => { - const next = new Set(prev); - next.add(message.id); - return next; - }); - setMessagesByConversation((prev) => - appendToConversation(prev, message), - ); - const agentId = agentIdOf(message.conversationId); - streaming.add(agentId); - publish(agentId); - }, - ); - // Streamed token: append it to the matching in-flight message, routed to its - // Conversation bucket via the id-to-conversation map set at `agent:start`. - socket.on( - SocketEvents.AgentDelta, - ({ messageId, delta }: AgentDeltaPayload) => { - const conversationId = conversationByMessageId.current.get(messageId); - if (!conversationId) return; - setMessagesByConversation((prev) => - editInConversation(prev, conversationId, messageId, (m) => ({ - ...m, - content: m.content + delta, - })), - ); - }, - ); - // Streamed reasoning token: append it to the matching message's thinking. - socket.on( - SocketEvents.AgentThinking, - ({ messageId, delta }: AgentThinkingPayload) => { - const conversationId = conversationByMessageId.current.get(messageId); - if (!conversationId) return; - setMessagesByConversation((prev) => - editInConversation(prev, conversationId, messageId, (m) => ({ - ...m, - thinking: (m.thinking ?? "") + delta, - })), - ); - }, - ); - // A single message finished: replace its placeholder with the final - // message and end that message's stream. The run may still be busy (the - // activity indicator drives that); message streaming is cleared here. - socket.on(SocketEvents.AgentEnd, ({ message, runId }: AgentEndPayload) => { - conversationByMessageId.current.delete(message.id); - if (runId) streamingRuns.current.delete(runId); - setStreamingConversations((prev) => { - if (!prev.has(message.conversationId)) return prev; - const next = new Set(prev); - next.delete(message.conversationId); - return next; - }); - setStreamingMessageIds((prev) => { - if (!prev.has(message.id)) return prev; - const next = new Set(prev); - next.delete(message.id); - return next; - }); - setMessagesByConversation((prev) => - editInConversation( - prev, - message.conversationId, - message.id, - () => message, - ), - ); - const agentId = agentIdOf(message.conversationId); - streaming.delete(agentId); - publish(agentId); - }); - // The agent's run-level activity changed: a non-null activity surfaces the - // ephemeral spinner bubble; null clears it (message streaming / run idle). - socket.on( - SocketEvents.AgentActivity, - ({ conversationId, activity, runId }: AgentActivityPayload) => { - trackRunActivity(conversationId, activity, runId); - setActivityByConversation((prev) => { - const next = new Map(prev); - if (activity) { - next.set(conversationId, activity); - } else { - next.delete(conversationId); - } - return next; - }); - const agentId = agentIdOf(conversationId); - if (activity) { - activities.set(agentId, activity); - } else { - activities.delete(agentId); - } - publish(agentId); - }, - ); - socket.on( - SocketEvents.AgentError, - ({ messageId, message, runId }: AgentErrorPayload) => { - const conversationId = messageId - ? conversationByMessageId.current.get(messageId) - : undefined; - if (messageId) conversationByMessageId.current.delete(messageId); - if (runId) streamingRuns.current.delete(runId); - if (conversationId) runIdByConversation.current.delete(conversationId); - if (messageId) { - setStreamingMessageIds((prev) => { - if (!prev.has(messageId)) return prev; - const next = new Set(prev); - next.delete(messageId); - return next; - }); - } - if (conversationId) { - setStreamingConversations((prev) => { - if (!prev.has(conversationId)) return prev; - const next = new Set(prev); - next.delete(conversationId); - return next; - }); - const agentId = agentIdOf(conversationId); - streaming.delete(agentId); - activities.delete(agentId); - publish(agentId); - } - console.error("[chat] agent error:", message); - }, - ); - - // Full roster snapshot (sent on join): replace local state and seed each - // sub-agent's model/thinking selection. - socket.on( - SocketEvents.SubagentRoster, - ({ - subagents: roster, - primaryConversationId: primary, - }: SubagentRosterPayload) => { - setSubagents(roster); - setPrimaryConversationId(primary); - setConversationByAgent(() => { - const next = new Map([[PI_AGENT.id, primary]]); - for (const s of roster) next.set(s.id, s.conversationId); - return next; - }); - agentByConversation.current = new Map([[primary, PI_AGENT.id]]); - for (const s of roster) - agentByConversation.current.set(s.conversationId, s.id); - // Prime's home is joined server-side at chat:join; record it so a later - // roster/update for it does not re-subscribe. - subscribedConversations.current.add(primary); - setModelByAgent((prev) => { - const next = new Map(prev); - for (const s of roster) { - next.set(s.id, { model: s.model, thinkingDepth: s.thinkingDepth }); - } - return next; - }); - for (const s of roster) { - statuses.set(s.id, s.status); - // Joined server-side at chat:join; record it so a later update for it - // does not re-subscribe. - subscribedConversations.current.add(s.conversationId); - publish(s.id); - } - }, - ); - // A single sub-agent spawned or changed status: upsert by id. - socket.on( - SocketEvents.SubagentUpdate, - ({ subagent }: SubagentUpdatePayload) => { - // A sub-agent this client hasn't been subscribed to yet spawned after - // join: subscribe so its per-Conversation room and history are joined. - if (!subscribedConversations.current.has(subagent.conversationId)) { - subscribedConversations.current.add(subagent.conversationId); - const payload: ConversationSubscribePayload = { - sessionId, - conversationId: subagent.conversationId, - }; - socket.emit(SocketEvents.ConversationSubscribe, payload); - } - setConversationByAgent((prev) => - new Map(prev).set(subagent.id, subagent.conversationId), - ); - agentByConversation.current.set(subagent.conversationId, subagent.id); - setSubagents((prev) => { - const next = prev.filter((s) => s.id !== subagent.id); - next.push(subagent); - return next; - }); - setModelByAgent((prev) => - new Map(prev).set(subagent.id, { - model: subagent.model, - thinkingDepth: subagent.thinkingDepth, - }), - ); - statuses.set(subagent.id, subagent.status); - publish(subagent.id); - }, - ); - // Prime's model/thinking (sent on join and after a change): upsert by id. - socket.on( - SocketEvents.AgentModel, - ({ agentId, model, thinkingDepth }: AgentModelPayload) => { - setModelByAgent((prev) => - new Map(prev).set(agentId, { model, thinkingDepth }), - ); - }, - ); - - // A Participant's presence changed (a human connected/left, a person was - // revoked): refetch the roster so the presence dots stay live. The roster - // is fetched over REST, so a targeted invalidation is enough. - socket.on(SocketEvents.ParticipantPresence, () => { - void queryClient.invalidateQueries({ - queryKey: SessionQueryKeys.Participants(sessionId), - }); - }); - - // The agent proposed remembering something: queue a confirm/dismiss card. - socket.on( - SocketEvents.MemorySuggestion, - (suggestion: MemorySuggestionPayload) => { - setMemorySuggestions((prev) => [...prev, suggestion]); - }, - ); - - // Full trigger roster (sent on join and after any change): replace state. - socket.on( - SocketEvents.TriggerRoster, - ({ triggers: roster }: TriggerRosterPayload) => { - setTriggers(roster); - }, - ); - // A single trigger fired or changed: upsert by id. - socket.on( - SocketEvents.TriggerUpdate, - ({ trigger }: TriggerUpdatePayload) => { - setTriggers((prev) => { - const next = prev.filter((t) => t.id !== trigger.id); - next.push(trigger); - return next; - }); - }, - ); - - // A generic agent->UI directive (e.g. a session rename): dispatch by kind. - // `artifacts.update` carries the pinned-artifact list, which lives in this - // hook's state (and drives the sidebar), so it's applied here directly; - // everything else goes through the shared, cache-writing dispatcher. - socket.on(SocketEvents.UiCommand, ({ command }: UiCommandPayload) => { - if (command.kind === "artifacts.update") { - setArtifacts(command.artifacts); - // A pin/unpin mirrors into the resource catalog; refetch its view. - void queryClient.invalidateQueries({ - queryKey: SessionQueryKeys.Resources(sessionId), - }); - return; - } - dispatchUiCommand(command); - }); - - return () => { - socket.removeAllListeners(); - socket.disconnect(); - socketRef.current = null; - // Forget this session's published agent statuses so the cache doesn't - // retain stale entries across a session change or unmount. - queryClient.removeQueries({ - queryKey: AgentStatusQueryKeys.Session(sessionId), - }); - }; - }, [sessionId]); - - // Sends a message to an agent thread. `conversationId` targets Prime by - // default or a sub-agent; `delivery` controls how a mid-run message is queued - // (steer before the next LLM call, or follow-up after the run stops). - function send( - content: string, - options?: { - conversationId?: string; - delivery?: MessageDelivery; - attachments?: Attachment[]; - }, - ) { - const trimmed = content.trim(); - const socket = socketRef.current; - const attachments = options?.attachments; - const hasAttachments = Boolean(attachments && attachments.length); - if ((!trimmed && !hasAttachments) || !socket) return; - - const payload: ChatMessagePayload = { - sessionId, - content: trimmed, - conversationId: options?.conversationId ?? primaryConversationId, - delivery: options?.delivery ?? "auto", - ...(hasAttachments ? { attachments } : {}), - }; - socket.emit(SocketEvents.ChatMessage, payload); - } + const snapshot = useSyncExternalStore( + sessionId ? subscribeToSessionChat(sessionId) : NOOP_SUBSCRIBE, + () => (sessionId ? peekSessionChat(sessionId) : EMPTY_SESSION_CHAT), + () => EMPTY_SESSION_CHAT, + ); + const pinnedPaths = new Set(snapshot.artifacts.map((a) => a.path)); - // Cancels the run an agent (`"prime"` or a sub-agent id) is working under, - // naming the run when we know it so a run that has since been replaced isn't - // the one cancelled. The UI clears via the usual agent events. - function abort(conversationId: string) { - const socket = socketRef.current; - if (!socket) return; - const payload: AgentAbortPayload = { - sessionId, - conversationId, - runId: runIdByConversation.current.get(conversationId), - }; - socket.emit(SocketEvents.AgentAbort, payload); + function messagesFor(conversationId: string): ChatMessage[] { + return snapshot.messagesByConversation.get(conversationId) ?? NO_MESSAGES; } - // Changes an agent's model and/or thinking depth (`"prime"` or a sub-agent - // id). The server respawns that agent's process and echoes the new selection - // back via `agent:model` (Prime) or the roster update (sub-agents). - function setAgentModel(agentId: string, selection: AgentModelSelection) { - const socket = socketRef.current; - if (!socket) return; - const payload: AgentSetModelPayload = { - sessionId, - agentId, - model: selection.model, - thinkingDepth: selection.thinkingDepth, - }; - socket.emit(SocketEvents.AgentSetModel, payload); + function conversationForAgent(agentId: string): string { + return snapshot.conversationByAgent.get(agentId) ?? agentId; } - // The current model/thinking selection for an agent, or null when unknown - // (the agent then runs the server default). function getAgentModel(agentId: string): AgentModelSelection | null { - return modelByAgent.get(agentId) ?? null; + return snapshot.modelByAgent.get(agentId) ?? null; } - // Resolves a memory suggestion: tells the server to apply or discard it and - // optimistically removes the card so it can't be answered twice. - function resolveSuggestion(suggestionId: string, accept: boolean) { - const socket = socketRef.current; - if (!socket) return; - const event = accept - ? SocketEvents.MemoryConfirm - : SocketEvents.MemoryDismiss; - const payload: MemoryConfirmPayload | MemoryDismissPayload = { - sessionId, - suggestionId, - }; - socket.emit(event, payload); - setMemorySuggestions((prev) => - prev.filter((s) => s.suggestionId !== suggestionId), - ); - } - - function confirmMemory(suggestionId: string) { - resolveSuggestion(suggestionId, true); - } - function dismissMemory(suggestionId: string) { - resolveSuggestion(suggestionId, false); - } - - // Pins an artifact (by workspace-relative path) for quick access. The server - // dedupes by path and broadcasts the updated list back over `artifacts.update`. - function pinArtifact(path: string, title: string) { - const socket = socketRef.current; - if (!socket) return; - const payload: ArtifactPinPayload = { sessionId, path, title }; - socket.emit(SocketEvents.ArtifactPin, payload); - } - - function unpinArtifact(path: string) { - const socket = socketRef.current; - if (!socket) return; - const payload: ArtifactUnpinPayload = { sessionId, path }; - socket.emit(SocketEvents.ArtifactUnpin, payload); - } - - // The set of pinned paths, for O(1) "is this artifact pinned?" checks when - // rendering artifact chips. - const pinnedPaths = new Set(artifacts.map((a) => a.path)); - - // A conversation is busy while a message streams OR while it has a non-null - // activity (thinking between turns / running a tool). Together these bracket - // the whole run, even across multiple messages and tool calls. - function isConversationBusy(conversationId: string) { - return ( - streamingConversations.has(conversationId) || - activityByConversation.has(conversationId) - ); - } - - // The current ephemeral activity for a conversation, or null when idle or a - // message is actively streaming (the streaming bubble is the visual then). function getActivity(conversationId: string): AgentActivity | null { - return activityByConversation.get(conversationId) ?? null; - } - - function isMessageStreaming(messageId: string) { - return streamingMessageIds.has(messageId); - } - - // One Conversation's messages, in `seq` order, or an empty list when this - // client holds no bucket for it (never subscribed / not authorized). - function messagesFor(conversationId: string): ChatMessage[] { - return messagesByConversation.get(conversationId) ?? NO_MESSAGES; + return snapshot.activityByConversation.get(conversationId) ?? null; } - // The Conversation an agent's thread lives in, or the agent id itself when the - // roster hasn't mapped it yet (a legacy agent whose ids coincide, or Prime - // before the roster arrives). Components resolve an agent tab/card to its - // thread through this rather than assuming `conversationId === agentId`. - function conversationForAgent(agentId: string): string { - return conversationByAgent.get(agentId) ?? agentId; - } - - // Removes a sub-agent from the local roster (e.g. dismissing a killed agent - // from the sidebar). The server still tracks it, so it reappears on the next - // `subagent:roster` snapshot after a reconnect. - function dismissSubagent(id: string) { - setSubagents((prev) => prev.filter((s) => s.id !== id)); + function isMessageStreaming(messageId: string): boolean { + return snapshot.streamingMessageIds.has(messageId); } return { messagesFor, - subagents, - primaryConversationId, + subagents: snapshot.subagents, + primaryConversationId: snapshot.primaryConversationId, conversationForAgent, - triggers, - artifacts, + triggers: snapshot.triggers, + artifacts: snapshot.artifacts, pinnedPaths, - pinArtifact, - unpinArtifact, - connected, - historyLoaded, - memorySuggestions, - confirmMemory, - dismissMemory, - // The main thread's busy state drives the header/input; Prime owns it. - agentBusy: isConversationBusy(primaryConversationId), - isConversationBusy, + pinArtifact: (path: string, title: string) => + peekSessionChatRoom(sessionId)?.pinArtifact(path, title), + unpinArtifact: (path: string) => + peekSessionChatRoom(sessionId)?.unpinArtifact(path), + connected: snapshot.connected, + historyLoaded: snapshot.historyLoaded, + rosterReady: snapshot.rosterReady, + memorySuggestions: snapshot.memorySuggestions, + confirmMemory: (suggestionId: string) => + peekSessionChatRoom(sessionId)?.confirmMemory(suggestionId), + dismissMemory: (suggestionId: string) => + peekSessionChatRoom(sessionId)?.dismissMemory(suggestionId), + agentBusy: isConversationBusy(snapshot, snapshot.primaryConversationId), + isConversationBusy: (conversationId: string) => + isConversationBusy(snapshot, conversationId), getActivity, isMessageStreaming, currentAuthorId: author.id, - send, - abort, + send: ( + content: string, + options?: { + conversationId?: string; + delivery?: MessageDelivery; + attachments?: Attachment[]; + }, + ) => peekSessionChatRoom(sessionId)?.send(content, options), + abort: (conversationId: string) => + peekSessionChatRoom(sessionId)?.abort(conversationId), getAgentModel, - setAgentModel, - dismissSubagent, + setAgentModel: (agentId: string, selection: AgentModelSelection) => + peekSessionChatRoom(sessionId)?.setAgentModel(agentId, selection), + dismissSubagent: (id: string) => + peekSessionChatRoom(sessionId)?.dismissSubagent(id), }; } diff --git a/apps/web/src/features/chat/model/resources.ts b/apps/web/src/features/chat/model/resources.ts index 98f990c..9165514 100644 --- a/apps/web/src/features/chat/model/resources.ts +++ b/apps/web/src/features/chat/model/resources.ts @@ -7,6 +7,7 @@ export const RESOURCE_ICON: Record = { file: "File", attachment: "Paperclip", memory: "Brain", + host: "Link", }; /** Human-readable label for a resource's kind, shown as its subtitle. */ @@ -20,6 +21,8 @@ export function resourceKindLabel(kind: ResourceKind): string { return "Attachment"; case "memory": return "Memory"; + case "host": + return "Host"; } } diff --git a/apps/web/src/features/global-memory/api/globalMemoryApi.ts b/apps/web/src/features/global-memory/api/globalMemoryApi.ts index 266dce3..835fdc5 100644 --- a/apps/web/src/features/global-memory/api/globalMemoryApi.ts +++ b/apps/web/src/features/global-memory/api/globalMemoryApi.ts @@ -4,7 +4,7 @@ import type { UpdateGlobalMemoryResponse, } from "@tangent/shared/contracts"; -import { apiUrl } from "@/shared/lib/basePath"; +import { apiFetch } from "@/shared/lib/apiFetch"; async function parseJson(res: Response): Promise { if (!res.ok) { @@ -17,7 +17,7 @@ async function parseJson(res: Response): Promise { /** Fetches the current global memory file contents. */ export async function getGlobalMemory(): Promise { const data = await parseJson( - await fetch(apiUrl("/api/global-memory")), + await apiFetch("/api/global-memory"), ); return data.content; } @@ -26,7 +26,7 @@ export async function getGlobalMemory(): Promise { export async function updateGlobalMemory(content: string): Promise { const body: UpdateGlobalMemoryRequest = { content }; const data = await parseJson( - await fetch(apiUrl("/api/global-memory"), { + await apiFetch("/api/global-memory", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), diff --git a/apps/web/src/features/sessions/api/sessionsApi.ts b/apps/web/src/features/sessions/api/sessionsApi.ts index dfe7819..85e6376 100644 --- a/apps/web/src/features/sessions/api/sessionsApi.ts +++ b/apps/web/src/features/sessions/api/sessionsApi.ts @@ -1,6 +1,8 @@ import type { + AddResourceResponse, Attachment, CreateSessionRequest, + HostResourceInput, ListParticipantsResponse, ListResourcesResponse, MembershipView, @@ -12,7 +14,7 @@ import type { UploadFilesResponse, } from "@tangent/shared/contracts"; -import { apiUrl } from "@/shared/lib/basePath"; +import { apiFetch } from "@/shared/lib/apiFetch"; export type CreateSessionInput = CreateSessionRequest; @@ -26,14 +28,14 @@ async function parseJson(res: Response): Promise { export async function listSessions(): Promise { const data = await parseJson<{ sessions: Session[] }>( - await fetch(apiUrl("/api/sessions")), + await apiFetch("/api/sessions"), ); return data.sessions; } export async function getSession(id: string): Promise { const data = await parseJson<{ session: Session }>( - await fetch(apiUrl(`/api/sessions/${id}`)), + await apiFetch(`/api/sessions/${id}`), ); return data.session; } @@ -42,7 +44,7 @@ export async function createSession( input: CreateSessionInput, ): Promise { const data = await parseJson<{ session: Session }>( - await fetch(apiUrl("/api/sessions"), { + await apiFetch("/api/sessions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), @@ -56,7 +58,7 @@ export async function updateSession( input: UpdateSessionRequest, ): Promise { const data = await parseJson<{ session: Session }>( - await fetch(apiUrl(`/api/sessions/${id}`), { + await apiFetch(`/api/sessions/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(input), @@ -78,7 +80,7 @@ export async function uploadFiles( for (const file of files) form.append("files", file); const data = await parseJson( - await fetch(apiUrl(`/api/sessions/${sessionId}/files`), { + await apiFetch(`/api/sessions/${sessionId}/files`, { method: "POST", body: form, }), @@ -122,11 +124,43 @@ export async function listResources( } const suffix = params.toString() ? `?${params.toString()}` : ""; const data = await parseJson( - await fetch(apiUrl(`/api/sessions/${sessionId}/resources${suffix}`)), + await apiFetch(`/api/sessions/${sessionId}/resources${suffix}`), ); return data.resources; } +/** + * Adds one resource to a session — a memory write or a host entry (e.g. a known + * pipeline). Returns the stored resource; re-adding the same `uri` updates it. + */ +export async function addResource( + sessionId: string, + input: HostResourceInput, +): Promise { + const data = await parseJson( + await apiFetch(`/api/sessions/${sessionId}/resources`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); + return data.resource; +} + +/** Removes a resource by its `uri`. Removing a memory store clears it. */ +export async function removeResource( + sessionId: string, + uri: string, +): Promise { + const res = await apiFetch( + `/api/sessions/${sessionId}/resources?uri=${encodeURIComponent(uri)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + throw new Error(`Failed to remove resource (status ${res.status})`); + } +} + /** * Lists the session's Participants (humans, agents, automations) each with their * Memberships. The roster reads this to show who is present and where. @@ -135,7 +169,7 @@ export async function listParticipants( sessionId: string, ): Promise { const data = await parseJson( - await fetch(apiUrl(`/api/sessions/${sessionId}/participants`)), + await apiFetch(`/api/sessions/${sessionId}/participants`), ); return data.participants; } @@ -152,7 +186,7 @@ export async function inviteParticipant( input: InviteParticipantInput, ): Promise { const data = await parseJson<{ participant: ParticipantView }>( - await fetch(apiUrl(`/api/sessions/${sessionId}/participants`), { + await apiFetch(`/api/sessions/${sessionId}/participants`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), @@ -166,8 +200,8 @@ export async function revokeParticipant( sessionId: string, participantId: string, ): Promise { - const res = await fetch( - apiUrl(`/api/sessions/${sessionId}/participants/${participantId}`), + const res = await apiFetch( + `/api/sessions/${sessionId}/participants/${participantId}`, { method: "DELETE" }, ); if (!res.ok) { @@ -182,10 +216,8 @@ export async function joinMembership( conversationId: string, ): Promise { const data = await parseJson<{ membership: MembershipView | null }>( - await fetch( - apiUrl( - `/api/sessions/${sessionId}/participants/${participantId}/memberships`, - ), + await apiFetch( + `/api/sessions/${sessionId}/participants/${participantId}/memberships`, { method: "POST", headers: { "content-type": "application/json" }, @@ -202,10 +234,8 @@ export async function leaveMembership( participantId: string, conversationId: string, ): Promise { - const res = await fetch( - apiUrl( - `/api/sessions/${sessionId}/participants/${participantId}/memberships/${conversationId}`, - ), + const res = await apiFetch( + `/api/sessions/${sessionId}/participants/${participantId}/memberships/${conversationId}`, { method: "DELETE" }, ); if (!res.ok) { @@ -225,10 +255,8 @@ export async function muteMembership( muted: boolean, ): Promise { const data = await parseJson<{ membership: MembershipView }>( - await fetch( - apiUrl( - `/api/sessions/${sessionId}/participants/${participantId}/memberships/${conversationId}`, - ), + await apiFetch( + `/api/sessions/${sessionId}/participants/${participantId}/memberships/${conversationId}`, { method: "PATCH", headers: { "content-type": "application/json" }, @@ -240,7 +268,7 @@ export async function muteMembership( } export async function markSessionViewed(id: string): Promise { - const res = await fetch(apiUrl(`/api/sessions/${id}/viewed`), { + const res = await apiFetch(`/api/sessions/${id}/viewed`, { method: "POST", }); if (!res.ok) { @@ -249,7 +277,7 @@ export async function markSessionViewed(id: string): Promise { } export async function deleteSession(id: string): Promise { - const res = await fetch(apiUrl(`/api/sessions/${id}`), { method: "DELETE" }); + const res = await apiFetch(`/api/sessions/${id}`, { method: "DELETE" }); if (!res.ok) { throw new Error(`Failed to delete session (status ${res.status})`); } diff --git a/apps/web/src/features/sessions/components/SessionStatusProvider.tsx b/apps/web/src/features/sessions/components/SessionStatusProvider.tsx index d35b124..62e7107 100644 --- a/apps/web/src/features/sessions/components/SessionStatusProvider.tsx +++ b/apps/web/src/features/sessions/components/SessionStatusProvider.tsx @@ -6,14 +6,13 @@ import { } from "@tangent/shared/contracts"; import { useQueryClient } from "@tanstack/react-query"; import { type PropsWithChildren, useEffect, useState } from "react"; -import { io } from "socket.io-client"; import { SessionQueryKeys } from "@/features/sessions/model/sessionQueryKeys"; import { SessionStatusContext, type SessionStatusMap, } from "@/features/sessions/model/sessionStatusContext"; -import { BASE_PREFIX } from "@/shared/lib/basePath"; +import { createSocket } from "@/shared/lib/socket"; /** * Holds one shared socket subscribed to the sessions lobby and exposes every @@ -26,9 +25,8 @@ export function SessionStatusProvider({ children }: PropsWithChildren) { const queryClient = useQueryClient(); useEffect(() => { - // Same connection style as `useSessionChat`: Vite proxies /socket.io to the - // dev server, and the path is mount-prefix aware for the pod-proxy sub-path. - const socket = io({ autoConnect: true, path: `${BASE_PREFIX}socket.io` }); + // Same connection style as `useSessionChat` (see createSocket). + const socket = createSocket(); socket.on("connect", () => { // The snapshot repopulates state on (re)connect, so clear first. diff --git a/apps/web/src/features/triggers/api/triggersApi.ts b/apps/web/src/features/triggers/api/triggersApi.ts index 73f9151..f7117ae 100644 --- a/apps/web/src/features/triggers/api/triggersApi.ts +++ b/apps/web/src/features/triggers/api/triggersApi.ts @@ -1,6 +1,6 @@ import type { Trigger, UpdateTriggerRequest } from "@tangent/shared/contracts"; -import { apiUrl } from "@/shared/lib/basePath"; +import { apiFetch } from "@/shared/lib/apiFetch"; async function parseJson(res: Response): Promise { if (!res.ok) { @@ -17,7 +17,7 @@ export async function updateTrigger( input: UpdateTriggerRequest, ): Promise { const data = await parseJson<{ trigger: Trigger }>( - await fetch(apiUrl(`/api/sessions/${sessionId}/triggers/${triggerId}`), { + await apiFetch(`/api/sessions/${sessionId}/triggers/${triggerId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(input), @@ -31,8 +31,8 @@ export async function deleteTrigger( sessionId: string, triggerId: string, ): Promise { - const res = await fetch( - apiUrl(`/api/sessions/${sessionId}/triggers/${triggerId}`), + const res = await apiFetch( + `/api/sessions/${sessionId}/triggers/${triggerId}`, { method: "DELETE", }, diff --git a/apps/web/src/features/user/api/userApi.ts b/apps/web/src/features/user/api/userApi.ts index 7499bb0..e5556f2 100644 --- a/apps/web/src/features/user/api/userApi.ts +++ b/apps/web/src/features/user/api/userApi.ts @@ -1,6 +1,6 @@ import { DEFAULT_USER, type UserIdentity } from "@tangent/shared/contracts"; -import { apiUrl } from "@/shared/lib/basePath"; +import { apiFetch } from "@/shared/lib/apiFetch"; /** * Fetches the current user from `GET /api/me`. The endpoint returns `401`/`501` @@ -10,7 +10,7 @@ import { apiUrl } from "@/shared/lib/basePath"; */ export async function getMe(): Promise { try { - const res = await fetch(apiUrl("/api/me")); + const res = await apiFetch("/api/me"); if (!res.ok) return DEFAULT_USER; return (await res.json()) as UserIdentity; } catch { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 792dd7d..14e8e7a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,7 +4,7 @@ @source "../../../packages/ui-primitives/src"; @source "../../../packages/windows/src"; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:is(.dark, .dark *)); :root { color-scheme: light; diff --git a/apps/web/src/shared/lib/apiFetch.ts b/apps/web/src/shared/lib/apiFetch.ts new file mode 100644 index 0000000..17dcbad --- /dev/null +++ b/apps/web/src/shared/lib/apiFetch.ts @@ -0,0 +1,17 @@ +import { apiUrl, getAuthToken } from "@/shared/lib/basePath"; + +/** + * Central HTTP client for the Tangent REST API. Resolves the path against the + * active API base ({@link apiUrl}) and attaches the embed bearer token as + * `Authorization: Bearer` when a token getter is configured. In the standalone + * SPA no token resolves, so this is an ordinary same-origin `fetch`. + */ +export async function apiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const token = await Promise.resolve(getAuthToken()); + const headers = new Headers(init.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + return fetch(apiUrl(path), { ...init, headers }); +} diff --git a/apps/web/src/shared/lib/basePath.ts b/apps/web/src/shared/lib/basePath.ts index 321b9a7..9bb1006 100644 --- a/apps/web/src/shared/lib/basePath.ts +++ b/apps/web/src/shared/lib/basePath.ts @@ -1,5 +1,5 @@ /** - * Proxy mount-prefix awareness. + * Proxy mount-prefix awareness, plus a runtime override for embedded mode. * * Behind the tangle pod-proxy the app is served from a sub-path * (e.g. `.../ports/8000/`), not the origin root. `index.html` injects a @@ -9,12 +9,66 @@ * * At the origin root (dev, production-without-proxy) the prefix is `/` and * `apiUrl` is a no-op aside from normalizing the leading slash. + * + * When the code runs embedded on a third-party host page, `document.baseURI` + * is the *host's* URL and there is no Vite proxy, so API traffic must target + * Tangent's origin directly. `configureEmbedApi` lets the embed runtime supply + * an absolute API base, an explicit Socket.IO url/path, and a bearer-token + * getter. All accessors below fall back to the same-origin behaviour when it is + * left unset, so the standalone SPA is unchanged. */ /** The mount-root pathname, always ending in `/` (e.g. `.../ports/8000/` or `/`). */ export const BASE_PREFIX = new URL(document.baseURI).pathname; -/** Rewrites an origin-root path onto the mount prefix. */ +export interface EmbedApiConfig { + /** Absolute API origin (optionally with a mount prefix), e.g. `https://tangent.example/`. */ + apiBase?: string; + /** Absolute origin for the Socket.IO connection. Defaults to same-origin. */ + socketUrl?: string; + /** Socket.IO path. Defaults to `${BASE_PREFIX}socket.io`. */ + socketPath?: string; + /** Returns a bearer token for API/socket auth. May be sync or async. */ + getToken?: () => string | undefined | Promise; +} + +let embedConfig: EmbedApiConfig = {}; + +/** Sets (merges) the embedded-mode API configuration. */ +export function configureEmbedApi(config: EmbedApiConfig): void { + embedConfig = { ...embedConfig, ...config }; +} + +/** Rewrites an origin-root path onto the embed API base, or the mount prefix. */ export function apiUrl(path: string): string { - return `${BASE_PREFIX}${path.replace(/^\//, "")}`; + const rel = path.replace(/^\//, ""); + if (embedConfig.apiBase) { + return `${embedConfig.apiBase.replace(/\/$/, "")}/${rel}`; + } + return `${BASE_PREFIX}${rel}`; +} + +/** + * The origin to pass to `io()`; `undefined` means same-origin. Embedded on a + * host page, defaults to the API base's origin so the socket targets Tangent's + * origin rather than the host page. The standalone SPA has no `apiBase`, so it + * stays same-origin (Vite proxies `/socket.io` in dev). + */ +export function socketUrl(): string | undefined { + if (embedConfig.socketUrl) return embedConfig.socketUrl; + if (embedConfig.apiBase) return new URL(embedConfig.apiBase).origin; + return undefined; +} + +/** The Socket.IO path, mount-prefix aware unless overridden for embed. */ +export function socketPath(): string { + return embedConfig.socketPath ?? `${BASE_PREFIX}socket.io`; +} + +/** Resolves the current bearer token, if a getter was configured. */ +export function getAuthToken(): + | string + | undefined + | Promise { + return embedConfig.getToken?.(); } diff --git a/apps/web/src/shared/lib/socket.ts b/apps/web/src/shared/lib/socket.ts new file mode 100644 index 0000000..d810777 --- /dev/null +++ b/apps/web/src/shared/lib/socket.ts @@ -0,0 +1,24 @@ +import { io, type Socket } from "socket.io-client"; + +import { getAuthToken, socketPath, socketUrl } from "@/shared/lib/basePath"; + +/** + * Creates a Socket.IO client honouring the embed API configuration. In the + * standalone SPA this connects same-origin with the mount-prefixed path (Vite + * proxies `/socket.io` in dev); embedded on a host page it targets Tangent's + * origin and attaches the bearer token over the handshake. + */ +export function createSocket(): Socket { + const options = { + autoConnect: true, + path: socketPath(), + auth: (cb: (data: Record) => void) => { + void Promise.resolve(getAuthToken()).then((token) => + cb(token ? { token } : {}), + ); + }, + }; + + const url = socketUrl(); + return url ? io(url, options) : io(options); +} diff --git a/apps/web/src/shared/theme/theme.ts b/apps/web/src/shared/theme/theme.ts index 1c81e43..0c7196b 100644 --- a/apps/web/src/shared/theme/theme.ts +++ b/apps/web/src/shared/theme/theme.ts @@ -29,13 +29,19 @@ function isTheme(value: unknown): value is Theme { } /** - * Apply a theme by toggling classes on the document root. Light uses no class, - * dark adds `dark`, and the dark-derived themes (xterm, piforge) add - * `dark ` so `dark:` utilities keep working while the theme's token - * overrides win (they are defined after `.dark`). + * Apply a theme by toggling classes on `target` (the document root by default). + * Light uses no class, dark adds `dark`, and the dark-derived themes (xterm, + * piforge) add `dark ` so `dark:` utilities keep working while the + * theme's token overrides win (they are defined after `.dark`). + * + * Embedded mode passes its shadow-root wrapper as `target` so themes scope to + * the embed rather than the host document. */ -export function applyTheme(theme: Theme): void { - const root = document.documentElement; +export function applyTheme( + theme: Theme, + target: HTMLElement = document.documentElement, +): void { + const root = target; root.classList.remove("dark", "xterm", "piforge"); if (theme === "dark" || theme === "xterm" || theme === "piforge") { root.classList.add("dark"); diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json index 41be2f7..15913c9 100644 --- a/apps/web/tsconfig.node.json +++ b/apps/web/tsconfig.node.json @@ -3,5 +3,5 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vite.embed.config.ts"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bed7700..ecbe68d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,15 +3,36 @@ import path from "node:path"; import babel from "@rolldown/plugin-babel"; import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; // Backend the dev server proxies /api and /socket.io to. Override via the // API_TARGET env var to point the local UI at a remote agent (e.g. the Cloud // Run proxy on http://localhost:8788) without touching client fetch/socket code. const apiTarget = process.env.API_TARGET ?? "http://localhost:8787"; +// The embed channel URL (production: an nginx-served, prebuilt bundle at +// /embed/v1/tangent-elements.js) has no equivalent on the Vite dev server, so +// hitting it 404s. In dev, answer it with a shim that imports the live source +// through Vite's transform pipeline — same elements, with HMR and no separate +// `build:embed` step. CORS is open so a cross-port harness can import it too. +function embedDevChannel(): Plugin { + const CHANNEL = "/embed/v1/tangent-elements.js"; + return { + name: "tangent-embed-dev-channel", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url || req.url.split("?")[0] !== CHANNEL) return next(); + res.setHeader("Content-Type", "text/javascript"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.end('import "/src/embed/index.ts";\n'); + }); + }, + }; +} + // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ command }) => ({ // Relative asset base so the built index.html references assets as // "./assets/..." rather than "/assets/...". Behind the tangle pod-proxy the // Kubernetes apiserver rewrites same-host absolute-path URLs in HTML to its @@ -50,9 +71,28 @@ export default defineConfig({ babel({ presets: [reactCompilerPreset()] }), react(), tailwindcss(), + embedDevChannel(), ], resolve: { alias: [ + // The dev embed channel serves the runtime through this same server, so a + // cross-origin host page cannot construct the default module-URL worker + // (worker scripts must be same-origin with the page). Vite does not inline + // workers in dev, so `?worker&inline` would still emit a bare root-relative + // URL that resolves against the host origin; the dev factory instead boots + // from a same-origin blob that imports the worker's absolute Shell URL. The + // SPA production build keeps the default separate-chunk worker. + ...(command === "serve" + ? [ + { + find: /^\.\/createBundleUiWorker$/, + replacement: path.resolve( + __dirname, + "./src/features/bundle-ui/createBundleUiWorker.dev.ts", + ), + }, + ] + : []), { find: "@", replacement: path.resolve(__dirname, "./src") }, // The SDK barrel UI extensions import. In-repo (harness, type checks) the // bare specifier resolves to the runtime module; sandboxed components get @@ -88,4 +128,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/apps/web/vite.embed.config.ts b/apps/web/vite.embed.config.ts new file mode 100644 index 0000000..549d7eb --- /dev/null +++ b/apps/web/vite.embed.config.ts @@ -0,0 +1,78 @@ +import path from "node:path"; + +import babel from "@rolldown/plugin-babel"; +import tailwindcss from "@tailwindcss/vite"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Builds the embed runtime: a single, self-contained ESM module that registers +// the `tangent-*` custom elements with our React 19, Tailwind output, and +// dependencies all bundled in (nothing external — there is no host runtime to +// provide React). The compiled Tailwind CSS is inlined via `?inline` and +// adopted into each element's shadow root at runtime (see src/embed/styles.ts). +export default defineConfig({ + define: { + // No dev-only absolute API origin in the embed bundle; the base is + // configured at runtime through . + __API_ORIGIN__: JSON.stringify(""), + // Vite lib mode does not replace process.env.NODE_ENV (only app builds do), + // so bundled React-ecosystem deps would ship raw `process.env.NODE_ENV` + // reads and crash the browser-loaded bundle with `process is not defined`. + "process.env.NODE_ENV": JSON.stringify("production"), + }, + plugins: [ + babel({ + include: /packages[\\/]windows[\\/]src[\\/].+\.ts$/, + exclude: /node_modules/, + plugins: [ + [ + "@babel/plugin-syntax-typescript", + { allExtensions: false, isTSX: false }, + ], + ["@babel/plugin-proposal-decorators", { version: "2023-05" }], + ], + }), + babel({ presets: [reactCompilerPreset()] }), + react(), + tailwindcss(), + ], + resolve: { + alias: [ + // Inline the bundle-UI worker (single self-contained file); the SPA build + // keeps the default module-URL worker. The whole specifier is matched so + // the replacement fully substitutes the relative import in `BundleUiHost`. + { + find: /^\.\/createBundleUiWorker$/, + replacement: path.resolve( + __dirname, + "./src/features/bundle-ui/createBundleUiWorker.embed.ts", + ), + }, + { find: "@", replacement: path.resolve(__dirname, "./src") }, + { + find: /^@tangent\/ui-extensions-sdk$/, + replacement: path.resolve( + __dirname, + "./src/features/bundle-ui/runtime/bridge.tsx", + ), + }, + ], + }, + build: { + outDir: "dist/embed/v1", + emptyOutDir: true, + // The embed bundle inlines its CSS and needs none of the SPA's public + // assets (favicon, logo, harness); keep the served directory minimal. + copyPublicDir: false, + // Long-lived immutable caching is applied by nginx on the version-pinned + // filename; a single ESM file keeps the channel URL stable. + lib: { + entry: path.resolve(__dirname, "./src/embed/index.ts"), + formats: ["es"], + fileName: () => "tangent-elements.js", + }, + rollupOptions: { + output: { inlineDynamicImports: true }, + }, + }, +}); diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template index 43bc89f..0e7f2a1 100644 --- a/docker/nginx.conf.template +++ b/docker/nginx.conf.template @@ -40,6 +40,29 @@ server { proxy_read_timeout 86400; } + # Embedded UI runtime (Option E): the `tangent-*` custom-element bundle, + # imported cross-origin by a third-party host as an ESM module. Served from + # /app/ui-dist/embed/** and answered honestly with 404 (no SPA fallback, so a + # missing bundle is not silently handed back as index.html). CORS is required + # because a module script imported from another origin is a cross-origin + # request. The channel file (/embed/v/tangent-elements.js) moves with each + # UI release, so it gets a short, revalidated cache. + location /embed/ { + add_header Access-Control-Allow-Origin "*" always; + add_header Cross-Origin-Resource-Policy "cross-origin" always; + add_header Cache-Control "public, max-age=60, must-revalidate" always; + try_files $uri =404; + } + + # Content-hashed embed chunks (worker, split assets) are immutable, so they + # can be cached forever. The regex takes precedence over the prefix above. + location ~* ^/embed/v\d+/assets/ { + add_header Access-Control-Allow-Origin "*" always; + add_header Cross-Origin-Resource-Policy "cross-origin" always; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + # SPA history fallback: serve the requested asset if it exists, otherwise # hand back index.html so client-side routing can take over. location / { diff --git a/packages/embed-react/README.md b/packages/embed-react/README.md new file mode 100644 index 0000000..7b1547f --- /dev/null +++ b/packages/embed-react/README.md @@ -0,0 +1,324 @@ +# @tangent/embed-react + +Thin React 19 wrappers for embedding the Tangent UI in a third-party host app. + +The UI itself ships as runtime-delivered custom elements (`embed.js`, served from +Tangent's origin). This package is only types and glue: it loads that module once, +renders the elements, forwards rich values as element _properties_, and turns the +elements' `CustomEvent`s into `on*` props. Because it holds no UI, Tangent can ship +UI changes without the host redeploying — the host redeploys only when the +prop/event contract changes. + +## Requirements + +- React 19 (unknown props on custom elements are assigned as properties). +- The host and Tangent may be different origins. CORS and bearer auth on the + Tangent server are required for cross-origin use (see the deploy notes). + +## Install + +```bash +pnpm add @tangent/embed-react +``` + +## Quickstart + +Wrap your app in `TangentProvider`, start a session, and render `Chat`. + +```tsx +import { TangentProvider, Chat, useTangent } from "@tangent/embed-react"; +import { useState } from "react"; + +function Embed() { + const { newSession } = useTangent(); + const [sessionId, setSessionId] = useState(null); + + async function start() { + const { sessionId } = await newSession( + "Draft a pipeline that ingests orders and flags anomalies.", + "tangle-oss", // agent bundle id + { model: "claude-sonnet", name: "Anomaly pipeline" }, + ); + setSessionId(sessionId); + } + + return sessionId ? ( + openInHostTab(url, title)} + onSendPrompt={(content) => console.log("user sent:", content)} + /> + ) : ( + + ); +} + +export function App() { + return ( + auth.getAccessToken()} + colorScheme="system" + > + + + ); +} +``` + +## API + +### `` + +Loads the runtime once and owns the shared configuration. + +| Prop | Type | Notes | +| ------------- | ------------------------------------------- | ----------------------------------------------------------------- | +| `baseUrl` | `string` | Tangent origin. API + socket + channel URL derive from this. | +| `getToken` | `() => string \| undefined \| Promise<...>` | Bearer token for API/socket auth. Held in memory, never in URLs. | +| `colorScheme` | `"light" \| "dark" \| "system"` | Defaults to `light`. | +| `tokens` | `Record` | Unstable escape hatch for one-off token overrides. | +| `socketUrl` | `string` | Override the Socket.IO origin (defaults to the API origin). | +| `socketPath` | `string` | Override the Socket.IO path (defaults to `/socket.io`). | +| `channelUrl` | `string` | Override the runtime URL (defaults to `${baseUrl}/embed/v1/...`). | +| `instance` | `string` | Disambiguate when a page mounts more than one provider. | + +### `useTangent()` + +Returns `{ newSession, listResources, addResource, removeResource }`. + +```ts +newSession( + prompt: string, + bundleId: string, + options?: { + name?: string; + model?: string; + thinkingDepth?: string; + delivery?: "auto" | "steer" | "followUp"; + attachments?: unknown[]; + resources?: HostResourceInput[]; + }, +): Promise<{ sessionId: string }>; +``` + +Creates a session from the bundle and queues `prompt`; the chat sends it once it +joins, so it never races the agent. Render `` with the +returned id. Pass `options.resources` to seed the session before the agent +spawns, so the seeds are standing context from the first turn. + +#### Resources + +A session holds a catalog of resources — memory documents, host-provided +entries, attachments, and workspace files. The host can seed, add, list, and +remove the two kinds it owns (`memory` and `host`); artifacts, attachments, and +files stay on their own mechanisms and are read-only through this API. + +```ts +type HostResourceInput = + | { kind: "memory"; scope?: "session" | "global"; content: string } + | { kind: "host"; name: string; uri: string; meta?: Record }; + +listResources(sessionId: string): Promise; +addResource(sessionId: string, input: HostResourceInput): Promise; +removeResource(sessionId: string, uri: string): Promise; +``` + +A `memory` entry writes the session (or global) memory store the agent reads and +consults from its first turn. A `host` entry is host-owned content the shell +surfaces but does not interpret — `uri` is a host-stable id and `meta` is +free-form JSON. Removing a `memory` resource clears that store; removing a +`host` resource drops the catalog entry. + +```tsx +const { newSession, addResource } = useTangent(); + +const { sessionId } = await newSession( + "Draft a pipeline that ingests orders and flags anomalies.", + "tangle-oss", + { + name: "Anomaly pipeline", + resources: [ + { kind: "memory", scope: "session", content: "Prefer concise plans." }, + { + kind: "host", + name: "Orders pipeline", + uri: "https://tangent.example/pipelines/orders", + meta: { + url: "https://tangent.example/pipelines/orders", + name: "Orders pipeline", + description: "Ingests orders and flags anomalies.", + }, + }, + ], + }, +); + +// Add another known pipeline later: +await addResource(sessionId, { + kind: "host", + name: "Returns pipeline", + uri: "https://tangent.example/pipelines/returns", + meta: { url: "...", name: "Returns pipeline", description: "..." }, +}); +``` + +### `` + +| Prop | Type | Notes | +| -------------------- | ---------------------- | --------------------------------------------------------- | +| `sessionId` | `string` | The session to render. | +| `agentId` | `string` | Render this agent's thread instead of Prime. | +| `initialPrompt` | `string` | Sent once the session joins (for a host-created session). | +| `onOpenArtifact` | `(url, title) => void` | The host decides how to open the resource. | +| `onSendPrompt` | `(content) => void` | Fired when the user submits a prompt. | +| `onError` | `(message) => void` | Fired on a surfaced runtime error. | +| `className`, `style` | — | Forwarded to the element; size it with `height`. | + +### `` + +Renders the session list. It fetches its own sessions; wire `onSelect` (and +optionally `onDeleted`) to react to the user, and pass `selectedId` to highlight +the active row. + +| Prop | Type | Notes | +| -------------------- | -------------- | --------------------------------------------------------- | +| `onSelect` | `(id) => void` | A row was clicked. The host decides what selecting means. | +| `selectedId` | `string` | Highlighted row; scrolled into view when set. | +| `onDeleted` | `(id) => void` | A session was deleted from its row menu. | +| `instance` | `string` | Disambiguate when a page mounts more than one provider. | +| `className`, `style` | — | Forwarded to the element; size it with `height`. | + +### `` + +Renders Prime and the live sub-agent roster for a session. Wire `onOpen` to +place a `` (or any host chrome); dismissing a killed +sub-agent surfaces through `onRemove`. + +```tsx + setOpenAgentId(agent.id)} +/>; +{ + openAgentId ? : null; +} +``` + +| Prop | Type | Notes | +| -------------------- | ----------------- | --------------------------------------------------------- | +| `sessionId` | `string` | The session whose agents to render. | +| `onOpen` | `(agent) => void` | A card was clicked. `agent.conversationId` is the thread. | +| `selectedId` | `string` | Highlighted row (Prime or a sub-agent id). | +| `onRemove` | `(id) => void` | A killed sub-agent was dismissed from the list. | +| `instance` | `string` | Disambiguate multiple providers. | +| `className`, `style` | — | Forwarded; size it with `height`. | + +### `` + +Renders pinned pages, files, and triggers for a session. Wire `onOpen` to place +an `` for a `page`; unpin (and trigger toggle/delete) stay +inside the element, with `onUnpin` so the host can close a matching viewer. + +```tsx + { + if (asset.kind === "page") openViewer(asset.url, asset.title); + }} +/> +``` + +| Prop | Type | Notes | +| -------------------- | ----------------- | ------------------------------------------------- | +| `sessionId` | `string` | The session whose assets to render. | +| `onOpen` | `(asset) => void` | A card was clicked. Discriminate on `asset.kind`. | +| `selectedId` | `string` | Highlighted row (artifact URL or trigger id). | +| `onUnpin` | `(path) => void` | An artifact was unpinned. | +| `instance` | `string` | Disambiguate multiple providers. | +| `className`, `style` | — | Forwarded; size it with `height`. | + +### `` + +Renders the session's catalogued content read-only — pinned artifacts, human +attachments, memory documents, and workspace files. Opening a viewable +`file`/`artifact` fires `onOpen` with a resolved `url`; other kinds +(`memory`, `attachment`) are inert rows. Scopes to `agentId`'s Conversation +when set, otherwise Prime's. + +```tsx + openViewer(resource.url, resource.name)} +/> +``` + +| Prop | Type | Notes | +| -------------------- | -------------------- | --------------------------------------------------------------- | +| `sessionId` | `string` | The session whose resources to render. | +| `onOpen` | `(resource) => void` | A viewable row was clicked. `resource.url` is ready to open. | +| `agentId` | `string` | Scopes the catalog to that agent's Conversation (else Prime's). | +| `instance` | `string` | Disambiguate multiple providers. | +| `className`, `style` | — | Forwarded; size it with `height`. | + +### `` + +Renders the session's roster (humans, agents, automations) with live presence, +the orchestrator marked, and a mute toggle for an agent in the active +Conversation. The toggle mutates the shared session directly; `onToggleMute` +fires afterwards so the host can react. + +```tsx + console.log("muted", t.participantId, t.muted)} +/> +``` + +| Prop | Type | Notes | +| -------------------- | ------------------ | ------------------------------------------------------------------------ | +| `sessionId` | `string` | The session whose roster to render. | +| `onToggleMute` | `(toggle) => void` | An agent's mute was toggled: `{ participantId, conversationId, muted }`. | +| `agentId` | `string` | The Conversation a mute acts on (else Prime's thread). | +| `instance` | `string` | Disambiguate multiple providers. | +| `className`, `style` | — | Forwarded; size it with `height`. | + +### `` + +Renders an opened artifact (markdown, PDF, images, HTML). Point it at a resolved +`url` — for example the one handed to ``. A submitted review +surfaces through `onSendPrompt`, which you typically forward to a ``. + +| Prop | Type | Notes | +| -------------------- | --------------------------------- | ---------------------------------------- | +| `sessionId` | `string` | Session that owns the artifact. | +| `url` | `string` | Resolved artifact URL. | +| `title` | `string` | Human-readable title. | +| `onSendPrompt` | `(content, attachments?) => void` | Review submitted; feed it to a ``. | +| `instance` | `string` | Disambiguate multiple providers. | +| `className`, `style` | — | Forwarded; size it with `height`. | + +### `` + +Renders a sandboxed bundle-UI component in a Web Worker (remote-dom). Prompts and +collapse requests from the component surface via callbacks. + +| Prop | Type | Notes | +| -------------------- | ------------------------- | ------------------------------------------- | +| `moduleUrl` | `string` | URL of the compiled component JS. | +| `kind` | `"message" \| "panel"` | Which surface the component renders on. | +| `props` | `Record` | JSON props for a `message` component. | +| `stateNamespace` | `string` | localStorage namespace for persisted state. | +| `onSendPrompt` | `(text) => void` | A `panel` component composed a prompt. | +| `onCollapse` | `() => void` | The component asked to collapse. | +| `instance` | `string` | Disambiguate multiple providers. | +| `className`, `style` | — | Forwarded to the element. | + +## Versioning + +The runtime reports a protocol version at registration; this package declares the +range it supports and warns on mismatch rather than failing. A breaking contract +change bumps the served channel to `/embed/v2/` and both serve in parallel while +hosts migrate. diff --git a/packages/embed-react/eslint.config.js b/packages/embed-react/eslint.config.js new file mode 100644 index 0000000..537482c --- /dev/null +++ b/packages/embed-react/eslint.config.js @@ -0,0 +1,3 @@ +import base from "@tangent/build/eslint/base"; + +export default [{ ignores: ["node_modules", "dist"] }, ...base]; diff --git a/packages/embed-react/package.json b/packages/embed-react/package.json new file mode 100644 index 0000000..f196242 --- /dev/null +++ b/packages/embed-react/package.json @@ -0,0 +1,52 @@ +{ + "name": "@tangent/embed-react", + "version": "0.1.1", + "type": "module", + "description": "Thin React 19 wrappers for embedding the Tangent UI (runtime-delivered custom elements) in a third-party host app.", + "license": "MIT", + "publishConfig": { + "access": "public", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + } + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist", + "src", + "README.md" + ], + "sideEffects": false, + "prettier": "@tangent/build/prettier", + "scripts": { + "build": "tsup", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "format": "prettier --write ." + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tangent/build": "workspace:*", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "eslint": "^10.4.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tsup": "^8.5.0", + "typescript": "^6.0.3" + } +} diff --git a/packages/embed-react/src/AgentList.tsx b/packages/embed-react/src/AgentList.tsx new file mode 100644 index 0000000..102401d --- /dev/null +++ b/packages/embed-react/src/AgentList.tsx @@ -0,0 +1,71 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { EmbedAgent, TangentAgentListElementLike } from "./types"; + +export interface AgentListProps { + /** The session whose agents to render. */ + sessionId: string; + /** Highlighted row (Prime or a sub-agent id). */ + selectedId?: string; + /** A card was clicked; the host decides what opening an agent means. */ + onOpen: (agent: EmbedAgent) => void; + /** A killed sub-agent was dismissed from the list. */ + onRemove?: (id: string) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded agent list as ``. Wire `onOpen` to + * place a ``; dismissals surface via `onRemove`. + */ +export function AgentList({ + sessionId, + selectedId, + onOpen, + onRemove, + instance, + className, + style, +}: AgentListProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentAgentListElementLike | null; + if (element) element.sessionId = sessionId; + }, [sessionId]); + + useEffect(() => { + const element = ref.current as TangentAgentListElementLike | null; + if (element && selectedId != null) element.selectedId = selectedId; + }, [selectedId]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleOpen = (event: Event) => { + onOpen((event as CustomEvent).detail); + }; + const handleRemove = (event: Event) => { + onRemove?.((event as CustomEvent<{ id: string }>).detail.id); + }; + element.addEventListener("open-agent", handleOpen); + element.addEventListener("remove-agent", handleRemove); + return () => { + element.removeEventListener("open-agent", handleOpen); + element.removeEventListener("remove-agent", handleRemove); + }; + }, [onOpen, onRemove]); + + return ( + + ); +} diff --git a/packages/embed-react/src/ArtifactViewer.tsx b/packages/embed-react/src/ArtifactViewer.tsx new file mode 100644 index 0000000..bcf7208 --- /dev/null +++ b/packages/embed-react/src/ArtifactViewer.tsx @@ -0,0 +1,74 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { TangentArtifactViewerElementLike } from "./types"; + +export interface ArtifactViewerProps { + /** Session that owns the artifact; review screenshots upload into it. */ + sessionId: string; + /** Resolved artifact URL (e.g. from ``). */ + url: string; + /** Human-readable title (the iframe's accessible name). */ + title: string; + /** A review was submitted; feed it to a `` to send it to Prime. */ + onSendPrompt?: (content: string, attachments?: unknown[]) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded artifact viewer as ``. Point it + * at a resolved artifact `url`; a submitted review surfaces via `onSendPrompt`. + */ +export function ArtifactViewer({ + sessionId, + url, + title, + onSendPrompt, + instance, + className, + style, +}: ArtifactViewerProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentArtifactViewerElementLike | null; + if (element) element.sessionId = sessionId; + }, [sessionId]); + + useEffect(() => { + const element = ref.current as TangentArtifactViewerElementLike | null; + if (element) element.url = url; + }, [url]); + + useEffect(() => { + const element = ref.current as TangentArtifactViewerElementLike | null; + if (element) element.title = title; + }, [title]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleSend = (event: Event) => { + const detail = ( + event as CustomEvent<{ content: string; attachments?: unknown[] }> + ).detail; + onSendPrompt?.(detail.content, detail.attachments); + }; + element.addEventListener("send-prompt", handleSend); + return () => { + element.removeEventListener("send-prompt", handleSend); + }; + }, [onSendPrompt]); + + return ( + + ); +} diff --git a/packages/embed-react/src/AssetList.tsx b/packages/embed-react/src/AssetList.tsx new file mode 100644 index 0000000..a316067 --- /dev/null +++ b/packages/embed-react/src/AssetList.tsx @@ -0,0 +1,71 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { EmbedAsset, TangentAssetListElementLike } from "./types"; + +export interface AssetListProps { + /** The session whose assets to render. */ + sessionId: string; + /** Highlighted row (artifact URL or trigger id). */ + selectedId?: string; + /** A card was clicked; the host decides what opening an asset means. */ + onOpen: (asset: EmbedAsset) => void; + /** An artifact was unpinned; the host can close a matching viewer. */ + onUnpin?: (path: string) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded asset list as ``. Wire `onOpen` to + * place an `` for a `page`; unpin surfaces via `onUnpin`. + */ +export function AssetList({ + sessionId, + selectedId, + onOpen, + onUnpin, + instance, + className, + style, +}: AssetListProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentAssetListElementLike | null; + if (element) element.sessionId = sessionId; + }, [sessionId]); + + useEffect(() => { + const element = ref.current as TangentAssetListElementLike | null; + if (element && selectedId != null) element.selectedId = selectedId; + }, [selectedId]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleOpen = (event: Event) => { + onOpen((event as CustomEvent).detail); + }; + const handleUnpin = (event: Event) => { + onUnpin?.((event as CustomEvent<{ path: string }>).detail.path); + }; + element.addEventListener("open-asset", handleOpen); + element.addEventListener("unpin-asset", handleUnpin); + return () => { + element.removeEventListener("open-asset", handleOpen); + element.removeEventListener("unpin-asset", handleUnpin); + }; + }, [onOpen, onUnpin]); + + return ( + + ); +} diff --git a/packages/embed-react/src/BundledUISlot.tsx b/packages/embed-react/src/BundledUISlot.tsx new file mode 100644 index 0000000..e3e3819 --- /dev/null +++ b/packages/embed-react/src/BundledUISlot.tsx @@ -0,0 +1,86 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { TangentBundledUiElementLike } from "./types"; + +export interface BundledUISlotProps { + /** URL of the compiled bundle component JS. */ + moduleUrl: string; + /** Which surface the component renders on. */ + kind: "message" | "panel"; + /** JSON props for a `message` component (ignored for `panel`). */ + props?: Record; + /** localStorage namespace for the component's persisted state (optional). */ + stateNamespace?: string; + /** A `panel` component composed a prompt. */ + onSendPrompt?: (text: string) => void; + /** The component asked to collapse its host surface. */ + onCollapse?: () => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders a sandboxed bundle-UI component as ``. The + * component runs in a Web Worker and streams a remote-dom tree; prompts and + * collapse requests surface via `onSendPrompt` / `onCollapse`. + */ +export function BundledUISlot({ + moduleUrl, + kind, + props, + stateNamespace, + onSendPrompt, + onCollapse, + instance, + className, + style, +}: BundledUISlotProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentBundledUiElementLike | null; + if (element) element.moduleUrl = moduleUrl; + }, [moduleUrl]); + + useEffect(() => { + const element = ref.current as TangentBundledUiElementLike | null; + if (element) element.kind = kind; + }, [kind]); + + useEffect(() => { + const element = ref.current as TangentBundledUiElementLike | null; + if (element) element.props = props; + }, [props]); + + useEffect(() => { + const element = ref.current as TangentBundledUiElementLike | null; + if (element) element.stateNamespace = stateNamespace; + }, [stateNamespace]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleSend = (event: Event) => { + onSendPrompt?.((event as CustomEvent<{ text: string }>).detail.text); + }; + const handleCollapse = () => onCollapse?.(); + element.addEventListener("send-prompt", handleSend); + element.addEventListener("collapse", handleCollapse); + return () => { + element.removeEventListener("send-prompt", handleSend); + element.removeEventListener("collapse", handleCollapse); + }; + }, [onSendPrompt, onCollapse]); + + return ( + + ); +} diff --git a/packages/embed-react/src/Chat.tsx b/packages/embed-react/src/Chat.tsx new file mode 100644 index 0000000..e5d5ee7 --- /dev/null +++ b/packages/embed-react/src/Chat.tsx @@ -0,0 +1,92 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { TangentChatElementLike } from "./types"; + +export interface ChatProps { + /** The session to render. Obtain a new one from `useTangent().newSession`. */ + sessionId: string; + /** Render this agent's thread instead of Prime. */ + agentId?: string; + /** A prompt to send once the session has joined (e.g. for a fresh session). */ + initialPrompt?: string; + /** The host opens the resource however it wants (tab, drawer, ...). */ + onOpenArtifact?: (url: string, title: string) => void; + /** Fired when the user submits a prompt, so the host can react. */ + onSendPrompt?: (content: string) => void; + /** Fired on a runtime error surfaced by the chat. */ + onError?: (message: string) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded chat (message list + composer) as ``. + * Rich values are assigned as element properties; `on*` callbacks bind to the + * element's `CustomEvent`s. + */ +export function Chat({ + sessionId, + agentId, + initialPrompt, + onOpenArtifact, + onSendPrompt, + onError, + instance, + className, + style, +}: ChatProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentChatElementLike | null; + if (element) element.sessionId = sessionId; + }, [sessionId]); + + useEffect(() => { + const element = ref.current as TangentChatElementLike | null; + if (element) element.agentId = agentId ?? ""; + }, [agentId]); + + useEffect(() => { + const element = ref.current as TangentChatElementLike | null; + if (element && initialPrompt != null) element.initialPrompt = initialPrompt; + }, [initialPrompt]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleArtifact = (event: Event) => { + const detail = (event as CustomEvent<{ url: string; title: string }>) + .detail; + onOpenArtifact?.(detail.url, detail.title); + }; + const handleSend = (event: Event) => { + onSendPrompt?.( + (event as CustomEvent<{ content: string }>).detail.content, + ); + }; + const handleError = (event: Event) => { + onError?.((event as CustomEvent<{ message: string }>).detail.message); + }; + element.addEventListener("open-artifact", handleArtifact); + element.addEventListener("send-prompt", handleSend); + element.addEventListener("error", handleError); + return () => { + element.removeEventListener("open-artifact", handleArtifact); + element.removeEventListener("send-prompt", handleSend); + element.removeEventListener("error", handleError); + }; + }, [onOpenArtifact, onSendPrompt, onError]); + + return ( + + ); +} diff --git a/packages/embed-react/src/ParticipantList.tsx b/packages/embed-react/src/ParticipantList.tsx new file mode 100644 index 0000000..4c42a33 --- /dev/null +++ b/packages/embed-react/src/ParticipantList.tsx @@ -0,0 +1,67 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { + EmbedMuteToggle, + TangentParticipantListElementLike, +} from "./types"; + +export interface ParticipantListProps { + /** The session whose roster to render. */ + sessionId: string; + /** The Conversation a mute acts on; defaults to Prime's thread. */ + agentId?: string; + /** An agent's mute state was toggled in the active Conversation. */ + onToggleMute?: (toggle: EmbedMuteToggle) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded participant list as ``. The + * mute toggle mutates the shared session state directly; `onToggleMute` fires + * afterwards so the host can react. + */ +export function ParticipantList({ + sessionId, + agentId, + onToggleMute, + instance, + className, + style, +}: ParticipantListProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentParticipantListElementLike | null; + if (element) element.sessionId = sessionId; + }, [sessionId]); + + useEffect(() => { + const element = ref.current as TangentParticipantListElementLike | null; + if (element && agentId != null) element.agentId = agentId; + }, [agentId]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleToggle = (event: Event) => { + onToggleMute?.((event as CustomEvent).detail); + }; + element.addEventListener("toggle-mute", handleToggle); + return () => { + element.removeEventListener("toggle-mute", handleToggle); + }; + }, [onToggleMute]); + + return ( + + ); +} diff --git a/packages/embed-react/src/ResourceList.tsx b/packages/embed-react/src/ResourceList.tsx new file mode 100644 index 0000000..31733a0 --- /dev/null +++ b/packages/embed-react/src/ResourceList.tsx @@ -0,0 +1,64 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { EmbedResourceRow, TangentResourceListElementLike } from "./types"; + +export interface ResourceListProps { + /** The session whose catalogued resources to render. */ + sessionId: string; + /** Scopes the catalog to a sub-agent's Conversation; defaults to Prime's. */ + agentId?: string; + /** A viewable row was clicked; the host decides how to open the resource. */ + onOpen: (resource: EmbedResourceRow) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded resource list as ``. Wire `onOpen` + * to place an `` at the resource's resolved `url`; non-viewable + * kinds (memory, attachment) read as inert rows and never fire `onOpen`. + */ +export function ResourceList({ + sessionId, + agentId, + onOpen, + instance, + className, + style, +}: ResourceListProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentResourceListElementLike | null; + if (element) element.sessionId = sessionId; + }, [sessionId]); + + useEffect(() => { + const element = ref.current as TangentResourceListElementLike | null; + if (element && agentId != null) element.agentId = agentId; + }, [agentId]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleOpen = (event: Event) => { + onOpen((event as CustomEvent).detail); + }; + element.addEventListener("open-resource", handleOpen); + return () => { + element.removeEventListener("open-resource", handleOpen); + }; + }, [onOpen]); + + return ( + + ); +} diff --git a/packages/embed-react/src/SessionList.tsx b/packages/embed-react/src/SessionList.tsx new file mode 100644 index 0000000..3e67137 --- /dev/null +++ b/packages/embed-react/src/SessionList.tsx @@ -0,0 +1,64 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import type { TangentSessionListElementLike } from "./types"; + +export interface SessionListProps { + /** Highlighted row; scrolled into view when set. */ + selectedId?: string; + /** A row was clicked; the host decides what selecting a session means. */ + onSelect: (id: string) => void; + /** A session was deleted from its row menu. */ + onDeleted?: (id: string) => void; + /** Disambiguates the provider when a page mounts more than one. */ + instance?: string; + className?: string; + style?: CSSProperties; +} + +/** + * Renders the embedded session list as ``. The list + * fetches its own sessions; the host wires `onSelect` (and optionally + * `onDeleted`) and can highlight a row with `selectedId`. + */ +export function SessionList({ + selectedId, + onSelect, + onDeleted, + instance, + className, + style, +}: SessionListProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current as TangentSessionListElementLike | null; + if (element && selectedId != null) element.selectedId = selectedId; + }, [selectedId]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const handleSelect = (event: Event) => { + onSelect((event as CustomEvent<{ id: string }>).detail.id); + }; + const handleDeleted = (event: Event) => { + onDeleted?.((event as CustomEvent<{ id: string }>).detail.id); + }; + element.addEventListener("select-session", handleSelect); + element.addEventListener("session-deleted", handleDeleted); + return () => { + element.removeEventListener("select-session", handleSelect); + element.removeEventListener("session-deleted", handleDeleted); + }; + }, [onSelect, onDeleted]); + + return ( + + ); +} diff --git a/packages/embed-react/src/TangentProvider.tsx b/packages/embed-react/src/TangentProvider.tsx new file mode 100644 index 0000000..e4fc31e --- /dev/null +++ b/packages/embed-react/src/TangentProvider.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; + +import { TangentContext, type TangentContextValue } from "./context"; +import { defaultChannelUrl, loadEmbedRuntime } from "./loader"; +import type { ColorScheme, TangentProviderElementLike } from "./types"; + +export interface TangentProviderProps { + /** Tangent origin (optionally with a mount prefix), e.g. `https://tangent.example`. */ + baseUrl: string; + /** Overrides the runtime channel URL (defaults to `${baseUrl}/embed/v1/tangent-elements.js`). */ + channelUrl?: string; + /** Returns a bearer token for API/socket auth. May be sync or async. */ + getToken?: () => string | undefined | Promise; + /** `light`, `dark`, or `system`. Defaults to `light`. */ + colorScheme?: ColorScheme; + /** Unstable per-token overrides keyed by Tangent's internal names. */ + tokens?: Record; + /** Overrides the Socket.IO origin (defaults to same-origin as the API base). */ + socketUrl?: string; + /** Overrides the Socket.IO path (defaults to `/socket.io`). */ + socketPath?: string; + /** Disambiguates when a page mounts more than one provider. */ + instance?: string; + children?: ReactNode; +} + +/** + * Loads the Tangent embed runtime once, owns the shared configuration (API base, + * token getter, theme), and renders `` so descendant `` + * elements resolve the runtime. Children mount only after the runtime is ready. + */ +export function TangentProvider({ + baseUrl, + channelUrl, + getToken, + colorScheme, + tokens, + socketUrl, + socketPath, + instance, + children, +}: TangentProviderProps) { + const url = channelUrl ?? defaultChannelUrl(baseUrl); + const ref = useRef(null); + const [ready, setReady] = useState(false); + const [readyPromise] = useState(() => loadEmbedRuntime(url)); + + useEffect(() => { + let active = true; + void readyPromise.then(() => { + if (active) setReady(true); + }); + return () => { + active = false; + }; + }, [readyPromise]); + + useEffect(() => { + const element = ref.current as TangentProviderElementLike | null; + if (!element || !ready) return; + element.config = { apiBase: baseUrl, socketUrl, socketPath, getToken }; + }, [ready, baseUrl, socketUrl, socketPath, getToken]); + + useEffect(() => { + const element = ref.current as TangentProviderElementLike | null; + if (!element || !ready) return; + element.theme = { colorScheme, tokens }; + }, [ready, colorScheme, tokens]); + + const context: TangentContextValue = { + getProvider: () => ref.current as TangentProviderElementLike | null, + ready: readyPromise, + }; + + return ( + + + {ready ? children : null} + + + ); +} diff --git a/packages/embed-react/src/context.ts b/packages/embed-react/src/context.ts new file mode 100644 index 0000000..0183c27 --- /dev/null +++ b/packages/embed-react/src/context.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; + +import type { TangentProviderElementLike } from "./types"; + +export interface TangentContextValue { + /** The live `` element, or null before it mounts. */ + getProvider: () => TangentProviderElementLike | null; + /** Resolves once the runtime module has loaded and elements are registered. */ + ready: Promise; +} + +export const TangentContext = createContext(null); + +export function useTangentContext(): TangentContextValue { + const context = useContext(TangentContext); + if (!context) { + throw new Error("useTangent must be used within a "); + } + return context; +} diff --git a/packages/embed-react/src/index.ts b/packages/embed-react/src/index.ts new file mode 100644 index 0000000..489521e --- /dev/null +++ b/packages/embed-react/src/index.ts @@ -0,0 +1,24 @@ +export { AgentList, type AgentListProps } from "./AgentList"; +export { ArtifactViewer, type ArtifactViewerProps } from "./ArtifactViewer"; +export { AssetList, type AssetListProps } from "./AssetList"; +export { BundledUISlot, type BundledUISlotProps } from "./BundledUISlot"; +export { Chat, type ChatProps } from "./Chat"; +export { ParticipantList, type ParticipantListProps } from "./ParticipantList"; +export { ResourceList, type ResourceListProps } from "./ResourceList"; +export { SessionList, type SessionListProps } from "./SessionList"; +export { TangentProvider, type TangentProviderProps } from "./TangentProvider"; +export type { + ColorScheme, + EmbedAgent, + EmbedAsset, + EmbedMuteToggle, + EmbedResource, + EmbedResourceKind, + EmbedResourceRow, + HostResourceInput, + MessageDelivery, + NewSessionOptions, + NewSessionResult, + TangentThemeInputs, +} from "./types"; +export { type Tangent, useTangent } from "./useTangent"; diff --git a/packages/embed-react/src/loader.ts b/packages/embed-react/src/loader.ts new file mode 100644 index 0000000..4af42ee --- /dev/null +++ b/packages/embed-react/src/loader.ts @@ -0,0 +1,38 @@ +/** Protocol version range this wrapper understands. */ +const SUPPORTED_PROTOCOL = { min: 1, max: 1 }; + +const loaders = new Map>(); + +/** The default channel URL for the runtime bundle served from Tangent's origin. */ +export function defaultChannelUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/$/, "")}/embed/v1/tangent-elements.js`; +} + +/** + * Imports the remote runtime module once per URL (idempotent). The import + * registers the `tangent-*` custom elements as a side effect. The specifier is + * a runtime value so host bundlers do not try to resolve it at build time. + */ +export function loadEmbedRuntime(url: string): Promise { + const existing = loaders.get(url); + if (existing) return existing; + const loading = import(/* @vite-ignore */ /* webpackIgnore: true */ url).then( + () => warnOnProtocolMismatch(), + ); + loaders.set(url, loading); + return loading; +} + +function warnOnProtocolMismatch(): void { + const reported = ( + globalThis as { __TANGENT_EMBED__?: { protocolVersion?: number } } + ).__TANGENT_EMBED__?.protocolVersion; + if (reported == null) return; + if (reported < SUPPORTED_PROTOCOL.min || reported > SUPPORTED_PROTOCOL.max) { + console.warn( + `[tangent] embed runtime protocol v${reported} is outside the range ` + + `@tangent/embed-react supports (v${SUPPORTED_PROTOCOL.min}-v${SUPPORTED_PROTOCOL.max}). ` + + `Update the package to match the runtime.`, + ); + } +} diff --git a/packages/embed-react/src/types.ts b/packages/embed-react/src/types.ts new file mode 100644 index 0000000..7a5f42e --- /dev/null +++ b/packages/embed-react/src/types.ts @@ -0,0 +1,215 @@ +import type { DetailedHTMLProps, HTMLAttributes } from "react"; + +type TangentElementProps = DetailedHTMLProps< + HTMLAttributes & { instance?: string }, + HTMLElement +>; + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace -- JSX.IntrinsicElements can only be augmented via namespace + namespace JSX { + interface IntrinsicElements { + "tangent-provider": TangentElementProps; + "tangent-chat": TangentElementProps; + "tangent-session-list": TangentElementProps; + "tangent-artifact-viewer": TangentElementProps; + "tangent-bundled-ui": TangentElementProps; + "tangent-agent-list": TangentElementProps; + "tangent-asset-list": TangentElementProps; + "tangent-resource-list": TangentElementProps; + "tangent-participant-list": TangentElementProps; + } + } +} + +export type ColorScheme = "light" | "dark" | "system"; + +/** Delivery routing for a prompt, mirroring the server's `MessageDelivery`. */ +export type MessageDelivery = "auto" | "steer" | "followUp"; + +/** What a catalogued resource is, by origin. Mirrors the server's `ResourceKind`. */ +export type EmbedResourceKind = + | "file" + | "memory" + | "attachment" + | "artifact" + | "host"; + +/** + * A catalogued piece of content in a session, as returned to the host. The + * bytes live elsewhere (a workspace path or a `memory://` / host URI); this is + * the catalog entry pointing at them. + */ +export interface EmbedResource { + id: string; + sessionId: string; + kind: EmbedResourceKind; + name: string; + uri: string; + authorParticipantId?: string; + meta?: Record; + createdAt: string; +} + +/** + * A resource the host may seed at session create or add afterwards. A `memory` + * entry writes the session (or global) memory store the agent reads; a `host` + * entry is host-owned content (e.g. a known pipeline) whose `meta` is free-form + * JSON the shell does not interpret. + */ +export type HostResourceInput = + | { kind: "memory"; scope?: "session" | "global"; content: string } + | { + kind: "host"; + name: string; + uri: string; + meta?: Record; + }; + +/** Host-driven theme inputs forwarded to ``. */ +export interface TangentThemeInputs { + /** `system` follows the host's `prefers-color-scheme`. Defaults to `light`. */ + colorScheme?: ColorScheme; + /** + * One-off token overrides keyed by Tangent's internal custom-property names. + * Unstable escape hatch — pins the host to our token names. + */ + tokens?: Record; +} + +/** Options for {@link Tangent.newSession}. */ +export interface NewSessionOptions { + /** Session display name; the server defaults to `Session N` when omitted. */ + name?: string; + /** Initial model for Prime, applied after the socket joins. */ + model?: string; + /** Initial thinking depth for Prime (e.g. `off`, `low`, `medium`, `high`). */ + thinkingDepth?: string; + /** Delivery routing for the opening prompt. */ + delivery?: MessageDelivery; + /** Attachments to send with the opening prompt. */ + attachments?: unknown[]; + /** + * Resources to seed the session with. Applied server-side before the agent + * spawns, so memory seeds and host entries are standing context from the + * first turn. + */ + resources?: HostResourceInput[]; +} + +export interface NewSessionResult { + sessionId: string; +} + +/** The runtime surface `` exposes on its DOM element. */ +export interface EmbedRuntimeHandle { + newSession( + prompt: string, + bundleId: string, + options?: NewSessionOptions, + ): Promise; + listResources(sessionId: string): Promise; + addResource( + sessionId: string, + input: HostResourceInput, + ): Promise; + removeResource(sessionId: string, uri: string): Promise; +} + +export interface TangentProviderElementLike extends HTMLElement { + config: { + apiBase?: string; + socketUrl?: string; + socketPath?: string; + getToken?: () => string | undefined | Promise; + }; + theme: TangentThemeInputs; + runtime?: EmbedRuntimeHandle | null; +} + +export interface TangentChatElementLike extends HTMLElement { + sessionId: string; + agentId?: string; + initialPrompt?: string; +} + +export interface TangentSessionListElementLike extends HTMLElement { + selectedId: string; +} + +export interface TangentArtifactViewerElementLike extends HTMLElement { + sessionId: string; + url: string; + title: string; +} + +export interface TangentBundledUiElementLike extends HTMLElement { + moduleUrl: string; + kind: "message" | "panel"; + props?: Record; + stateNamespace?: string; +} + +export interface TangentAgentListElementLike extends HTMLElement { + sessionId: string; + selectedId: string; +} + +export interface TangentAssetListElementLike extends HTMLElement { + sessionId: string; + selectedId: string; +} + +export interface TangentResourceListElementLike extends HTMLElement { + sessionId: string; + agentId: string; +} + +export interface TangentParticipantListElementLike extends HTMLElement { + sessionId: string; + agentId: string; +} + +/** An agent row emitted by ``. */ +export interface EmbedAgent { + id: string; + name: string; + kind: "prime" | "subagent"; + status: string; + conversationId: string; +} + +/** A resource row emitted by ``. */ +export interface EmbedResourceRow { + id: string; + kind: EmbedResourceKind; + name: string; + uri: string; + /** The viewable file API url, resolved for a `file`/`artifact`. */ + url: string; + authorParticipantId?: string; +} + +/** A mute toggle emitted by ``. */ +export interface EmbedMuteToggle { + participantId: string; + conversationId: string; + muted: boolean; +} + +/** An asset row emitted by ``. */ +export type EmbedAsset = + | { + kind: "page" | "file"; + id: string; + title: string; + url: string; + path: string; + } + | { + kind: "trigger"; + id: string; + title: string; + triggerKind: string; + enabled: boolean; + }; diff --git a/packages/embed-react/src/useTangent.ts b/packages/embed-react/src/useTangent.ts new file mode 100644 index 0000000..1412271 --- /dev/null +++ b/packages/embed-react/src/useTangent.ts @@ -0,0 +1,80 @@ +import { useTangentContext } from "./context"; +import type { + EmbedResource, + EmbedRuntimeHandle, + HostResourceInput, + NewSessionOptions, + NewSessionResult, +} from "./types"; + +export interface Tangent { + /** + * Creates a session from a bundle and queues an opening prompt. Resolves with + * the new `sessionId` — render `` to show it. The + * prompt is sent by the chat once it joins, so it never races Prime. Pass + * `options.resources` to seed memory or host entries before the agent spawns. + */ + newSession( + prompt: string, + bundleId: string, + options?: NewSessionOptions, + ): Promise; + /** Lists the session's catalogued resources (memory, host entries, files). */ + listResources(sessionId: string): Promise; + /** + * Adds one resource to a session: a memory write (writes the store the agent + * reads) or a host entry (e.g. a known pipeline). Returns the stored resource; + * re-adding the same `uri` updates it in place. + */ + addResource( + sessionId: string, + input: HostResourceInput, + ): Promise; + /** Removes a resource by its `uri`. Removing a memory store clears it. */ + removeResource(sessionId: string, uri: string): Promise; +} + +/** Resolves the ready runtime handle, or throws a clear error if unavailable. */ +async function useRuntime( + ready: Promise, + getProvider: () => { runtime?: EmbedRuntimeHandle | null } | null, +): Promise { + await ready; + const runtime = getProvider()?.runtime; + if (!runtime) { + throw new Error("Tangent runtime is not ready"); + } + if (typeof runtime.addResource !== "function") { + throw new Error( + "This Tangent runtime does not support resources. Update the served embed runtime.", + ); + } + return runtime; +} + +/** Access the Tangent runtime API (session + resource operations). */ +export function useTangent(): Tangent { + const context = useTangentContext(); + return { + async newSession(prompt, bundleId, options) { + await context.ready; + const runtime = context.getProvider()?.runtime; + if (!runtime) { + throw new Error("Tangent runtime is not ready"); + } + return runtime.newSession(prompt, bundleId, options); + }, + async listResources(sessionId) { + const runtime = await useRuntime(context.ready, context.getProvider); + return runtime.listResources(sessionId); + }, + async addResource(sessionId, input) { + const runtime = await useRuntime(context.ready, context.getProvider); + return runtime.addResource(sessionId, input); + }, + async removeResource(sessionId, uri) { + const runtime = await useRuntime(context.ready, context.getProvider); + return runtime.removeResource(sessionId, uri); + }, + }; +} diff --git a/packages/embed-react/tsconfig.json b/packages/embed-react/tsconfig.json new file mode 100644 index 0000000..3c05c72 --- /dev/null +++ b/packages/embed-react/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tangent/build/tsconfig/react.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "ignoreDeprecations": "6.0" + }, + "include": ["src"] +} diff --git a/packages/embed-react/tsup.config.ts b/packages/embed-react/tsup.config.ts new file mode 100644 index 0000000..226df32 --- /dev/null +++ b/packages/embed-react/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +// The wrapper is deliberately tiny: types and glue, no Tangent UI, CSS, Radix, +// or TanStack. React stays external (the host provides it). A small diff here is +// what makes "the host rarely redeploys" credible. +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: ["react", "react-dom"], +}); diff --git a/packages/remote-subagent/README.md b/packages/remote-subagent/README.md index 7fb1d85..6ad19f9 100644 --- a/packages/remote-subagent/README.md +++ b/packages/remote-subagent/README.md @@ -66,6 +66,45 @@ const client = connectRemoteEnvironment({ const messages = await client.readRoom(sessionId, 30); ``` +## Remote tools (RPC, no sub-agent) + +An environment can also offer **remote tools**: named async functions the +session's agents (Prime and local sub-agents) call directly over this same +connection, without hosting a browser agent. Register them with `tools`; +`handlers` stay optional, so an environment may host tools, sub-agents, or both. + +```ts +const client = connectRemoteEnvironment({ + url: "http://localhost:8787", + token, + environmentId, + sessionId, // the session these tools are registered for + tools: { + get_pipeline_state: { + description: "Get the current pipeline spec as JSON.", + inputSchema: { type: "object", properties: {} }, + execute: async () => JSON.stringify(getSpec()), + }, + add_task: { + description: "Add a task node to the pipeline.", + inputSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + execute: async (args) => addTask(args), + }, + }, +}); +``` + +The catalog is registered on connect and re-registered on reconnect; the server +drops it when the socket falls. Agents discover it with `list_remote_tools` and +invoke it with `call_remote_tool` — the server routes each call here and hands +the result (any JSON-serializable value) back to the agent that asked. The +runtime serializes nothing for you: if two calls can race a shared mutable state, +serialize them in your `execute`. + ## Outbound helpers - `client.agentEvent(sessionId, agentId, event)` — stream a single agent event diff --git a/packages/remote-subagent/package.json b/packages/remote-subagent/package.json index b8a42c0..cb5828d 100644 --- a/packages/remote-subagent/package.json +++ b/packages/remote-subagent/package.json @@ -11,6 +11,7 @@ "prettier": "@tangent/build/prettier", "scripts": { "lint": "eslint .", + "test": "tsx --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit", "format": "prettier --write ." }, @@ -22,6 +23,7 @@ "@tangent/build": "workspace:*", "@types/node": "^25.9.1", "eslint": "^10.4.0", + "tsx": "^4.22.3", "typescript": "^6.0.3" } } diff --git a/packages/remote-subagent/src/index.test.ts b/packages/remote-subagent/src/index.test.ts new file mode 100644 index 0000000..842c827 --- /dev/null +++ b/packages/remote-subagent/src/index.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildRemoteEnvConnectArgs } from "./index.ts"; + +const base = { + token: "env-token", + environmentId: "env-1", +} as const; + +test("forwards socketPath as the transport path when set", () => { + const { uri, opts } = buildRemoteEnvConnectArgs({ + ...base, + url: "https://host", + socketPath: "/tangent/socket.io", + }); + + assert.equal(uri, "https://host/remote-env"); + assert.equal(opts.path, "/tangent/socket.io"); +}); + +test("omits path when socketPath is unset, preserving the default", () => { + const { opts } = buildRemoteEnvConnectArgs({ ...base, url: "https://host" }); + + assert.equal("path" in opts, false); +}); + +test("always requests the websocket transport and forwards auth", () => { + const { opts } = buildRemoteEnvConnectArgs({ ...base, url: "https://host" }); + + assert.deepEqual(opts.transports, ["websocket"]); + assert.deepEqual(opts.auth, { + token: "env-token", + environmentId: "env-1", + }); +}); diff --git a/packages/remote-subagent/src/index.ts b/packages/remote-subagent/src/index.ts index 473c87c..c42f46c 100644 --- a/packages/remote-subagent/src/index.ts +++ b/packages/remote-subagent/src/index.ts @@ -30,8 +30,17 @@ import { type RemoteRoomReadResponse, type RemoteSpawnCommand, type RemoteSubagentUpdatePayload, + type RemoteToolCallRequest, + type RemoteToolCallResponse, + type RemoteToolDef, + type RemoteToolsRegisterPayload, } from "@tangent/shared/remoteSubagent.ts"; -import { io, type Socket } from "socket.io-client"; +import { + io, + type ManagerOptions, + type Socket, + type SocketOptions, +} from "socket.io-client"; /** How long {@link RemoteEnvironmentClient.readRoom} waits for the server ack. */ const READ_ROOM_TIMEOUT_MS = 10_000; @@ -50,16 +59,48 @@ export interface RemoteEnvironmentHandlers { onKill(command: RemoteKillCommand): void | Promise; } +/** + * One RPC tool this environment hosts: a named async function the server can + * invoke on behalf of an agent, without spawning a sub-agent. The `description` + * and JSON `inputSchema` are advertised to agents; `execute` runs the call and + * returns any JSON-serializable value (typically a string). + */ +export interface RemoteTool { + description: string; + inputSchema: Record; + execute(args: unknown): unknown | Promise; +} + +/** The tool catalog an environment offers, keyed by tool name. */ +export type RemoteToolMap = Record; + /** Options for {@link connectRemoteEnvironment}. */ export interface ConnectRemoteEnvironmentOptions { /** Base server URL, e.g. `http://localhost:8787` (namespace is appended). */ url: string; + /** + * engine.io transport path for mounted-prefix deployments + * (e.g. `/tangent/socket.io`); defaults to Socket.IO's `/socket.io`. + */ + socketPath?: string; /** Shared bearer token the server validates against `REMOTE_ENV_TOKEN`. */ token: string; /** Stable id identifying this environment when several are connected. */ environmentId: string; /** Command handlers; any omitted handler throws when its command arrives. */ handlers?: Partial; + /** + * RPC tools this environment offers. Registered on connect and re-registered + * on reconnect. Independent of {@link handlers}: an environment may host tools, + * sub-agents, or both. Omit to host no tools. + */ + tools?: RemoteToolMap; + /** + * The session this environment's {@link tools} are registered for. Required + * when `tools` is set: a scoped embed host knows its session, and the server + * only accepts a catalog matching the socket's bound session. + */ + sessionId?: string; } /** @@ -132,6 +173,29 @@ function normalizeUrl(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; } +/** + * Builds the `io()` connection arguments from the connect options. Forwards an + * explicit `path` only when {@link ConnectRemoteEnvironmentOptions.socketPath} + * is set, so a mounted-prefix deployment reaches the right transport path while + * the default `/socket.io` behavior is untouched otherwise. + */ +export function buildRemoteEnvConnectArgs( + options: ConnectRemoteEnvironmentOptions, +): { uri: string; opts: Partial } { + const auth: RemoteEnvHandshake = { + token: options.token, + environmentId: options.environmentId, + }; + return { + uri: `${normalizeUrl(options.url)}${REMOTE_ENV_NAMESPACE}`, + opts: { + auth, + transports: ["websocket"], + ...(options.socketPath ? { path: options.socketPath } : {}), + }, + }; +} + /** Wires the inbound command listeners onto the socket. */ function registerCommandHandlers( socket: Socket, @@ -148,6 +212,68 @@ function registerCommandHandlers( }); } +/** Projects a tool map onto the wire catalog the server advertises. */ +function toolCatalog(tools: RemoteToolMap): RemoteToolDef[] { + return Object.entries(tools).map(([name, tool]) => ({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + })); +} + +/** + * Wires the tool-call listener and (re)registers the catalog on every connect, + * so a reconnect re-declares the tools the server dropped when the socket fell. + * No-op when the environment hosts no tools. + */ +function registerToolHost( + socket: Socket, + tools: RemoteToolMap | undefined, + sessionId: string | undefined, +): void { + if (!tools || Object.keys(tools).length === 0) return; + + const announce = (): void => { + const payload: RemoteToolsRegisterPayload = { + sessionId: sessionId ?? "", + tools: toolCatalog(tools), + }; + socket.emit(RemoteEnvEvents.ToolsRegister, payload); + }; + socket.on("connect", announce); + if (socket.connected) announce(); + + socket.on( + RemoteEnvEvents.ToolsCall, + ( + request: RemoteToolCallRequest, + callback: (response: RemoteToolCallResponse) => void, + ) => { + void runToolCall(tools, request, callback); + }, + ); +} + +/** Runs one tool call and acks its result, turning a throw into an error ack. */ +async function runToolCall( + tools: RemoteToolMap, + request: RemoteToolCallRequest, + callback: (response: RemoteToolCallResponse) => void, +): Promise { + const tool = tools[request.name]; + if (!tool) { + callback({ ok: false, error: `Unknown tool: ${request.name}` }); + return; + } + try { + const result = await tool.execute(request.arguments); + callback({ ok: true, result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + callback({ ok: false, error: message }); + } +} + /** * Connects to the server's remote sub-agent gateway and returns a client. The * connection authenticates with the supplied token/environmentId; inbound @@ -158,16 +284,11 @@ export function connectRemoteEnvironment( options: ConnectRemoteEnvironmentOptions, ): RemoteEnvironmentClient { const handlers = withDefaultHandlers(options.handlers ?? {}); - const auth: RemoteEnvHandshake = { - token: options.token, - environmentId: options.environmentId, - }; - const socket = io(`${normalizeUrl(options.url)}${REMOTE_ENV_NAMESPACE}`, { - auth, - transports: ["websocket"], - }); + const { uri, opts } = buildRemoteEnvConnectArgs(options); + const socket = io(uri, opts); registerCommandHandlers(socket, handlers); + registerToolHost(socket, options.tools, options.sessionId); return { socket, @@ -221,4 +342,7 @@ export type { RemoteKillCommand, RemoteMessageCommand, RemoteSpawnCommand, + RemoteToolCallRequest, + RemoteToolCallResponse, + RemoteToolDef, } from "@tangent/shared/remoteSubagent.ts"; diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index c65473c..c22efb4 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -108,6 +108,18 @@ export interface Session { updatedAt: string; } +/** Body of `POST /api/embed/remote-env-token`. */ +export interface RemoteEnvTokenRequest { + sessionId: string; +} + +/** Response of `POST /api/embed/remote-env-token`. */ +export interface RemoteEnvTokenResponse { + token: string; + environmentId: string; + expiresAt: string; +} + /** * Distinguishes the session's orchestrating Prime agent from the sub-agents it * spawns. Only present on agent authors. Drives client rendering (e.g. sub-agent @@ -370,7 +382,12 @@ export interface PinnedArtifact { } /** What a catalogued resource is: content the session holds, by origin. */ -export type ResourceKind = "file" | "memory" | "attachment" | "artifact"; +export type ResourceKind = + | "file" + | "memory" + | "attachment" + | "artifact" + | "host"; /** * A catalogued piece of content in a session — a pinned `artifact`, a human @@ -402,6 +419,26 @@ export interface ListResourcesResponse { resources: Resource[]; } +/** + * A resource the embed host may seed at session create or add afterwards. A + * `memory` entry writes the session (or global) memory store the agent reads; a + * `host` entry is host-owned content (e.g. a known pipeline) whose `meta` is + * free-form JSON the shell does not interpret. + */ +export type HostResourceInput = + | { kind: "memory"; scope?: MemoryScope; content: string } + | { + kind: "host"; + name: string; + uri: string; + meta?: Record; + }; + +/** Response from `POST /api/sessions/:id/resources`: the stored resource. */ +export interface AddResourceResponse { + resource: Resource; +} + /** Response from `GET /api/sessions/:id/triggers`. */ export interface ListTriggersResponse { triggers: Trigger[]; @@ -507,15 +544,18 @@ export type SpawnAuthority = "server" | "remote-env" | "bundle-tool" | "none"; * in issuance — one is handed to a process the server spawns, the other is * presented back by a caller that already holds it. * - * `peer-bearer` runs the other way: the far end sits outside the trust domain - * and Tangent is the caller, so the secret is presented outbound and no inbound - * caller ever authenticates under this scheme. + * `scoped-token` is a short-lived HMAC token minted per session for an embed + * host connecting as a remote environment. `peer-bearer` runs the other way: + * the far end sits outside the trust domain and Tangent is the caller, so the + * secret is presented outbound and no inbound caller ever authenticates under + * this scheme. */ export type CredentialScheme = | "inherited-token" | "shared-token" | "internal-bearer" | "minted-secret" + | "scoped-token" | "peer-bearer" | "none"; @@ -959,6 +999,11 @@ export interface CreateSessionRequest { name?: string; /** Marketplace agent bundle id to create the session from. */ bundleId: string; + /** + * Resources to seed the session with, applied before the agent spawns so + * memory seeds and host entries are standing context from the first turn. + */ + resources?: HostResourceInput[]; } export interface UpdateSessionRequest { @@ -1343,6 +1388,7 @@ export const SocketEvents = { TriggerRemoved: "trigger:removed", ArtifactPin: "artifact:pin", ArtifactUnpin: "artifact:unpin", + ResourcesUpdated: "resources:updated", UiCommand: "ui:command", SessionStatusSubscribe: "session:status:subscribe", SessionStatusSnapshot: "session:status:snapshot", diff --git a/packages/shared/src/remoteSubagent.ts b/packages/shared/src/remoteSubagent.ts index c82c4b2..50c6561 100644 --- a/packages/shared/src/remoteSubagent.ts +++ b/packages/shared/src/remoteSubagent.ts @@ -57,6 +57,10 @@ export const RemoteEnvEvents = { AgentMessage: "remote:agent-message", /** remote -> server (ack): read the shared session transcript. */ RoomRead: "remote:room:read", + /** remote -> server: (re)declare the tool catalog this environment offers. */ + ToolsRegister: "remote:tools:register", + /** server -> remote (ack): invoke one registered tool and await its result. */ + ToolsCall: "remote:tools:call", } as const; export type RemoteEnvEvent = @@ -172,3 +176,50 @@ export interface RemoteRoomReadRequest { export interface RemoteRoomReadResponse { messages: ChatMessage[]; } + +/** + * One tool a remote environment offers. It is a named async function the + * environment implements, not a participant: the server routes a call to the + * environment and hands the result back to the agent that asked, without a + * Conversation, roster entry, or second LLM. `inputSchema` is JSON Schema so an + * agent's tool runtime can validate arguments before the call. + */ +export interface RemoteToolDef { + name: string; + description: string; + inputSchema: Record; +} + +/** + * remote -> server: the full tool catalog this environment offers for a session. + * Idempotent and total — each register replaces the environment's prior catalog, + * and an empty `tools` clears it. Disconnecting drops it entirely. + */ +export interface RemoteToolsRegisterPayload { + sessionId: string; + tools: RemoteToolDef[]; +} + +/** + * server -> remote (ack request): invoke one registered tool. `agentId` names + * the agent (Prime or a local sub-agent) that called it, so the environment can + * attribute or scope the work; `callId` correlates the ack. + */ +export interface RemoteToolCallRequest { + callId: string; + sessionId: string; + agentId: string; + name: string; + arguments: unknown; +} + +/** + * remote -> server (ack response): the outcome of one tool call. `result` is any + * JSON-serializable value (typically a string); `error` is set instead when the + * environment could not run the tool. + */ +export interface RemoteToolCallResponse { + ok: boolean; + result?: unknown; + error?: string; +} diff --git a/packages/ui-primitives/src/dropdown-menu.tsx b/packages/ui-primitives/src/dropdown-menu.tsx index b48aea8..ed39a35 100644 --- a/packages/ui-primitives/src/dropdown-menu.tsx +++ b/packages/ui-primitives/src/dropdown-menu.tsx @@ -3,6 +3,7 @@ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import * as React from "react"; +import { usePortalContainer } from "./portal-container"; import { cn } from "./utils"; function DropdownMenu({ @@ -28,8 +29,9 @@ function DropdownMenuContent({ align = "end", ...props }: React.ComponentProps) { + const portalContainer = usePortalContainer(); return ( - + ) { + const portalContainer = usePortalContainer(); return ( - + (null); + +export function usePortalContainer(): HTMLElement | null { + return useContext(PortalContainerContext); +} diff --git a/packages/ui-primitives/src/tooltip.tsx b/packages/ui-primitives/src/tooltip.tsx index 3c42d6c..57008fd 100644 --- a/packages/ui-primitives/src/tooltip.tsx +++ b/packages/ui-primitives/src/tooltip.tsx @@ -5,6 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import type { PropsWithChildren, ReactNode } from "react"; import * as React from "react"; +import { usePortalContainer } from "./portal-container"; import { cn } from "./utils"; function TooltipProvider({ @@ -50,8 +51,9 @@ function TooltipContent({ arrowClassName, ...props }: TooltipContentProps) { + const portalContainer = usePortalContainer(); return ( - + =18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.0': resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz} engines: {node: '>=18'} @@ -682,6 +718,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.0': resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -700,6 +742,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.0': resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz} engines: {node: '>=18'} @@ -718,6 +766,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.0': resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -736,6 +790,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.0': resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -754,6 +814,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.0': resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -772,6 +838,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -790,6 +862,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -808,6 +886,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.0': resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -826,6 +910,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.0': resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz} engines: {node: '>=18'} @@ -844,6 +934,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.0': resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz} engines: {node: '>=18'} @@ -862,6 +958,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.0': resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz} engines: {node: '>=18'} @@ -880,6 +982,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.0': resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz} engines: {node: '>=18'} @@ -898,6 +1006,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.0': resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz} engines: {node: '>=18'} @@ -916,6 +1030,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.0': resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz} engines: {node: '>=18'} @@ -934,6 +1054,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.0': resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz} engines: {node: '>=18'} @@ -952,6 +1078,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.0': resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -964,6 +1096,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -982,6 +1120,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -994,6 +1138,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -1012,6 +1162,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -1024,6 +1180,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -1042,6 +1204,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.0': resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -1060,6 +1228,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.0': resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz} engines: {node: '>=18'} @@ -1078,6 +1252,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.0': resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz} engines: {node: '>=18'} @@ -1096,6 +1276,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.0': resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz} engines: {node: '>=18'} @@ -1192,6 +1378,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz} peerDependencies: @@ -2082,6 +2275,144 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==, tarball: https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz} + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz} + cpu: [x64] + os: [win32] + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==, tarball: https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz} @@ -2478,6 +2809,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, tarball: https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, tarball: https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==, tarball: https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz} @@ -2540,6 +2874,12 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==, tarball: https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz} engines: {node: '>=18'} + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==, tarball: https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==, tarball: https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz} engines: {node: '>=10.16.0'} @@ -2548,6 +2888,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, tarball: https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, tarball: https://registry.npmjs.org/cac/-/cac-6.7.14.tgz} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, tarball: https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz} engines: {node: '>= 0.4'} @@ -2574,6 +2918,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==, tarball: https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz} + engines: {node: '>= 14.16.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==, tarball: https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz} @@ -2587,10 +2935,21 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==, tarball: https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==, tarball: https://registry.npmjs.org/commander/-/commander-4.1.1.tgz} + engines: {node: '>= 6'} + concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==, tarball: https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz} engines: {'0': node >= 6.0} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==, tarball: https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, tarball: https://registry.npmjs.org/consola/-/consola-3.4.2.tgz} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==, tarball: https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz} engines: {node: '>=18'} @@ -2835,6 +3194,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz} engines: {node: '>=18'} @@ -2967,6 +3331,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz} engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==, tarball: https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, tarball: https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz} engines: {node: '>=16'} @@ -3160,6 +3527,10 @@ packages: jose@6.2.8: resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==, tarball: https://registry.npmjs.org/jose/-/jose-6.2.8.tgz} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==, tarball: https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} @@ -3263,6 +3634,17 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==, tarball: https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz} engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, tarball: https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==, tarball: https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz} engines: {node: '>=10'} @@ -3459,6 +3841,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==, tarball: https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==, tarball: https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz} + mobx-react-lite@4.1.1: resolution: {integrity: sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==, tarball: https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz} peerDependencies: @@ -3482,6 +3867,9 @@ packages: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==, tarball: https://registry.npmjs.org/multer/-/multer-2.1.1.tgz} engines: {node: '>= 10.16.0'} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==, tarball: https://registry.npmjs.org/mz/-/mz-2.7.0.tgz} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3558,6 +3946,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} @@ -3565,6 +3956,31 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz} engines: {node: '>=12'} + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==, tarball: https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==, tarball: https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==, tarball: https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz} engines: {node: ^10 || ^12 || >=14} @@ -3695,6 +4111,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz} engines: {node: '>= 6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz} + engines: {node: '>= 14.18.0'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz} @@ -3707,6 +4127,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==, tarball: https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, tarball: https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} @@ -3715,6 +4139,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, tarball: https://registry.npmjs.org/router/-/router-2.2.0.tgz} engines: {node: '>= 18'} @@ -3818,6 +4247,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, tarball: https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==, tarball: https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==, tarball: https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz} @@ -3845,6 +4278,11 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==, tarball: https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==, tarball: https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz} @@ -3862,6 +4300,16 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==, tarball: https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz} engines: {node: '>=6'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==, tarball: https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, tarball: https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz} engines: {node: '>=12.0.0'} @@ -3870,6 +4318,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, tarball: https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz} engines: {node: '>=0.6'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==, tarball: https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz} + hasBin: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==, tarball: https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz} @@ -3882,9 +4334,31 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==, tarball: https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==, tarball: https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + tsx@4.22.3: resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==, tarball: https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz} engines: {node: '>=18.0.0'} @@ -3927,6 +4401,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==, tarball: https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz} @@ -4327,6 +4804,9 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/aix-ppc64@0.28.0': optional: true @@ -4336,6 +4816,9 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm64@0.28.0': optional: true @@ -4345,6 +4828,9 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-arm@0.28.0': optional: true @@ -4354,6 +4840,9 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/android-x64@0.28.0': optional: true @@ -4363,6 +4852,9 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.28.0': optional: true @@ -4372,6 +4864,9 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.28.0': optional: true @@ -4381,6 +4876,9 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.28.0': optional: true @@ -4390,6 +4888,9 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.28.0': optional: true @@ -4399,6 +4900,9 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.28.0': optional: true @@ -4408,6 +4912,9 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-arm@0.28.0': optional: true @@ -4417,6 +4924,9 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-ia32@0.28.0': optional: true @@ -4426,6 +4936,9 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-loong64@0.28.0': optional: true @@ -4435,6 +4948,9 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.28.0': optional: true @@ -4444,6 +4960,9 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.28.0': optional: true @@ -4453,6 +4972,9 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.28.0': optional: true @@ -4462,6 +4984,9 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-s390x@0.28.0': optional: true @@ -4471,12 +4996,18 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/linux-x64@0.28.0': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.28.0': optional: true @@ -4486,12 +5017,18 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.28.0': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.28.0': optional: true @@ -4501,12 +5038,18 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.28.0': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.28.0': optional: true @@ -4516,6 +5059,9 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.28.0': optional: true @@ -4525,6 +5071,9 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.28.0': optional: true @@ -4534,6 +5083,9 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-ia32@0.28.0': optional: true @@ -4543,6 +5095,9 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@esbuild/win32-x64@0.28.0': optional: true @@ -4632,6 +5187,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5504,6 +6062,81 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + '@socket.io/component-emitter@3.1.2': {} '@tailwindcss/node@4.3.0': @@ -5911,6 +6544,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + any-promise@1.3.0: {} + append-field@1.0.0: {} aria-hidden@1.2.6: @@ -5983,12 +6618,19 @@ snapshots: dependencies: run-applescript: 7.1.0 + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6011,6 +6653,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chownr@1.1.4: {} class-variance-authority@0.7.1: @@ -6021,6 +6667,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@4.1.1: {} + concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -6028,6 +6676,10 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.1.8: {} + + consola@3.4.2: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -6220,6 +6872,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -6417,6 +7098,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.4 + flat-cache@4.0.1: dependencies: flatted: 3.4.2 @@ -6588,6 +7275,8 @@ snapshots: jose@6.2.8: {} + joycon@3.1.1: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -6658,6 +7347,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -7052,6 +7747,13 @@ snapshots: mkdirp-classic@0.5.3: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + mobx-react-lite@4.1.1(mobx@6.16.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: mobx: 6.16.1 @@ -7071,6 +7773,12 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} @@ -7143,10 +7851,29 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.7.0 + postcss: 8.5.15 + tsx: 4.22.3 + yaml: 2.9.0 + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -7341,6 +8068,8 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@4.1.2: {} + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7375,6 +8104,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} rolldown@1.0.2: @@ -7398,6 +8129,38 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.2 '@rolldown/binding-win32-x64-msvc': 1.0.2 + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -7545,6 +8308,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} statuses@2.0.2: {} @@ -7570,6 +8335,16 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + tailwind-merge@3.6.0: {} tailwindcss@4.3.0: {} @@ -7591,6 +8366,16 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -7598,6 +8383,8 @@ snapshots: toidentifier@1.0.1: {} + tree-kill@1.2.2: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -7606,8 +8393,38 @@ snapshots: dependencies: typescript: 6.0.3 + ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.15 + typescript: 6.0.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.22.3: dependencies: esbuild: 0.28.0 @@ -7659,6 +8476,8 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + undici-types@7.24.6: {} unified@11.0.5: