diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 76d27209..890966b4 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -390,6 +390,7 @@ app.use( correlations, context: contextEngine, }, + remoteGateway, ), ); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts index a9004eca..7ff25017 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; +import { + connectorFor, + SocketEvents, + type SubagentInfo, +} from "@tangent/shared/contracts.ts"; import { type RemoteAgentEventPayload, RemoteEnvEvents, @@ -141,8 +145,14 @@ function makeHarness(options: HarnessOptions = {}) { const store = new InMemorySessionStore(); const runStore = new InMemoryRunStore(); const runs = new RunRegistry(runStore); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + const io = { + of: () => namespace, + emit: (event: string, payload: unknown) => + broadcasts.push({ event, payload }), + }; const gateway = new RemoteEnvironmentGateway( - { of: () => namespace } as unknown as SocketIOServer, + io as unknown as SocketIOServer, relayHandlers(rosterUpdates, agentEvents), store, runs, @@ -158,6 +168,7 @@ function makeHarness(options: HarnessOptions = {}) { runStore, rosterUpdates, agentEvents, + broadcasts, }; } @@ -856,3 +867,81 @@ test("an environmentId stays claimed by its owner after they disconnect", () => "Alice can still reclaim her own id", ); }); + +test("listEnvironments lists a session's scoped hosts and its dev hosts, newest first", () => { + const scoped = new ScopedTokenCredential(SCOPED_SECRET); + const h = makeHarness({ scoped }); + const alice = h.connect("x", { + token: mintScoped(scoped, "env-a", "s1", "alice@x"), + }); + h.connect("vscode-generic"); + h.connect("x", { token: mintScoped(scoped, "env-b", "s2", "bob@x") }); + + alice.send(RemoteEnvEvents.ToolsRegister, { + sessionId: "s1", + tools: [ECHO_TOOL], + }); + h.gateway.spawnSubagent("s1", { + name: "Worker", + audienceParticipantId: "alice@x", + }); + + assert.deepEqual(h.gateway.listEnvironments("s1"), [ + { + environmentId: "vscode-generic", + sessionId: undefined, + owner: undefined, + scoped: false, + toolCount: 0, + subagentCount: 0, + }, + { + environmentId: "env-a", + sessionId: "s1", + owner: "alice@x", + scoped: true, + toolCount: 1, + subagentCount: 1, + }, + ]); + assert.deepEqual( + h.gateway.listEnvironments("s2").map((e) => e.environmentId), + ["env-b", "vscode-generic"], + ); +}); + +test("a session-pinned dev host replaces the generic one for its session only", () => { + const h = makeHarness(); + h.connect("s1:vscode-1"); + h.connect("vscode-generic"); + + assert.deepEqual( + h.gateway.listEnvironments("s1").map((e) => e.environmentId), + ["s1:vscode-1"], + ); + assert.deepEqual( + h.gateway.listEnvironments("s2").map((e) => e.environmentId), + ["vscode-generic"], + ); +}); + +test("environment changes are broadcast and reflected in the list", async () => { + const h = makeHarness(); + const updates = () => + h.broadcasts.filter((b) => b.event === SocketEvents.EnvironmentUpdate) + .length; + + const env = h.connect("env-1"); + await flush(); + assert.equal(updates(), 1, "connect"); + + const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" }); + assert.equal(h.gateway.listEnvironments("s1")[0].subagentCount, 1); + h.gateway.killAgent("s1", info.id); + assert.equal(h.gateway.listEnvironments("s1")[0].subagentCount, 0); + assert.equal(updates(), 3, "spawn and kill"); + + env.disconnect(); + assert.equal(updates(), 4, "disconnect"); + assert.deepEqual(h.gateway.listEnvironments("s1"), []); +}); diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index 40331738..e20e4c6b 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -3,11 +3,14 @@ import path from "node:path"; import { connectorFor, + type EnvironmentUpdatePayload, isTerminalStatus, type MessageDelivery, + type RemoteEnvironmentInfo, RESTORABLE_STATUSES, type RunId, type RunIngress, + SocketEvents, type SubagentInfo, type SubagentStatus, } from "@tangent/shared/contracts.ts"; @@ -244,6 +247,31 @@ export class RemoteEnvironmentGateway { return [...roster.values()].map(toInfo); } + /** + * The connected environments `sessionId` can route to: its scoped hosts plus + * the unscoped dev hosts eligible for it, most recent first. + */ + listEnvironments(sessionId: string): RemoteEnvironmentInfo[] { + const roster = [...(this.sessions.get(sessionId)?.values() ?? [])]; + return [ + ...this.scopedEnvironments(sessionId), + ...this.unscopedEnvironmentsFor(sessionId), + ] + .sort((a, b) => b.seq - a.seq) + .map((environment) => ({ + environmentId: environment.environmentId, + sessionId: environment.sessionId, + owner: environment.sub, + scoped: Boolean(environment.sessionId), + toolCount: environment.tools.size, + subagentCount: roster.filter( + (subagent) => + subagent.environmentId === environment.environmentId && + !isTerminalStatus(subagent.status), + ).length, + })); + } + /** * Spawns a sub-agent on a connected remote environment. Resolves the * effective config from the session's installed bundle templates/defaults @@ -304,6 +332,7 @@ export class RemoteEnvironmentGateway { const info = toInfo(subagent); this.handlers.onSubagentUpdate(sessionId, info); + this.emitEnvironmentsChanged(sessionId); return { info, tools, @@ -412,6 +441,13 @@ export class RemoteEnvironmentGateway { completed, } satisfies RemoteKillCommand); this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + this.emitEnvironmentsChanged(sessionId); + } + + /** Tells every client that the connected environments changed. */ + private emitEnvironmentsChanged(sessionId?: string): void { + const payload: EnvironmentUpdatePayload = sessionId ? { sessionId } : {}; + this.io.emit(SocketEvents.EnvironmentUpdate, payload); } /** Emits an event to a specific environment's socket, if still connected. */ @@ -691,6 +727,7 @@ export class RemoteEnvironmentGateway { console.log( `[remote-env] ${environmentId} registered ${environment.tools.size} tool(s)`, ); + this.emitEnvironmentsChanged(); } /** @@ -792,6 +829,7 @@ export class RemoteEnvironmentGateway { if (!RESTORABLE_STATUSES.includes(agent.status)) continue; this.reattach(agent.sessionId, agent); } + this.emitEnvironmentsChanged(); } /** @@ -849,6 +887,7 @@ export class RemoteEnvironmentGateway { payload.status === "completed" ? "completed" : "failed", ); this.clearActivity(payload.sessionId, subagent); + this.emitEnvironmentsChanged(payload.sessionId); } this.handlers.onSubagentUpdate(payload.sessionId, toInfo(subagent)); } @@ -934,6 +973,7 @@ export class RemoteEnvironmentGateway { console.log( `[remote-env] disconnected: ${environmentId} session=${dropped?.sessionId ?? "unscoped"} socket=${dropped?.socket.id}`, ); + this.emitEnvironmentsChanged(); } /** diff --git a/apps/server/src/routes/sessions/environments.ts b/apps/server/src/routes/sessions/environments.ts new file mode 100644 index 00000000..025d4361 --- /dev/null +++ b/apps/server/src/routes/sessions/environments.ts @@ -0,0 +1,49 @@ +import type { ListEnvironmentsResponse } from "@tangent/shared/contracts.ts"; +import { type Request, type Response, Router } from "express"; + +import { getValidated, validate } from "../../middleware/validate.ts"; +import type { RemoteEnvironmentGateway } from "../../remote/remoteEnvironmentGateway.ts"; +import type { SessionStore } from "../../store/sessionStore.ts"; +import type { SessionParams } from "./schemas.ts"; +import { sessionParamsSchema } from "./schemas.ts"; +import { loadSession } from "./utils.ts"; + +/** The slice of the remote gateway the environments route reads. */ +export type EnvironmentLister = Pick< + RemoteEnvironmentGateway, + "listEnvironments" +>; + +/** `GET /:id/environments` → the connected remote environments this session can use. */ +async function handleListEnvironments( + store: SessionStore, + environments: EnvironmentLister, + id: string, + res: Response, +): Promise { + const session = await loadSession(store, res, id); + if (!session) return; + const body: ListEnvironmentsResponse = { + environments: environments.listEnvironments(session.id), + }; + res.json(body); +} + +/** Registers the read-only connected-environments route on a session. */ +export function registerEnvironmentRoutes( + router: Router, + store: SessionStore, + environments: EnvironmentLister, +): void { + router.get( + "/:id/environments", + validate({ params: sessionParamsSchema }), + (req: Request, res: Response) => + handleListEnvironments( + store, + environments, + getValidated(req).params.id, + res, + ), + ); +} diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index ec31b04d..73d44561 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -10,6 +10,10 @@ import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { TriggerManager } from "../../pi/triggers/triggerManager.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; +import { + type EnvironmentLister, + registerEnvironmentRoutes, +} from "./environments.ts"; import { createArtifactFileHandler, handleCreateSession, @@ -142,6 +146,7 @@ export function createSessionsRouter( hostPreamble: HostResourcePreamble, emitResourcesUpdated: (sessionId: string) => void, workflow: WorkflowRouteDeps, + environments: EnvironmentLister, ): Router { const router = Router(); @@ -161,6 +166,7 @@ export function createSessionsRouter( registerSessionActivityRoutes(router, store); registerTriggerRoutes(router, store, triggers, triggerEngine); registerParticipantRoutes(router, store, participants); + registerEnvironmentRoutes(router, store, environments); registerResourceRoutes(router, { store, resources, diff --git a/apps/web/src/embed/components/EmbeddedParticipantList.tsx b/apps/web/src/embed/components/EmbeddedParticipantList.tsx index 269ef8d1..79bf00dc 100644 --- a/apps/web/src/embed/components/EmbeddedParticipantList.tsx +++ b/apps/web/src/embed/components/EmbeddedParticipantList.tsx @@ -3,6 +3,7 @@ import { Box } from "@tangent/ui-primitives/box"; import { ParticipantList } from "@/features/chat/components/sidebar/participants/ParticipantList"; import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { useSessionEnvironments } from "@/features/chat/hooks/useSessionEnvironments"; import { useMuteMembership, useSessionParticipants, @@ -35,12 +36,14 @@ export function EmbeddedParticipantList({ const { data: participants = [] } = useSessionParticipants(sessionId); const muteMembership = useMuteMembership(sessionId); + const { data: environments = [] } = useSessionEnvironments(sessionId); return ( { diff --git a/apps/web/src/features/chat/components/sidebar/participants/EnvironmentRow.tsx b/apps/web/src/features/chat/components/sidebar/participants/EnvironmentRow.tsx new file mode 100644 index 00000000..7bf0443a --- /dev/null +++ b/apps/web/src/features/chat/components/sidebar/participants/EnvironmentRow.tsx @@ -0,0 +1,73 @@ +import type { RemoteEnvironmentInfo } from "@tangent/shared/contracts"; +import { Box } from "@tangent/ui-primitives/box"; +import { Icon } from "@tangent/ui-primitives/icon"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Text } from "@tangent/ui-primitives/typography"; + +import { ListRow } from "@/shared/ui/patterns/list-row"; +import { Pill } from "@/shared/ui/patterns/pill"; +import { Truncating } from "@/shared/ui/patterns/truncating"; + +import { PresenceDot } from "./PresenceDot"; + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** Drops the `:` prefix a session-pinned dev host carries in its id. */ +function environmentLabel(environmentId: string): string { + const idx = environmentId.indexOf(":"); + return idx === -1 ? environmentId : environmentId.slice(idx + 1); +} + +function environmentDetail(environment: RemoteEnvironmentInfo): string { + const kind = environment.scoped ? "Session host" : "Dev host"; + return environment.owner ? `${kind} · ${environment.owner}` : kind; +} + +/** A connected remote environment, listed read-only alongside the roster. */ +export function EnvironmentRow({ + environment, +}: { + environment: RemoteEnvironmentInfo; +}) { + return ( + + + + + + } + > + + + + + {environmentLabel(environment.environmentId)} + + + + {plural(environment.toolCount, "tool")} + + {plural(environment.subagentCount, "agent")} + + + + + + {environmentDetail(environment)} + + + + ); +} diff --git a/apps/web/src/features/chat/components/sidebar/participants/ParticipantList.tsx b/apps/web/src/features/chat/components/sidebar/participants/ParticipantList.tsx index 27d863cd..0cd249a3 100644 --- a/apps/web/src/features/chat/components/sidebar/participants/ParticipantList.tsx +++ b/apps/web/src/features/chat/components/sidebar/participants/ParticipantList.tsx @@ -2,6 +2,7 @@ import type { ParticipantKind, ParticipantWithMemberships, Presence, + RemoteEnvironmentInfo, } from "@tangent/shared/contracts"; import { Box } from "@tangent/ui-primitives/box"; import { Icon, type IconName } from "@tangent/ui-primitives/icon"; @@ -14,10 +15,13 @@ import { ListRow } from "@/shared/ui/patterns/list-row"; import { Pill } from "@/shared/ui/patterns/pill"; import { Truncating } from "@/shared/ui/patterns/truncating"; +import { EnvironmentRow } from "./EnvironmentRow"; import { PresenceDot } from "./PresenceDot"; interface ParticipantListProps { participants: ParticipantWithMemberships[]; + /** Connected remote environments, listed read-only after the roster. */ + environments?: RemoteEnvironmentInfo[]; /** * The Conversation currently in view; a participant's mute toggle acts on its * Membership there. Muting is only meaningful for a member of this thread. @@ -78,6 +82,7 @@ function rosterOrder( */ export function ParticipantList({ participants, + environments = [], activeConversationId, currentUserId, onToggleMute, @@ -85,7 +90,7 @@ export function ParticipantList({ }: ParticipantListProps) { const active = participants.filter((p) => !p.revokedAt).sort(rosterOrder); - if (active.length === 0) { + if (active.length === 0 && environments.length === 0) { return ( ); })} + {environments.map((environment) => ( + + ))} ); diff --git a/apps/web/src/features/chat/components/windows/ParticipantsWindow.tsx b/apps/web/src/features/chat/components/windows/ParticipantsWindow.tsx index d9b2145d..0fd4957a 100644 --- a/apps/web/src/features/chat/components/windows/ParticipantsWindow.tsx +++ b/apps/web/src/features/chat/components/windows/ParticipantsWindow.tsx @@ -7,6 +7,7 @@ export function ParticipantsWindow() { participants, participantsPending, participantsError, + environments, activeConversationId, currentUserId, onToggleMuteParticipant, @@ -26,6 +27,7 @@ export function ParticipantsWindow() { return ( { + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Environments(sessionId), + }); + }); + socket.on(SocketEvents.ResourcesUpdated, () => { void queryClient.invalidateQueries({ queryKey: SessionQueryKeys.Resources(sessionId), diff --git a/apps/web/src/features/chat/hooks/useSessionChatModel.ts b/apps/web/src/features/chat/hooks/useSessionChatModel.ts index 6f4c5ea3..cf1c2455 100644 --- a/apps/web/src/features/chat/hooks/useSessionChatModel.ts +++ b/apps/web/src/features/chat/hooks/useSessionChatModel.ts @@ -6,6 +6,7 @@ import { useAssetTabs, } from "@/features/chat/hooks/useAssetTabs"; import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { useSessionEnvironments } from "@/features/chat/hooks/useSessionEnvironments"; import { useMuteMembership, useSessionParticipants, @@ -84,6 +85,7 @@ export function useSessionChatModel(sessionId: string) { } = useSessionParticipants(sessionId); const { mutate: muteMembership, isPending: isTogglingMute } = useMuteMembership(sessionId); + const { data: environments = [] } = useSessionEnvironments(sessionId); // Scope the catalog to the current human when they are an invited Participant, // so surfacing consults their per-Conversation grants (default-permissive with @@ -195,6 +197,7 @@ export function useSessionChatModel(sessionId: string) { participants, participantsPending, participantsError, + environments, activeConversationId, currentUserId: currentAuthorId, onToggleMuteParticipant: (participantId, conversationId, muted) => diff --git a/apps/web/src/features/chat/hooks/useSessionEnvironments.ts b/apps/web/src/features/chat/hooks/useSessionEnvironments.ts new file mode 100644 index 00000000..4bb006de --- /dev/null +++ b/apps/web/src/features/chat/hooks/useSessionEnvironments.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; + +import { listEnvironments } from "@/features/sessions/api/sessionsApi"; +import { SessionQueryKeys } from "@/features/sessions/model/sessionQueryKeys"; + +/** + * The connected remote environments this session can route to. Kept fresh by + * invalidating its key whenever an `environment:update` socket signal arrives. + */ +export function useSessionEnvironments(sessionId: string) { + return useQuery({ + queryKey: SessionQueryKeys.Environments(sessionId), + queryFn: () => listEnvironments(sessionId), + enabled: sessionId.length > 0, + }); +} diff --git a/apps/web/src/features/sessions/api/sessionsApi.ts b/apps/web/src/features/sessions/api/sessionsApi.ts index 4df5b5b8..2d568778 100644 --- a/apps/web/src/features/sessions/api/sessionsApi.ts +++ b/apps/web/src/features/sessions/api/sessionsApi.ts @@ -3,11 +3,13 @@ import type { Attachment, CreateSessionRequest, HostResourceInput, + ListEnvironmentsResponse, ListParticipantsResponse, ListResourcesResponse, MembershipView, ParticipantView, ParticipantWithMemberships, + RemoteEnvironmentInfo, Resource, Session, UpdateSessionRequest, @@ -199,6 +201,16 @@ export async function listParticipants( return data.participants; } +/** Lists the connected remote environments (VS Code windows, embed hosts) this session can use. */ +export async function listEnvironments( + sessionId: string, +): Promise { + const data = await parseJson( + await apiFetch(`/api/sessions/${sessionId}/environments`), + ); + return data.environments; +} + export interface InviteParticipantInput { email: string; displayName?: string; diff --git a/apps/web/src/features/sessions/model/sessionQueryKeys.ts b/apps/web/src/features/sessions/model/sessionQueryKeys.ts index ff72ca37..62fdbaa2 100644 --- a/apps/web/src/features/sessions/model/sessionQueryKeys.ts +++ b/apps/web/src/features/sessions/model/sessionQueryKeys.ts @@ -18,6 +18,7 @@ export const SessionQueryKeys = { ] as const) : (["sessions", id, "resources"] as const), Participants: (id: string) => ["sessions", id, "participants"] as const, + Environments: (id: string) => ["sessions", id, "environments"] as const, Workflow: ( id: string, scope?: { conversationId?: string; participantId?: string }, diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 4a973923..fc5183fa 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -1008,6 +1008,29 @@ export interface ListParticipantsResponse { participants: ParticipantWithMemberships[]; } +/** + * A connected remote environment (a VS Code window, an embed host) that can host + * this session's remote sub-agents and tools. Not a Participant: it is the place + * a sub-agent runs, listed only while its socket is connected. + */ +export interface RemoteEnvironmentInfo { + environmentId: string; + /** Set when the host authenticated with a token scoped to this session. */ + sessionId?: string; + /** The human a scoped host belongs to (the scoped token's `sub`). */ + owner?: string; + /** Scoped to this session, versus a generic dev host shared across sessions. */ + scoped: boolean; + toolCount: number; + /** This session's live sub-agents hosted here. */ + subagentCount: number; +} + +/** Response of `GET /api/sessions/:id/environments`. */ +export interface ListEnvironmentsResponse { + environments: RemoteEnvironmentInfo[]; +} + /** * A serializable snapshot of the {@link ContextPolicy} a Membership's * {@link TranscriptVisibility} expands to (unified-model §9.8, "each @@ -1566,6 +1589,14 @@ export interface ParticipantPresencePayload { presence: Presence; } +/** + * Signals (server -> client) that connected remote environments changed. Sent to + * every client, since a generic dev host is not bound to one session. + */ +export interface EnvironmentUpdatePayload { + sessionId?: string; +} + /** * Emitted (server -> client) when the agent suggests remembering something that * needs user confirmation before it is applied (agent-initiated global memory). @@ -1729,6 +1760,7 @@ export const SocketEvents = { SubagentRoster: "subagent:roster", SubagentUpdate: "subagent:update", ParticipantPresence: "participant:presence", + EnvironmentUpdate: "environment:update", MemorySuggestion: "memory:suggestion", MemoryConfirm: "memory:confirm", MemoryDismiss: "memory:dismiss",