Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import { RemoteEnvironmentGateway } from "./remote/remoteEnvironmentGateway.ts";
import { createAgentBundlesRouter } from "./routes/agentBundles.ts";
import { createGlobalMemoryRouter } from "./routes/globalMemory.ts";
import { createInternalAgentsRouter } from "./routes/internalAgents.ts";
import { createInternalCsomRouter } from "./routes/internalCsom.ts";
import { createInternalEgressRouter } from "./routes/internalEgress.ts";
import { createInternalMemoryRouter } from "./routes/internalMemory.ts";
import { createInternalSessionRouter } from "./routes/internalSession.ts";
import { createInternalTriggersRouter } from "./routes/internalTriggers.ts";
import { createMeRouter } from "./routes/me.ts";
import { createRemoteEnvRouter } from "./routes/remoteEnv.ts";
import { createSessionsRouter } from "./routes/sessions/index.ts";
import {
createAgentEventHandler,
Expand Down Expand Up @@ -120,13 +122,19 @@ app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore));
app.use("/api/global-memory", createGlobalMemoryRouter(memory));
// Returns the current user, derived from the Oktasso JWT cookie.
app.use("/api/me", createMeRouter());
// Hands the SPA the `/remote-env` token so the Pipeline Editor tab can connect
// as its session's CSOM executor (only when remote hosting is enabled).
app.use("/api/remote-env", createRemoteEnvRouter());
// Internal API for the orchestrator extension running inside each Pi process.
app.use(
"/internal/agents",
createInternalAgentsRouter(store, pi, remoteGateway),
);
// Internal egress proxy for bundle tool extensions (e.g. the Tangle API tool).
app.use("/internal/egress", createInternalEgressRouter());
// Internal CSOM relay: Prime's pipeline-editor tools drive the browser's
// embedded Tangle editor through the remote-environment gateway.
app.use("/internal/csom", createInternalCsomRouter(remoteGateway));
// Internal API for the triggers extension running inside each Pi process.
app.use(
"/internal/triggers",
Expand Down
66 changes: 64 additions & 2 deletions apps/server/src/remote/remoteEnvironmentGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
type RemoteAgentEvent,
type RemoteAgentEventPayload,
type RemoteAgentMessagePayload,
type RemoteCsomCallRequest,
type RemoteCsomCallResponse,
RemoteEnvEvents,
type RemoteEnvHandshake,
type RemoteKillCommand,
Expand All @@ -35,6 +37,9 @@ import type { SessionStore } from "../store/sessionStore.ts";
const DEFAULT_ROOM_LIMIT = 30;
const MAX_ROOM_LIMIT = 200;

/** How long a CSOM invocation waits for the environment's ack before failing. */
const CSOM_CALL_TIMEOUT_MS = 20_000;

/**
* Relays a remote sub-agent's reply/report into the session's Prime process.
* Wired in `index.ts` to `pi.sendToAgent(sessionId, PRIME_AGENT_ID, text)`, so
Expand All @@ -46,6 +51,8 @@ export type DeliverToPrime = (sessionId: string, text: string) => void;
interface RemoteEnvConnection {
environmentId: string;
socket: Socket;
/** Session this environment is bound to as the CSOM executor, if any. */
sessionId?: string;
}

/** A sub-agent hosted in a remote environment, tracked in the gateway roster. */
Expand Down Expand Up @@ -107,6 +114,8 @@ export class RemoteEnvironmentGateway {
private readonly environments = new Map<string, RemoteEnvConnection>();
/** Per-session remote sub-agent rosters, keyed by sessionId then agentId. */
private readonly sessions = new Map<string, Map<string, RemoteSubagent>>();
/** CSOM executor binding: sessionId -> environmentId (e.g. a browser tab). */
private readonly csomBindings = new Map<string, string>();

constructor(
io: SocketIOServer,
Expand Down Expand Up @@ -138,6 +147,54 @@ export class RemoteEnvironmentGateway {
return [...roster.values()].map(toInfo);
}

/** True when a session has a connected CSOM executor (pipeline editor). */
hasCsomEditor(sessionId: string): boolean {
const environmentId = this.csomBindings.get(sessionId);
return environmentId !== undefined && this.environments.has(environmentId);
}

/**
* Invokes a CSOM editing method on the session's bound editor environment and
* resolves with its ack. Returns a structured `{ ok: false, error }` when no
* editor is connected or the environment times out, so callers (Prime's CSOM
* tools) can surface a helpful message rather than throw.
*/
invokeCsom(
sessionId: string,
method: string,
args: unknown[],
): Promise<RemoteCsomCallResponse> {
const environmentId = this.csomBindings.get(sessionId);
const environment = environmentId
? this.environments.get(environmentId)
: undefined;
if (!environment) {
return Promise.resolve({
ok: false,
error:
"No pipeline editor is connected for this session. Ask the user to " +
"open the Pipeline Editor tab first.",
});
}

const request: RemoteCsomCallRequest = { sessionId, method, args };
return new Promise<RemoteCsomCallResponse>((resolve) => {
environment.socket
.timeout(CSOM_CALL_TIMEOUT_MS)
.emit(
RemoteEnvEvents.CsomCall,
request,
(err: Error | null, response: RemoteCsomCallResponse) => {
if (err) {
resolve({ ok: false, error: `CSOM call timed out: ${method}` });
return;
}
resolve(response);
},
);
});
}

/**
* Spawns a sub-agent on a connected remote environment. Resolves the
* effective config from the global templates/defaults (remote environments
Expand Down Expand Up @@ -300,8 +357,10 @@ export class RemoteEnvironmentGateway {

/** 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, sessionId } = socket.handshake
.auth as RemoteEnvHandshake;
this.environments.set(environmentId, { environmentId, socket, sessionId });
if (sessionId) this.csomBindings.set(sessionId, environmentId);
console.log(`[remote-env] connected: ${environmentId}`);

socket.on(RemoteEnvEvents.AgentEvent, (payload: RemoteAgentEventPayload) =>
Expand Down Expand Up @@ -398,6 +457,9 @@ export class RemoteEnvironmentGateway {
/** Drops a disconnected environment and fails its still-live sub-agents. */
private onDisconnect(environmentId: string): void {
this.environments.delete(environmentId);
for (const [sessionId, boundId] of this.csomBindings) {
if (boundId === environmentId) this.csomBindings.delete(sessionId);
}
for (const [sessionId, roster] of this.sessions) {
this.failEnvironmentAgents(sessionId, roster, environmentId);
}
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/routes/internalCsom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { 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";

/**
* A CSOM invocation: the camelCase editor method (e.g. `addTask`,
* `connectNodes`, `getSpecYaml`) and its positional args, scoped to a session.
* `args` is permissive — it is forwarded verbatim to the editor bridge.
*/
const csomInvokeSchema = z.object({
sessionId: z.string().min(1),
method: z.string().min(1),
args: z.array(z.unknown()).optional(),
});
type CsomInvokeBody = z.infer<typeof csomInvokeSchema>;

/** Forwards a CSOM call to the session's bound editor and returns its ack. */
async function handleInvoke(
remoteGateway: RemoteEnvironmentGateway,
body: CsomInvokeBody,
res: Response,
): Promise<void> {
const result = await remoteGateway.invokeCsom(
body.sessionId,
body.method,
body.args ?? [],
);
res.json(result);
}

/**
* Internal API used by the CSOM tool extension running inside each Pi process.
* It lets Prime drive the embedded Tangle pipeline editor: each tool call is
* relayed to the browser tab that opened the editor (the session's bound remote
* environment) and the CSOM result is returned.
*
* Guarded by the same `INTERNAL_TOKEN` the other internal APIs use, so only the
* spawned Pi processes (not arbitrary local callers) can drive the editor.
*/
export function createInternalCsomRouter(
remoteGateway: RemoteEnvironmentGateway,
): Router {
const router = Router();

router.use(requireInternalToken);

router.post("/invoke", validate({ body: csomInvokeSchema }), (req, res) =>
handleInvoke(remoteGateway, getValidated<CsomInvokeBody>(req).body, res),
);

return router;
}
30 changes: 30 additions & 0 deletions apps/server/src/routes/remoteEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type Request, type Response, Router } from "express";

import { REMOTE_ENV_TOKEN } from "../config.ts";

/**
* Tells the SPA whether remote CSOM hosting is enabled and, if so, hands it the
* shared `/remote-env` token so the Pipeline Editor tab can connect as its
* session's CSOM executor.
*
* The token is the same shared secret external remote environments use. Remote
* hosting is opt-in (empty by default), so this only exposes a token an operator
* has explicitly configured. It is a same-origin dev convenience; production
* deployments that gate the app behind auth should front this route with it.
*/
function handleGetToken(_req: Request, res: Response): void {
if (!REMOTE_ENV_TOKEN) {
res.json({ enabled: false });
return;
}
res.json({ enabled: true, token: REMOTE_ENV_TOKEN });
}

/** Public REST router exposing the remote-env connection token to the SPA. */
export function createRemoteEnvRouter(): Router {
const router = Router();
router.get("/token", (req: Request, res: Response) =>
handleGetToken(req, res),
);
return router;
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@remote-dom/core": "^1.11.1",
"@remote-dom/polyfill": "^1.5.1",
"@remote-dom/react": "^1.2.2",
"@tangent/remote-subagent": "workspace:*",
"@tangent/shared": "workspace:*",
"@tangent/ui-extensions-sdk": "workspace:*",
"@tangent/ui-primitives": "workspace:*",
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/features/bundle-ui/BundleUiHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ interface BundleUiHostProps {
stateNamespace?: string;
/** Collapses the message this component is rendered in (message surface). */
onCollapse?: () => void;
/** Opens a full-screen in-app tab requested via `execUICommand({ type: "openTab" })`. */
onOpenTab?: (command: Extract<UICommand, { type: "openTab" }>) => void;
}

function Placeholder() {
Expand All @@ -118,6 +120,7 @@ export function BundleUiHost({
onSendPrompt,
stateNamespace,
onCollapse,
onOpenTab,
}: BundleUiHostProps) {
const receiver = useMemo(() => new RemoteReceiver(), []);
const [failed, setFailed] = useState(false);
Expand All @@ -128,11 +131,15 @@ export function BundleUiHost({
const sendPromptRef = useRef<((text: string) => void) | undefined>(undefined);
const stateNamespaceRef = useRef<string | undefined>(undefined);
const collapseRef = useRef<(() => void) | undefined>(undefined);
const openTabRef = useRef<
((command: Extract<UICommand, { type: "openTab" }>) => void) | undefined
>(undefined);
useEffect(() => {
propsRef.current = props ?? {};
sendPromptRef.current = onSendPrompt;
stateNamespaceRef.current = stateNamespace;
collapseRef.current = onCollapse;
openTabRef.current = onOpenTab;
});

useEffect(() => {
Expand Down Expand Up @@ -169,6 +176,10 @@ export function BundleUiHost({
openExternalUrl(command.url);
return;
}
if (command.type === "openTab") {
openTabRef.current?.(command);
return;
}
await openTargetUrl(command);
},
});
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/features/chat/components/PrimeChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type SubagentInfo,
type Trigger,
} from "@tangent/shared/contracts";
import type { UICommand } from "@tangent/ui-extensions-sdk/types";
import { Box } from "@tangent/ui-primitives/box";
import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout";

Expand Down Expand Up @@ -55,6 +56,7 @@ interface PrimeChatPanelProps {
openArtifactTab: (url: string, title: string) => void;
pinnedPaths: Set<string>;
togglePinArtifact: (path: string, title: string) => void;
onOpenTab: (command: Extract<UICommand, { type: "openTab" }>) => void;
}

export function PrimeChatPanel({
Expand Down Expand Up @@ -82,6 +84,7 @@ export function PrimeChatPanel({
openArtifactTab,
pinnedPaths,
togglePinArtifact,
onOpenTab,
}: PrimeChatPanelProps) {
return (
<BlockStack grow>
Expand All @@ -95,6 +98,7 @@ export function PrimeChatPanel({
onOpenArtifact={openArtifactTab}
pinnedPaths={pinnedPaths}
onTogglePinArtifact={togglePinArtifact}
onOpenTab={onOpenTab}
isMessageStreaming={isMessageStreaming}
/>
{memorySuggestions.length > 0 && (
Expand All @@ -112,7 +116,11 @@ export function PrimeChatPanel({
</Box>
)}
{bundleId && (
<BundlePanelLauncher bundleId={bundleId} onSendPrompt={send} />
<BundlePanelLauncher
bundleId={bundleId}
onSendPrompt={send}
onOpenTab={onOpenTab}
/>
)}
<PrimeComposerFooter
sessionId={sessionId}
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/features/chat/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,15 @@ export function SessionChat({ sessionId }: SessionChatProps) {

// Opened tabs (assets and sub-agent threads), each shown beside the chat in
// its own closeable tab.
const { tabs, activeTab, setActiveTab, openAsset, openAgent, closeAsset } =
useAssetTabs();
const {
tabs,
activeTab,
setActiveTab,
openAsset,
openAgent,
openPipelineEditor,
closeAsset,
} = useAssetTabs();

// The session's pages, files, and triggers as one uniform list of cards.
const assets = buildAssets({ sessionId, artifacts, triggers });
Expand Down Expand Up @@ -117,6 +124,16 @@ export function SessionChat({ sessionId }: SessionChatProps) {
});
};

// A bundle message/panel component can ask the host to open a full-screen
// in-app tab via `host.execUICommand({ type: "openTab" })`. Only the
// pipeline-editor tab exists today.
const handleOpenTab = (command: {
tab: "pipeline-editor";
title?: string;
}) => {
if (command.tab === "pipeline-editor") openPipelineEditor(command.title);
};

// Pin an artifact if it isn't already pinned, else unpin it. The chip's
// pinned state and the sidebar list both update via the `artifacts.update`
// directive once the server confirms.
Expand Down Expand Up @@ -227,6 +244,7 @@ export function SessionChat({ sessionId }: SessionChatProps) {
openArtifactTab={openArtifactTab}
pinnedPaths={pinnedPaths}
togglePinArtifact={togglePinArtifact}
onOpenTab={handleOpenTab}
/>
</TabsContent>

Expand Down
Loading
Loading