feat: add multi-agent beta remote office support (#62)

* Remote openclaw connection enabled and agent added

* 2 worlds connected

* Performance improvement

* Performance improvements

* Added documentation

* feat(office): add multi-agent beta remote office support

Add a second-office beta that can mirror remote Claw3D presence or derive remote gateway presence so teams can visualize and message agents across instances. Harden the new remote flows, document setup, and keep the branch green with full validation.

Made-with: Cursor

---------

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
Luke The Dev
2026-03-25 11:14:20 -05:00
committed by GitHub
parent 1185f7a9f0
commit a202cdc80f
31 changed files with 4326 additions and 467 deletions
+438
View File
@@ -0,0 +1,438 @@
import { createHash, randomUUID } from "node:crypto";
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
import { GatewayResponseError } from "@/lib/gateway/errors";
type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: {
code?: string;
message?: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
};
type GatewayEventFrame = {
type: "event";
event?: string;
payload?: unknown;
};
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
const CONNECT_TIMEOUT_MS = 8_000;
const REQUEST_TIMEOUT_MS = 12_000;
const INITIAL_CONNECT_DELAY_MS = 750;
const GATEWAY_ROLE = "operator";
const GATEWAY_SCOPES = ["operator.admin", "operator.approvals", "operator.pairing"];
const GATEWAY_CLIENT_ID = "openclaw-control-ui";
const asRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const parseGatewayFrame = (raw: string): GatewayResponseFrame | GatewayEventFrame | null => {
try {
return JSON.parse(raw) as GatewayResponseFrame | GatewayEventFrame;
} catch {
return null;
}
};
type DeviceIdentity = {
deviceId: string;
publicKey: string;
privateKey: Uint8Array;
};
const base64UrlEncode = (bytes: Uint8Array): string => {
return Buffer.from(bytes)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/g, "");
};
const fingerprintPublicKey = (publicKey: Uint8Array): string =>
createHash("sha256").update(publicKey).digest("hex");
const buildDeviceAuthPayload = (params: {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: "v1" | "v2";
}): string => {
const version = params.version ?? (params.nonce ? "v2" : "v1");
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const base = [
version,
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
];
if (version === "v2") {
base.push(params.nonce ?? "");
}
return base.join("|");
};
const createDeviceIdentity = async (): Promise<DeviceIdentity> => {
const privateKey = utils.randomSecretKey();
const publicKey = await getPublicKeyAsync(privateKey);
return {
deviceId: fingerprintPublicKey(publicKey),
publicKey: base64UrlEncode(publicKey),
privateKey,
};
};
const buildConnectParams = async (params: {
token: string;
nonce: string | null;
deviceIdentity: DeviceIdentity;
}) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: params.deviceIdentity.deviceId,
clientId: GATEWAY_CLIENT_ID,
clientMode: "webchat",
role: GATEWAY_ROLE,
scopes: GATEWAY_SCOPES,
signedAtMs,
token: params.token || null,
nonce: params.nonce,
});
const signature = await signAsync(new TextEncoder().encode(payload), params.deviceIdentity.privateKey);
return {
minProtocol: 3,
maxProtocol: 3,
client: {
id: GATEWAY_CLIENT_ID,
version: "dev",
platform: process.platform,
mode: "webchat",
},
role: GATEWAY_ROLE,
scopes: GATEWAY_SCOPES,
caps: [],
device: {
id: params.deviceIdentity.deviceId,
publicKey: params.deviceIdentity.publicKey,
signature: base64UrlEncode(signature),
signedAt: signedAtMs,
...(params.nonce ? { nonce: params.nonce } : {}),
},
...(params.token
? {
auth: {
token: params.token,
},
}
: {}),
userAgent: "node",
locale: "en-US",
};
};
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> => {
let timeoutId: NodeJS.Timeout | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(message));
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
};
export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => {
const trimmedAgent = agentId.trim();
const trimmedKey = mainKey.trim() || "main";
return `agent:${trimmedAgent}:${trimmedKey}`;
};
export class NodeGatewayClient {
private socket: WebSocket | null = null;
private pending = new Map<string, PendingRequest>();
private closed = false;
private connectRequestIds = new Set<string>();
private connectPromise: Promise<void> | null = null;
private resolveConnect: (() => void) | null = null;
private rejectConnect: ((error: Error) => void) | null = null;
private connectNonce: string | null = null;
private connectToken = "";
private deviceIdentity: DeviceIdentity | null = null;
private connectSent = false;
private connectTimer: NodeJS.Timeout | null = null;
async connect(params: { gatewayUrl: string; token?: string | null }) {
const gatewayUrl = params.gatewayUrl.trim();
if (!gatewayUrl) {
throw new Error("Remote office gateway URL is not configured.");
}
if (this.socket) {
throw new Error("Node gateway client is already connected.");
}
this.connectToken = params.token?.trim() ?? "";
this.deviceIdentity = await createDeviceIdentity();
const socket = new WebSocket(gatewayUrl);
this.socket = socket;
socket.addEventListener("message", (event) => {
const raw =
typeof event.data === "string"
? event.data
: event.data instanceof ArrayBuffer
? new TextDecoder().decode(new Uint8Array(event.data))
: String(event.data);
const frame = parseGatewayFrame(raw);
if (!frame) return;
if (frame.type === "event") {
if (frame.event === "connect.challenge") {
const payload = asRecord(frame.payload) ? frame.payload : null;
const nonce = typeof payload?.nonce === "string" ? payload.nonce.trim() : "";
if (!nonce) {
this.rejectConnectFlow(
new Error("Remote gateway requested device authentication without a nonce."),
);
this.close();
return;
}
this.connectNonce = nonce;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
void this.sendConnectRequest();
}
return;
}
if (this.connectRequestIds.has(frame.id)) {
this.connectRequestIds.delete(frame.id);
if (frame.ok) {
this.resolveConnect?.();
this.clearConnectFlow();
return;
}
if (asRecord(frame.error) && typeof frame.error.code === "string") {
this.rejectConnectFlow(
new GatewayResponseError({
code: frame.error.code,
message:
typeof frame.error.message === "string"
? frame.error.message
: "Gateway connect failed.",
details: frame.error.details,
retryable:
typeof frame.error.retryable === "boolean" ? frame.error.retryable : undefined,
retryAfterMs:
typeof frame.error.retryAfterMs === "number"
? frame.error.retryAfterMs
: undefined,
}),
);
return;
}
this.rejectConnectFlow(new Error("Gateway connect failed."));
return;
}
const pending = this.pending.get(frame.id);
if (!pending) return;
this.pending.delete(frame.id);
if (frame.ok) {
pending.resolve(frame.payload);
return;
}
if (asRecord(frame.error) && typeof frame.error.code === "string") {
pending.reject(
new GatewayResponseError({
code: frame.error.code,
message:
typeof frame.error.message === "string"
? frame.error.message
: "Gateway request failed.",
details: frame.error.details,
retryable:
typeof frame.error.retryable === "boolean" ? frame.error.retryable : undefined,
retryAfterMs:
typeof frame.error.retryAfterMs === "number"
? frame.error.retryAfterMs
: undefined,
}),
);
return;
}
pending.reject(new Error("Gateway request failed."));
});
socket.addEventListener("close", (event) => {
this.closed = true;
const reason = typeof event.reason === "string" ? event.reason : "";
this.rejectAllPending(
new Error(
`Remote gateway connection closed${reason.trim() ? `: ${reason}` : "."}`,
),
);
if (this.socket === socket) {
this.socket = null;
}
this.clearConnectFlow();
});
socket.addEventListener("error", () => {
const error = new Error("Remote gateway connection failed.");
this.rejectConnectFlow(error);
this.rejectAllPending(error);
});
await withTimeout(
new Promise<void>((resolve, reject) => {
const handleOpen = () => {
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("error", handleError);
resolve();
};
const handleError = () => {
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("error", handleError);
reject(new Error("Remote gateway connection failed."));
};
socket.addEventListener("open", handleOpen, { once: true });
socket.addEventListener("error", handleError, { once: true });
}),
CONNECT_TIMEOUT_MS,
"Timed out connecting to the remote gateway.",
);
this.connectPromise = new Promise<void>((resolve, reject) => {
this.resolveConnect = resolve;
this.rejectConnect = reject;
});
this.queueConnect();
await withTimeout(
this.connectPromise,
REQUEST_TIMEOUT_MS,
"Remote gateway connect handshake timed out.",
);
}
async request<T = unknown>(method: string, params: unknown): Promise<T> {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN || this.closed) {
throw new Error("Remote gateway is not connected.");
}
const id = randomUUID();
const response = withTimeout(
new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
this.socket?.send(JSON.stringify({ type: "req", id, method, params }));
} catch (error) {
this.pending.delete(id);
reject(error instanceof Error ? error : new Error("Failed to send gateway request."));
}
}),
REQUEST_TIMEOUT_MS,
`Remote gateway request timed out for ${method}.`,
) as Promise<T>;
return response;
}
close() {
this.closed = true;
this.rejectAllPending(new Error("Remote gateway client closed."));
if (this.socket) {
try {
this.socket.close();
} finally {
this.socket = null;
}
}
}
private rejectAllPending(error: Error) {
const entries = [...this.pending.values()];
this.pending.clear();
for (const pending of entries) {
pending.reject(error);
}
}
private queueConnect() {
this.connectSent = false;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
}
this.connectTimer = setTimeout(() => {
void this.sendConnectRequest();
}, INITIAL_CONNECT_DELAY_MS);
}
private async sendConnectRequest() {
if (
this.connectSent ||
!this.socket ||
this.socket.readyState !== WebSocket.OPEN ||
!this.deviceIdentity
) {
throw new Error("Remote gateway is not connected.");
}
this.connectSent = true;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const id = randomUUID();
this.connectRequestIds.add(id);
const params = await buildConnectParams({
token: this.connectToken,
nonce: this.connectNonce,
deviceIdentity: this.deviceIdentity,
});
this.socket.send(JSON.stringify({ type: "req", id, method: "connect", params }));
}
private rejectConnectFlow(error: Error) {
this.rejectConnect?.(error);
this.clearConnectFlow();
}
private clearConnectFlow() {
this.connectRequestIds.clear();
this.connectPromise = null;
this.resolveConnect = null;
this.rejectConnect = null;
this.connectSent = false;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
}
}
+6 -2
View File
@@ -48,6 +48,8 @@ const BROWSER_KEYWORD_RE =
/\b(browser|navigate|snapshot|screenshot|tab|click|console|cookies|storage|page|url)\b/i;
const BROWSER_INTENT_RE =
/\b(browse|inspect|visit|navigate|open|go to|website|site|page)\b/i;
const MONITOR_HISTORY_LINE_LIMIT = 160;
const MONITOR_BROWSER_SCAN_ENTRY_LIMIT = 18;
const extractUrls = (value: string): string[] => {
const matches = value.match(URL_RE);
@@ -276,8 +278,9 @@ const summarizeMode = (params: {
export const buildOfficeDeskMonitor = (
agent: AgentState,
): OfficeDeskMonitor => {
const monitorOutputLines = agent.outputLines.slice(-MONITOR_HISTORY_LINE_LIMIT);
const chatItems = buildAgentChatItems({
outputLines: agent.outputLines,
outputLines: monitorOutputLines,
streamText: agent.streamText,
liveThinkingTrace: agent.thinkingTrace ?? "",
showThinkingTraces: agent.showThinkingTraces,
@@ -287,12 +290,13 @@ export const buildOfficeDeskMonitor = (
.map(flattenMonitorEntry)
.filter((entry): entry is OfficeDeskMonitorEntry => Boolean(entry));
const latestEntries = flatEntries.slice(-6);
const browserScanEntries = flatEntries.slice(-MONITOR_BROWSER_SCAN_ENTRY_LIMIT);
const browserUrl =
[
agent.lastUserMessage ?? "",
agent.latestPreview ?? "",
...latestEntries.map((entry) => entry.text),
...flatEntries.map((entry) => entry.text),
...browserScanEntries.map((entry) => entry.text),
]
.flatMap((text) => [
...extractUrls(text),
+143
View File
@@ -0,0 +1,143 @@
import type {
SummaryPreviewSnapshot,
SummaryStatusSnapshot,
} from "@/features/agents/state/runtimeEventBridge";
import { buildAgentMainSessionKey } from "@/lib/gateway/GatewayClient";
import type { OfficeAgentPresence, OfficePresenceSnapshot } from "@/lib/office/presence";
type GatewayAgentsListEntry = {
id?: string;
name?: string;
identity?: {
name?: string;
};
};
type GatewayAgentsListResult = {
mainKey?: string;
agents?: GatewayAgentsListEntry[];
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const RECENT_ACTIVITY_MS = 45_000;
const resolveAgentsFromHelloSnapshot = (snapshot: unknown): GatewayAgentsListEntry[] => {
if (!isRecord(snapshot)) return [];
const health = isRecord(snapshot.health) ? snapshot.health : null;
const rawAgents = Array.isArray(health?.agents) ? health.agents : [];
return rawAgents.flatMap((entry) => {
if (!isRecord(entry)) return [];
const id = typeof entry.agentId === "string" ? entry.agentId.trim() : "";
if (!id) return [];
const name = typeof entry.name === "string" ? entry.name.trim() : "";
return [
{
id,
...(name ? { name } : {}),
},
];
});
};
const normalizeGatewayAgentEntries = (
agentsResult: GatewayAgentsListResult | null,
helloSnapshot: unknown,
): GatewayAgentsListEntry[] => {
const listedAgents = Array.isArray(agentsResult?.agents) ? agentsResult.agents : [];
if (listedAgents.length > 0) return listedAgents;
return resolveAgentsFromHelloSnapshot(helloSnapshot);
};
const resolvePreviewState = (
agentId: string,
agentsResult: GatewayAgentsListResult | null,
previewSnapshot: SummaryPreviewSnapshot | null,
): OfficeAgentPresence["state"] | null => {
const mainKey =
typeof agentsResult?.mainKey === "string" && agentsResult.mainKey.trim().length > 0
? agentsResult.mainKey.trim()
: "main";
const sessionKey = buildAgentMainSessionKey(agentId, mainKey);
const previews = Array.isArray(previewSnapshot?.previews) ? previewSnapshot.previews : [];
const preview = previews.find((entry) => entry.key === sessionKey) ?? null;
if (!preview || !Array.isArray(preview.items) || preview.items.length === 0) {
return null;
}
for (let index = preview.items.length - 1; index >= 0; index -= 1) {
const item = preview.items[index];
if (!item) continue;
if (item.role === "assistant") return "idle";
if (item.role === "user") return "working";
}
return null;
};
const resolveAgentState = (
agentId: string,
agentsResult: GatewayAgentsListResult | null,
statusSummary: SummaryStatusSnapshot | null,
previewSnapshot: SummaryPreviewSnapshot | null,
now = Date.now(),
): OfficeAgentPresence["state"] => {
const previewState = resolvePreviewState(agentId, agentsResult, previewSnapshot);
if (previewState) {
return previewState;
}
const byAgent = Array.isArray(statusSummary?.sessions?.byAgent)
? statusSummary.sessions.byAgent
: [];
const recentEntries =
byAgent.find((entry) => entry.agentId === agentId)?.recent?.filter(Boolean) ?? [];
const latestUpdatedAt = recentEntries.reduce<number | null>((latest, entry) => {
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : null;
if (updatedAt === null) return latest;
return latest === null ? updatedAt : Math.max(latest, updatedAt);
}, null);
if (latestUpdatedAt === null) return "idle";
if (now - latestUpdatedAt <= RECENT_ACTIVITY_MS) return "working";
return "idle";
};
export const buildOfficePresenceSnapshotFromGateway = (params: {
agentsResult: GatewayAgentsListResult | null;
helloSnapshot?: unknown;
statusSummary?: SummaryStatusSnapshot | null;
previewSnapshot?: SummaryPreviewSnapshot | null;
workspaceId?: string;
now?: number;
}): OfficePresenceSnapshot => {
const workspaceId = params.workspaceId?.trim() || "remote-gateway";
const now = params.now ?? Date.now();
const gatewayAgents = normalizeGatewayAgentEntries(
params.agentsResult,
params.helloSnapshot,
);
const agents: OfficeAgentPresence[] = gatewayAgents.flatMap((agent) => {
const agentId = typeof agent.id === "string" ? agent.id.trim() : "";
if (!agentId) return [];
const name =
(typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "") ||
(typeof agent.name === "string" ? agent.name.trim() : "") ||
agentId;
return [
{
agentId,
name,
state: resolveAgentState(
agentId,
params.agentsResult,
params.statusSummary ?? null,
params.previewSnapshot ?? null,
now,
),
},
];
});
return {
workspaceId,
timestamp: new Date(now).toISOString(),
agents,
};
};
+58
View File
@@ -0,0 +1,58 @@
import type { FurnitureItem } from "@/features/retro-office/core/types";
export type OfficeLayoutSnapshot = {
gatewayUrl: string;
timestamp: string;
width: number;
height: number;
furniture: FurnitureItem[];
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
export const normalizeOfficeLayoutSnapshot = (
value: unknown,
fallbackGatewayUrl = "",
): OfficeLayoutSnapshot | null => {
if (!isRecord(value)) return null;
const gatewayUrl =
typeof value.gatewayUrl === "string" && value.gatewayUrl.trim().length > 0
? value.gatewayUrl.trim()
: fallbackGatewayUrl.trim();
const timestamp =
typeof value.timestamp === "string" && value.timestamp.trim().length > 0
? value.timestamp
: new Date().toISOString();
const width =
typeof value.width === "number" && Number.isFinite(value.width) && value.width > 0
? value.width
: 1800;
const height =
typeof value.height === "number" && Number.isFinite(value.height) && value.height > 0
? value.height
: 720;
const furniture = Array.isArray(value.furniture)
? value.furniture.filter((item): item is FurnitureItem => isRecord(item))
: [];
return {
gatewayUrl,
timestamp,
width,
height,
furniture,
};
};
export const deriveRemoteLayoutUrlFromPresenceUrl = (presenceUrl: string) => {
const trimmed = presenceUrl.trim();
if (!trimmed) return "";
try {
const parsed = new URL(trimmed);
parsed.pathname = parsed.pathname.replace(/\/presence\/?$/, "/layout");
parsed.searchParams.delete("source");
return parsed.toString();
} catch {
return trimmed.replace(/\/presence\/?$/, "/layout");
}
};
+76
View File
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import type { OfficeLayoutSnapshot } from "@/lib/office/layoutSnapshot";
type LayoutSnapshotStore = {
schemaVersion: 1;
snapshots: Record<string, OfficeLayoutSnapshot>;
};
const STORE_DIR = "claw3d";
const STORE_FILE = "retro-office-layouts.json";
const ensureDirectory = (dirPath: string) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
const resolveStorePath = () => {
const stateDir = resolveStateDir();
const dir = path.join(stateDir, STORE_DIR);
ensureDirectory(dir);
return path.join(dir, STORE_FILE);
};
const defaultStore = (): LayoutSnapshotStore => ({
schemaVersion: 1,
snapshots: {},
});
const readStore = (): LayoutSnapshotStore => {
const storePath = resolveStorePath();
if (!fs.existsSync(storePath)) {
return defaultStore();
}
try {
const raw = fs.readFileSync(storePath, "utf8");
const parsed = JSON.parse(raw) as LayoutSnapshotStore;
if (
!parsed ||
parsed.schemaVersion !== 1 ||
!parsed.snapshots ||
typeof parsed.snapshots !== "object"
) {
return defaultStore();
}
return parsed;
} catch {
return defaultStore();
}
};
const writeStore = (store: LayoutSnapshotStore) => {
fs.writeFileSync(resolveStorePath(), JSON.stringify(store, null, 2), "utf8");
};
const normalizeGatewayKey = (gatewayUrl: string) => gatewayUrl.trim();
export const loadOfficeLayoutSnapshot = (gatewayUrl: string) => {
const key = normalizeGatewayKey(gatewayUrl);
if (!key) return null;
const store = readStore();
return store.snapshots[key] ?? null;
};
export const saveOfficeLayoutSnapshot = (snapshot: OfficeLayoutSnapshot) => {
const key = normalizeGatewayKey(snapshot.gatewayUrl);
if (!key) {
throw new Error("Gateway URL is required to save office layout snapshot.");
}
const store = readStore();
store.snapshots[key] = snapshot;
writeStore(store);
return snapshot;
};
+99
View File
@@ -36,6 +36,105 @@ const resolveStateFromSeed = (seed: number): OfficeAgentState => {
return "error";
};
const asRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const normalizeOfficeAgentState = (value: unknown): OfficeAgentState => {
if (value === "working" || value === "idle" || value === "meeting" || value === "error") {
return value;
}
return "idle";
};
export const normalizeOfficePresenceSnapshot = (
value: unknown,
fallbackWorkspaceId = "default"
): OfficePresenceSnapshot => {
if (!asRecord(value)) {
return {
workspaceId: fallbackWorkspaceId,
timestamp: new Date().toISOString(),
agents: [],
};
}
const workspaceId =
typeof value.workspaceId === "string" && value.workspaceId.trim().length > 0
? value.workspaceId.trim()
: fallbackWorkspaceId;
const timestamp =
typeof value.timestamp === "string" && value.timestamp.trim().length > 0
? value.timestamp
: new Date().toISOString();
const rawAgents = Array.isArray(value.agents) ? value.agents : [];
const agents: OfficeAgentPresence[] = rawAgents.flatMap((entry) => {
if (!asRecord(entry)) return [];
const agentId = typeof entry.agentId === "string" ? entry.agentId.trim() : "";
if (!agentId) return [];
const name = typeof entry.name === "string" && entry.name.trim().length > 0
? entry.name.trim()
: agentId;
const preferredDeskId =
typeof entry.preferredDeskId === "string" && entry.preferredDeskId.trim().length > 0
? entry.preferredDeskId.trim()
: undefined;
return [
{
agentId,
name,
state: normalizeOfficeAgentState(entry.state),
...(preferredDeskId ? { preferredDeskId } : {}),
},
];
});
return {
workspaceId,
timestamp,
agents,
};
};
export const fetchRemoteOfficePresenceSnapshot = async (params: {
presenceUrl: string;
token?: string | null;
timeoutMs?: number;
}): Promise<OfficePresenceSnapshot> => {
const presenceUrl = params.presenceUrl.trim();
if (!presenceUrl) {
throw new Error("Remote office presence URL is not configured.");
}
const controller = new AbortController();
const timeoutMs = Math.max(1_000, params.timeoutMs ?? 15_000);
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers: Record<string, string> = {
Accept: "application/json",
};
const token = params.token?.trim() ?? "";
if (token) {
headers.Authorization = `Bearer ${token}`;
headers["X-Claw3D-Office-Token"] = token;
}
const response = await fetch(presenceUrl, {
method: "GET",
headers,
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Remote office presence request failed with status ${response.status}.`);
}
const payload = (await response.json()) as unknown;
return normalizeOfficePresenceSnapshot(payload, "remote");
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Remote office presence request timed out after ${timeoutMs}ms.`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
};
export const loadOfficePresenceSnapshot = (workspaceId: string): OfficePresenceSnapshot => {
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
const timestamp = new Date().toISOString();
+132 -2
View File
@@ -64,10 +64,32 @@ export type StudioVoiceRepliesPreferencePatch = {
export type StudioOfficePreference = {
title: string;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
remoteOfficePresenceUrl: string;
remoteOfficeGatewayUrl: string;
remoteOfficeToken: string;
};
export type StudioOfficePreferencePublic = {
title: string;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
remoteOfficePresenceUrl: string;
remoteOfficeGatewayUrl: string;
remoteOfficeTokenConfigured: boolean;
};
export type StudioOfficePreferencePatch = {
title?: string | null;
remoteOfficeEnabled?: boolean;
remoteOfficeSourceKind?: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel?: string | null;
remoteOfficePresenceUrl?: string | null;
remoteOfficeGatewayUrl?: string | null;
remoteOfficeToken?: string | null;
};
export type StudioDeskAssignments = Record<string, string>;
@@ -102,8 +124,9 @@ export type StudioSettings = {
standup?: Record<string, StudioStandupPreference>;
};
export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "standup"> & {
export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "office" | "standup"> & {
gateway: StudioGatewaySettingsPublic | null;
office: Record<string, StudioOfficePreferencePublic>;
standup?: Record<string, StudioStandupPreferencePublic>;
};
@@ -271,6 +294,8 @@ const normalizeOptionalIsoString = (
};
const DEFAULT_OFFICE_TITLE = "Luke Headquarters";
const DEFAULT_REMOTE_OFFICE_LABEL = "Remote Office";
const DEFAULT_REMOTE_OFFICE_SOURCE_KIND = "presence_endpoint" as const;
const normalizeOfficeTitle = (
value: unknown,
@@ -280,8 +305,78 @@ const normalizeOfficeTitle = (
return (title || fallback).slice(0, 48);
};
const normalizeRemoteOfficeLabel = (
value: unknown,
fallback: string = DEFAULT_REMOTE_OFFICE_LABEL
) => {
const label = coerceString(value);
return (label || fallback).slice(0, 48);
};
const normalizeRemoteOfficePresenceUrl = (value: unknown) => {
const raw = coerceString(value);
return raw.replace(/\/+$/, "");
};
const normalizeRemoteOfficeSourceKind = (
value: unknown,
fallback: StudioOfficePreference["remoteOfficeSourceKind"] = DEFAULT_REMOTE_OFFICE_SOURCE_KIND,
): StudioOfficePreference["remoteOfficeSourceKind"] => {
const kind = coerceString(value);
if (kind === "presence_endpoint" || kind === "openclaw_gateway") {
return kind;
}
return fallback;
};
const normalizeRemoteOfficeGatewayUrl = (value: unknown) => {
const raw = coerceString(value);
if (!raw) return "";
try {
const parsed = new URL(raw);
if (parsed.protocol === "http:") {
return `ws://${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
if (parsed.protocol === "https:") {
return `wss://${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
return raw.replace(/\/+$/, "");
} catch {
return raw.replace(/\/+$/, "");
}
};
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
title: DEFAULT_OFFICE_TITLE,
remoteOfficeEnabled: false,
remoteOfficeSourceKind: DEFAULT_REMOTE_OFFICE_SOURCE_KIND,
remoteOfficeLabel: DEFAULT_REMOTE_OFFICE_LABEL,
remoteOfficePresenceUrl: "",
remoteOfficeGatewayUrl: "",
remoteOfficeToken: "",
});
export const defaultStudioOfficePreferencePublic =
(): StudioOfficePreferencePublic => ({
title: DEFAULT_OFFICE_TITLE,
remoteOfficeEnabled: false,
remoteOfficeSourceKind: DEFAULT_REMOTE_OFFICE_SOURCE_KIND,
remoteOfficeLabel: DEFAULT_REMOTE_OFFICE_LABEL,
remoteOfficePresenceUrl: "",
remoteOfficeGatewayUrl: "",
remoteOfficeTokenConfigured: false,
});
export const sanitizeStudioOfficePreference = (
value: StudioOfficePreference
): StudioOfficePreferencePublic => ({
title: value.title,
remoteOfficeEnabled: value.remoteOfficeEnabled,
remoteOfficeSourceKind: value.remoteOfficeSourceKind,
remoteOfficeLabel: value.remoteOfficeLabel,
remoteOfficePresenceUrl: value.remoteOfficePresenceUrl,
remoteOfficeGatewayUrl: value.remoteOfficeGatewayUrl,
remoteOfficeTokenConfigured: value.remoteOfficeToken.length > 0,
});
const normalizeStandupScheduleConfig = (
@@ -554,6 +649,26 @@ const normalizeOfficePreference = (
if (!isRecord(value)) return fallback;
return {
title: normalizeOfficeTitle(value.title, fallback.title),
remoteOfficeEnabled:
typeof value.remoteOfficeEnabled === "boolean"
? value.remoteOfficeEnabled
: fallback.remoteOfficeEnabled,
remoteOfficeSourceKind: normalizeRemoteOfficeSourceKind(
value.remoteOfficeSourceKind,
fallback.remoteOfficeSourceKind,
),
remoteOfficeLabel: normalizeRemoteOfficeLabel(
value.remoteOfficeLabel,
fallback.remoteOfficeLabel
),
remoteOfficePresenceUrl: normalizeRemoteOfficePresenceUrl(
value.remoteOfficePresenceUrl ?? value.remoteOfficeUrl,
),
remoteOfficeGatewayUrl: normalizeRemoteOfficeGatewayUrl(value.remoteOfficeGatewayUrl),
remoteOfficeToken:
value.remoteOfficeToken === null
? ""
: coerceString(value.remoteOfficeToken) || fallback.remoteOfficeToken,
};
};
@@ -610,6 +725,12 @@ export const sanitizeStudioSettings = (
): StudioSettingsPublic => ({
...value,
gateway: sanitizeStudioGatewaySettings(value.gateway),
office: Object.fromEntries(
Object.entries(value.office).map(([gatewayKey, preference]) => [
gatewayKey,
sanitizeStudioOfficePreference(preference),
]),
),
standup: Object.fromEntries(
Object.entries(value.standup ?? {}).map(([gatewayKey, preference]) => [
gatewayKey,
@@ -895,7 +1016,7 @@ export const resolveVoiceRepliesPreference = (
};
export const resolveOfficePreference = (
settings: StudioSettings | StudioSettingsPublic,
settings: StudioSettings,
gatewayUrl: string
): StudioOfficePreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
@@ -903,6 +1024,15 @@ export const resolveOfficePreference = (
return settings.office[gatewayKey] ?? defaultStudioOfficePreference();
};
export const resolveOfficePreferencePublic = (
settings: StudioSettingsPublic,
gatewayUrl: string
): StudioOfficePreferencePublic => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioOfficePreferencePublic();
return settings.office[gatewayKey] ?? defaultStudioOfficePreferencePublic();
};
export const resolveStandupPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string