2026-06-02 00:43:05 +03:00
|
|
|
import { getRuntimeUrlResolver } from './runtime-url';
|
|
|
|
|
import { subscribeRuntimeEndpointChanged } from './runtime-switch';
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
type ScheduledTaskRanEvent = {
|
2026-04-16 15:55:08 +03:00
|
|
|
type: 'scheduled-task-ran';
|
|
|
|
|
projectId: string;
|
|
|
|
|
taskId: string;
|
|
|
|
|
ranAt: number;
|
|
|
|
|
status: 'running' | 'success' | 'error';
|
|
|
|
|
sessionId?: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-24 21:54:28 +03:00
|
|
|
type SessionCreatedEvent = {
|
|
|
|
|
type: 'session-created';
|
|
|
|
|
sessionId: string;
|
|
|
|
|
directory: string;
|
|
|
|
|
projectId?: string;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
promptDispatched: boolean;
|
|
|
|
|
dispatchedAsCommand: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-13 22:44:13 +03:00
|
|
|
/**
|
|
|
|
|
* One in-app browser action requested by the agent tool. Broadcast to every
|
|
|
|
|
* connected client; only the one owning a browser view answers.
|
|
|
|
|
*/
|
|
|
|
|
type BrowserControlRequestEvent = {
|
|
|
|
|
type: 'browser-control-request';
|
|
|
|
|
requestId: string;
|
|
|
|
|
action: string;
|
|
|
|
|
parameters: Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type OpenChamberEvent = ScheduledTaskRanEvent | SessionCreatedEvent | BrowserControlRequestEvent;
|
2026-04-16 15:55:08 +03:00
|
|
|
type Listener = (event: OpenChamberEvent) => void;
|
|
|
|
|
|
|
|
|
|
let eventSource: EventSource | null = null;
|
|
|
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
2026-04-23 23:40:02 +03:00
|
|
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
2026-04-16 15:55:08 +03:00
|
|
|
let reconnectAttempt = 0;
|
2026-06-02 00:43:05 +03:00
|
|
|
let runtimeChangeUnsubscribe: (() => void) | null = null;
|
2026-04-16 15:55:08 +03:00
|
|
|
const listeners = new Set<Listener>();
|
|
|
|
|
|
|
|
|
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
2026-04-23 23:40:02 +03:00
|
|
|
const HEARTBEAT_TIMEOUT_MS = 45_000;
|
|
|
|
|
|
|
|
|
|
const clearHeartbeatTimer = () => {
|
|
|
|
|
if (!heartbeatTimer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearTimeout(heartbeatTimer);
|
|
|
|
|
heartbeatTimer = null;
|
|
|
|
|
};
|
2026-04-16 15:55:08 +03:00
|
|
|
|
|
|
|
|
const scheduleReconnect = () => {
|
|
|
|
|
if (reconnectTimer || listeners.size === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const delay = Math.min(1_000 * Math.pow(2, Math.min(reconnectAttempt, 5)), MAX_RECONNECT_DELAY_MS);
|
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
reconnectAttempt += 1;
|
|
|
|
|
connect();
|
|
|
|
|
}, delay);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cleanupSource = () => {
|
2026-04-23 23:40:02 +03:00
|
|
|
clearHeartbeatTimer();
|
2026-04-16 15:55:08 +03:00
|
|
|
if (eventSource) {
|
|
|
|
|
eventSource.close();
|
|
|
|
|
}
|
|
|
|
|
eventSource = null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-23 23:40:02 +03:00
|
|
|
const resetHeartbeatTimer = () => {
|
|
|
|
|
clearHeartbeatTimer();
|
|
|
|
|
if (listeners.size === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
heartbeatTimer = setTimeout(() => {
|
|
|
|
|
cleanupSource();
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
}, HEARTBEAT_TIMEOUT_MS);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
const parseEnvelope = (raw: string): { type: string; properties: unknown } | null => {
|
|
|
|
|
if (!raw || raw.trim().length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
const type = typeof parsed?.type === 'string' ? parsed.type : '';
|
|
|
|
|
const properties = parsed?.properties;
|
|
|
|
|
if (!type) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return { type, properties };
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-24 21:54:28 +03:00
|
|
|
const getEventProperties = (properties: unknown): Record<string, unknown> | null => {
|
|
|
|
|
if (!properties || typeof properties !== 'object') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return properties as Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) => {
|
|
|
|
|
if (envelope.type === 'openchamber:event-stream-ready') {
|
|
|
|
|
reconnectAttempt = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 23:40:02 +03:00
|
|
|
if (envelope.type === 'openchamber:heartbeat') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 21:54:28 +03:00
|
|
|
if (envelope.type === 'openchamber:session-created') {
|
|
|
|
|
const properties = getEventProperties(envelope.properties);
|
|
|
|
|
const sessionId = typeof properties?.sessionId === 'string' ? properties.sessionId : '';
|
|
|
|
|
const directory = typeof properties?.directory === 'string' ? properties.directory : '';
|
|
|
|
|
if (!sessionId || !directory) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextEvent: SessionCreatedEvent = {
|
|
|
|
|
type: 'session-created',
|
|
|
|
|
sessionId,
|
|
|
|
|
directory,
|
|
|
|
|
createdAt: typeof properties?.createdAt === 'number' ? properties.createdAt : Date.now(),
|
|
|
|
|
promptDispatched: properties?.promptDispatched === true,
|
|
|
|
|
dispatchedAsCommand: properties?.dispatchedAsCommand === true,
|
|
|
|
|
...(typeof properties?.projectId === 'string' && properties.projectId.length > 0
|
|
|
|
|
? { projectId: properties.projectId }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
for (const listener of listeners) {
|
|
|
|
|
listener(nextEvent);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 22:44:13 +03:00
|
|
|
if (envelope.type === 'openchamber:browser-control-request') {
|
|
|
|
|
const properties = getEventProperties(envelope.properties);
|
|
|
|
|
const requestId = typeof properties?.requestId === 'string' ? properties.requestId : '';
|
|
|
|
|
const action = typeof properties?.action === 'string' ? properties.action : '';
|
|
|
|
|
if (!requestId || !action) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawParameters = properties?.parameters;
|
|
|
|
|
const nextEvent: BrowserControlRequestEvent = {
|
|
|
|
|
type: 'browser-control-request',
|
|
|
|
|
requestId,
|
|
|
|
|
action,
|
|
|
|
|
parameters: rawParameters && typeof rawParameters === 'object' && !Array.isArray(rawParameters)
|
|
|
|
|
? rawParameters as Record<string, unknown>
|
|
|
|
|
: {},
|
|
|
|
|
};
|
|
|
|
|
for (const listener of listeners) {
|
|
|
|
|
listener(nextEvent);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
if (envelope.type !== 'openchamber:scheduled-task-ran') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 21:54:28 +03:00
|
|
|
const properties = getEventProperties(envelope.properties);
|
|
|
|
|
const projectId = typeof properties?.projectId === 'string' ? properties.projectId : '';
|
|
|
|
|
const taskId = typeof properties?.taskId === 'string' ? properties.taskId : '';
|
|
|
|
|
const ranAt = typeof properties?.ranAt === 'number' ? properties.ranAt : Date.now();
|
|
|
|
|
const rawStatus = properties?.status;
|
2026-04-16 15:55:08 +03:00
|
|
|
const status = rawStatus === 'running' || rawStatus === 'error' ? rawStatus : 'success';
|
|
|
|
|
if (!projectId || !taskId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextEvent: ScheduledTaskRanEvent = {
|
|
|
|
|
type: 'scheduled-task-ran',
|
|
|
|
|
projectId,
|
|
|
|
|
taskId,
|
|
|
|
|
ranAt,
|
|
|
|
|
status,
|
2026-07-24 21:54:28 +03:00
|
|
|
...(typeof properties?.sessionId === 'string' && properties.sessionId.length > 0
|
|
|
|
|
? { sessionId: properties.sessionId }
|
|
|
|
|
: {}),
|
2026-04-16 15:55:08 +03:00
|
|
|
};
|
|
|
|
|
for (const listener of listeners) {
|
|
|
|
|
listener(nextEvent);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const connect = () => {
|
|
|
|
|
if (typeof window === 'undefined' || listeners.size === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-23 23:40:02 +03:00
|
|
|
if (typeof EventSource !== 'function') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
cleanupSource();
|
|
|
|
|
|
2026-08-13 22:44:13 +03:00
|
|
|
// Tell the server what this client can do while the connection lasts. Only a
|
|
|
|
|
// Chromium host can drive a page; a browser tab can display one but not be
|
|
|
|
|
// driven, and the agent tool needs to know which it is talking to without a
|
|
|
|
|
// setting anyone has to remember to change.
|
|
|
|
|
const canControlBrowser = typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__);
|
|
|
|
|
const source = new EventSource(getRuntimeUrlResolver().sse(
|
|
|
|
|
'/api/openchamber/events',
|
|
|
|
|
canControlBrowser ? { browser: '1' } : undefined,
|
|
|
|
|
));
|
2026-04-23 23:40:02 +03:00
|
|
|
source.onopen = () => {
|
|
|
|
|
resetHeartbeatTimer();
|
|
|
|
|
};
|
2026-04-16 15:55:08 +03:00
|
|
|
source.onmessage = (event) => {
|
2026-04-23 23:40:02 +03:00
|
|
|
resetHeartbeatTimer();
|
2026-04-16 15:55:08 +03:00
|
|
|
const envelope = parseEnvelope(event.data);
|
|
|
|
|
if (!envelope) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
dispatchFromEnvelope(envelope);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
cleanupSource();
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
eventSource = source;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const ensureRuntimeChangeSubscription = () => {
|
|
|
|
|
if (runtimeChangeUnsubscribe || typeof window === 'undefined') return;
|
|
|
|
|
runtimeChangeUnsubscribe = subscribeRuntimeEndpointChanged(() => {
|
|
|
|
|
cleanupSource();
|
|
|
|
|
reconnectAttempt = 0;
|
|
|
|
|
connect();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cleanupRuntimeChangeSubscription = () => {
|
|
|
|
|
runtimeChangeUnsubscribe?.();
|
|
|
|
|
runtimeChangeUnsubscribe = null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
export const subscribeOpenchamberEvents = (listener: Listener): (() => void) => {
|
|
|
|
|
listeners.add(listener);
|
2026-06-02 00:43:05 +03:00
|
|
|
ensureRuntimeChangeSubscription();
|
2026-04-16 15:55:08 +03:00
|
|
|
connect();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
listeners.delete(listener);
|
|
|
|
|
if (listeners.size === 0) {
|
|
|
|
|
if (reconnectTimer) {
|
|
|
|
|
clearTimeout(reconnectTimer);
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
}
|
|
|
|
|
reconnectAttempt = 0;
|
|
|
|
|
cleanupSource();
|
2026-06-02 00:43:05 +03:00
|
|
|
cleanupRuntimeChangeSubscription();
|
2026-04-16 15:55:08 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
};
|