feat(event-stream): add global event stream and activity tracking
Add global event stream endpoint for cross-session activity monitoring Support multiple concurrent SSE subscriptions with scoped connections Enhance session activity detection from various event types
This commit is contained in:
@@ -127,6 +127,31 @@ export const useEventStream = () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [activeSessionDirectory, fallbackDirectory]);
|
}, [activeSessionDirectory, fallbackDirectory]);
|
||||||
|
|
||||||
|
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const normalized = trimmed.replace(/\\/g, '/');
|
||||||
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveSessionDirectoryForStatus = React.useCallback(
|
||||||
|
(sessionId: string | null | undefined): string | null => {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
try {
|
||||||
|
const metadata = getWorktreeMetadata?.(sessionId);
|
||||||
|
const metaPath = normalizeDirectory(metadata?.path ?? null);
|
||||||
|
if (metaPath) return metaPath;
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = sessions.find((entry) => entry.id === sessionId);
|
||||||
|
return normalizeDirectory((record as { directory?: string | null })?.directory ?? null);
|
||||||
|
},
|
||||||
|
[getWorktreeMetadata, normalizeDirectory, sessions]
|
||||||
|
);
|
||||||
|
|
||||||
const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus);
|
const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus);
|
||||||
const lastStatusRef = React.useRef<{ status: EventStreamStatus; hint: string | null } | null>(null);
|
const lastStatusRef = React.useRef<{ status: EventStreamStatus; hint: string | null } | null>(null);
|
||||||
|
|
||||||
@@ -240,6 +265,7 @@ export const useEventStream = () => {
|
|||||||
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastEventTimestampRef = React.useRef<number>(Date.now());
|
const lastEventTimestampRef = React.useRef<number>(Date.now());
|
||||||
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
||||||
|
const activityStreamAbortControllerRef = React.useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const maybeBootstrapIfStale = React.useCallback(
|
const maybeBootstrapIfStale = React.useCallback(
|
||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
@@ -266,6 +292,8 @@ export const useEventStream = () => {
|
|||||||
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
||||||
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
||||||
|
const previousSessionIdRef = React.useRef<string | null>(null);
|
||||||
|
const previousSessionDirectoryRef = React.useRef<string | null>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
currentSessionIdRef.current = currentSessionId;
|
currentSessionIdRef.current = currentSessionId;
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
@@ -361,28 +389,6 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
sessionStatusLastRefreshAtRef.current = now;
|
sessionStatusLastRefreshAtRef.current = now;
|
||||||
|
|
||||||
const normalizeDirectory = (value: string | null | undefined): string | null => {
|
|
||||||
if (typeof value !== 'string') return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const normalized = trimmed.replace(/\\/g, '/');
|
|
||||||
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveSessionDirectoryForStatus = (sessionId: string): string | null => {
|
|
||||||
try {
|
|
||||||
const metadata = getWorktreeMetadata?.(sessionId);
|
|
||||||
const metaPath = normalizeDirectory(metadata?.path ?? null);
|
|
||||||
if (metaPath) return metaPath;
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = sessions.find((entry) => entry.id === sessionId);
|
|
||||||
const recordPath = normalizeDirectory((record as { directory?: string | null })?.directory ?? null);
|
|
||||||
return recordPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
||||||
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
||||||
if (!sessionId || !raw) return;
|
if (!sessionId || !raw) return;
|
||||||
@@ -439,7 +445,109 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
sessionStatusRefreshInFlightRef.current = task;
|
sessionStatusRefreshInFlightRef.current = task;
|
||||||
return task;
|
return task;
|
||||||
}, [effectiveDirectory, getWorktreeMetadata, sessions, updateSessionActivityPhase]);
|
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, sessions, updateSessionActivityPhase]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const nextSessionId = currentSessionId ?? null;
|
||||||
|
const prevSessionId = previousSessionIdRef.current;
|
||||||
|
const nextDirectory = resolveSessionDirectoryForStatus(nextSessionId);
|
||||||
|
const prevDirectory = previousSessionDirectoryRef.current;
|
||||||
|
|
||||||
|
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||||
|
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||||
|
void refreshSessionActivityStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSessionIdRef.current = nextSessionId;
|
||||||
|
previousSessionDirectoryRef.current = nextDirectory;
|
||||||
|
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
|
||||||
|
|
||||||
|
const handleActivityEvent = React.useCallback((event: EventData) => {
|
||||||
|
if (!event?.type) return;
|
||||||
|
|
||||||
|
const props = (event.properties ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (event.type === 'openchamber:session-activity') {
|
||||||
|
const sessionId =
|
||||||
|
typeof props.sessionId === 'string'
|
||||||
|
? props.sessionId
|
||||||
|
: typeof props.sessionID === 'string'
|
||||||
|
? props.sessionID
|
||||||
|
: null;
|
||||||
|
const phase = typeof props.phase === 'string' ? props.phase : null;
|
||||||
|
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||||
|
updateSessionActivityPhase(sessionId, phase);
|
||||||
|
requestSessionListRefresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'session.status') {
|
||||||
|
const sessionId =
|
||||||
|
typeof props.sessionID === 'string'
|
||||||
|
? props.sessionID
|
||||||
|
: typeof props.sessionId === 'string'
|
||||||
|
? props.sessionId
|
||||||
|
: null;
|
||||||
|
const statusObj =
|
||||||
|
typeof props.status === 'object' && props.status !== null
|
||||||
|
? (props.status as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const statusType = typeof statusObj?.type === 'string' ? (statusObj.type as string) : null;
|
||||||
|
|
||||||
|
if (sessionId && statusType) {
|
||||||
|
updateSessionActivityPhase(
|
||||||
|
sessionId,
|
||||||
|
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
||||||
|
);
|
||||||
|
requestSessionListRefresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'session.idle') {
|
||||||
|
const sessionId =
|
||||||
|
typeof props.sessionID === 'string'
|
||||||
|
? props.sessionID
|
||||||
|
: typeof props.sessionId === 'string'
|
||||||
|
? props.sessionId
|
||||||
|
: null;
|
||||||
|
if (sessionId) {
|
||||||
|
updateSessionActivityPhase(sessionId, 'idle');
|
||||||
|
requestSessionListRefresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'message.updated' || event.type === 'message.part.updated') {
|
||||||
|
const messageInfo =
|
||||||
|
typeof props.info === 'object' && props.info !== null ? (props.info as Record<string, unknown>) : props;
|
||||||
|
|
||||||
|
const sessionId =
|
||||||
|
typeof (messageInfo as { sessionID?: unknown }).sessionID === 'string'
|
||||||
|
? (messageInfo as { sessionID?: string }).sessionID
|
||||||
|
: typeof (messageInfo as { sessionId?: unknown }).sessionId === 'string'
|
||||||
|
? (messageInfo as { sessionId?: string }).sessionId
|
||||||
|
: typeof props.sessionID === 'string'
|
||||||
|
? (props.sessionID as string)
|
||||||
|
: typeof props.sessionId === 'string'
|
||||||
|
? (props.sessionId as string)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const role = (messageInfo as { role?: unknown }).role;
|
||||||
|
const finish = (messageInfo as { finish?: unknown }).finish;
|
||||||
|
|
||||||
|
if (sessionId && role === 'assistant' && finish === 'stop') {
|
||||||
|
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
||||||
|
if (currentPhase === 'busy') {
|
||||||
|
updateSessionActivityPhase(sessionId, 'cooldown');
|
||||||
|
requestSessionListRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [requestSessionListRefresh, updateSessionActivityPhase]);
|
||||||
|
|
||||||
const handleEvent = React.useCallback((event: EventData) => {
|
const handleEvent = React.useCallback((event: EventData) => {
|
||||||
lastEventTimestampRef.current = Date.now();
|
lastEventTimestampRef.current = Date.now();
|
||||||
@@ -793,7 +901,8 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageId !== latestAssistantMessageId) break;
|
const isActiveSession = currentSessionId === sessionId;
|
||||||
|
if (isActiveSession && messageId !== latestAssistantMessageId) break;
|
||||||
|
|
||||||
if (!stopMarkerPresent && isDesktopRuntimeRef.current) {
|
if (!stopMarkerPresent && isDesktopRuntimeRef.current) {
|
||||||
trackMessage(messageId, 'desktop_completion_without_stop');
|
trackMessage(messageId, 'desktop_completion_without_stop');
|
||||||
@@ -1035,6 +1144,7 @@ export const useEventStream = () => {
|
|||||||
console.debug('[useEventStream] Connection state:', {
|
console.debug('[useEventStream] Connection state:', {
|
||||||
isDesktopRuntime: isDesktopRuntimeRef.current,
|
isDesktopRuntime: isDesktopRuntimeRef.current,
|
||||||
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
||||||
|
hasActivityStream: Boolean(activityStreamAbortControllerRef.current),
|
||||||
currentSessionId: currentSessionIdRef.current,
|
currentSessionId: currentSessionIdRef.current,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
onlineStatus: onlineStatusRef.current,
|
onlineStatus: onlineStatusRef.current,
|
||||||
@@ -1073,6 +1183,15 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activityStreamAbortControllerRef.current) {
|
||||||
|
try {
|
||||||
|
activityStreamAbortControllerRef.current.abort();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[useEventStream] Error during activity stream abort:', error);
|
||||||
|
}
|
||||||
|
activityStreamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
isCleaningUpRef.current = false;
|
isCleaningUpRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1159,7 +1278,143 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sdkUnsub = opencodeClient.subscribeToEvents(handleEvent, onError, onOpen, effectiveDirectory);
|
const sdkUnsub = opencodeClient.subscribeToEvents(
|
||||||
|
handleEvent,
|
||||||
|
onError,
|
||||||
|
onOpen,
|
||||||
|
effectiveDirectory,
|
||||||
|
{ scope: 'directory', key: 'events' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isDesktopRuntimeRef.current) {
|
||||||
|
if (activityStreamAbortControllerRef.current) {
|
||||||
|
activityStreamAbortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityAbortController = new AbortController();
|
||||||
|
activityStreamAbortControllerRef.current = activityAbortController;
|
||||||
|
|
||||||
|
const parseSseEventBlock = (block: string): EventData | null => {
|
||||||
|
if (!block) return null;
|
||||||
|
|
||||||
|
const dataLines = block
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadText = dataLines.join('\n').trim();
|
||||||
|
if (!payloadText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadText) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
if (typeof record.type === 'string') {
|
||||||
|
return record as unknown as EventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||||
|
const nestedRecord = nestedPayload as Record<string, unknown>;
|
||||||
|
if (typeof nestedRecord.type === 'string') {
|
||||||
|
return nestedRecord as unknown as EventData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const candidateEndpoints = ['/api/global/event', '/api/event'];
|
||||||
|
let response: Response | null = null;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (const endpoint of candidateEndpoints) {
|
||||||
|
try {
|
||||||
|
const candidateResponse = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
signal: activityAbortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidateResponse.ok && candidateResponse.body) {
|
||||||
|
response = candidateResponse;
|
||||||
|
if (streamDebugEnabled()) {
|
||||||
|
console.info('[useEventStream] Activity stream connected:', endpoint);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new Error(`Activity stream failed: ${candidateResponse.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw lastError ?? new Error('Activity stream failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = response.body;
|
||||||
|
if (!responseBody) {
|
||||||
|
throw new Error('Activity stream missing body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = responseBody.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (activityAbortController.signal.aborted) break;
|
||||||
|
if (!value || value.length === 0) continue;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||||
|
const blocks = buffer.split('\n\n');
|
||||||
|
buffer = blocks.pop() ?? '';
|
||||||
|
for (const block of blocks) {
|
||||||
|
const event = parseSseEventBlock(block);
|
||||||
|
if (event) {
|
||||||
|
handleActivityEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = buffer.trim();
|
||||||
|
if (remaining) {
|
||||||
|
const event = parseSseEventBlock(remaining);
|
||||||
|
if (event) {
|
||||||
|
handleActivityEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!activityAbortController.signal.aborted) {
|
||||||
|
console.warn('[useEventStream] Activity stream error:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (activityStreamAbortControllerRef.current === activityAbortController) {
|
||||||
|
activityStreamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
const compositeUnsub = () => {
|
const compositeUnsub = () => {
|
||||||
try {
|
try {
|
||||||
@@ -1173,6 +1428,10 @@ export const useEventStream = () => {
|
|||||||
unsubscribeRef.current = compositeUnsub;
|
unsubscribeRef.current = compositeUnsub;
|
||||||
} else {
|
} else {
|
||||||
compositeUnsub();
|
compositeUnsub();
|
||||||
|
if (activityStreamAbortControllerRef.current) {
|
||||||
|
activityStreamAbortControllerRef.current.abort();
|
||||||
|
activityStreamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (subscriptionError) {
|
} catch (subscriptionError) {
|
||||||
console.error('[useEventStream] Error during subscription:', subscriptionError);
|
console.error('[useEventStream] Error during subscription:', subscriptionError);
|
||||||
@@ -1186,6 +1445,7 @@ export const useEventStream = () => {
|
|||||||
resyncMessages,
|
resyncMessages,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
|
handleActivityEvent,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
refreshSessionActivityStatus,
|
refreshSessionActivityStatus,
|
||||||
waitForDesktopBridge,
|
waitForDesktopBridge,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ const getDesktopFilesApi = (): FilesAPI | null => {
|
|||||||
class OpencodeService {
|
class OpencodeService {
|
||||||
private client: OpencodeClient;
|
private client: OpencodeClient;
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private sseAbortController: AbortController | null = null;
|
private sseAbortControllers: Map<string, AbortController> = new Map();
|
||||||
private currentDirectory: string | undefined = undefined;
|
private currentDirectory: string | undefined = undefined;
|
||||||
|
|
||||||
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
||||||
@@ -835,16 +835,18 @@ class OpencodeService {
|
|||||||
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
|
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
|
||||||
onError?: (error: unknown) => void,
|
onError?: (error: unknown) => void,
|
||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
directoryOverride?: string | null
|
directoryOverride?: string | null,
|
||||||
|
options?: { scope?: 'global' | 'directory'; key?: string }
|
||||||
): () => void {
|
): () => void {
|
||||||
// Stop any existing subscription
|
const subscriptionKey = options?.key ?? 'default';
|
||||||
if (this.sseAbortController) {
|
const existingController = this.sseAbortControllers.get(subscriptionKey);
|
||||||
this.sseAbortController.abort();
|
if (existingController) {
|
||||||
|
existingController.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new AbortController for this subscription
|
// Create new AbortController for this subscription
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
this.sseAbortController = abortController;
|
this.sseAbortControllers.set(subscriptionKey, abortController);
|
||||||
|
|
||||||
let lastEventId: string | undefined;
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
@@ -853,10 +855,12 @@ class OpencodeService {
|
|||||||
// Start async generator in background with reconnect on failure
|
// Start async generator in background with reconnect on failure
|
||||||
(async () => {
|
(async () => {
|
||||||
const resolvedDirectory =
|
const resolvedDirectory =
|
||||||
typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
options?.scope === 'global'
|
||||||
? directoryOverride.trim()
|
? undefined
|
||||||
: this.currentDirectory;
|
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
||||||
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory);
|
? directoryOverride.trim()
|
||||||
|
: this.currentDirectory;
|
||||||
|
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
|
||||||
|
|
||||||
const connect = async (attempt: number): Promise<void> => {
|
const connect = async (attempt: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -939,16 +943,16 @@ class OpencodeService {
|
|||||||
await connect(0);
|
await connect(0);
|
||||||
} finally {
|
} finally {
|
||||||
console.log('[OpencodeClient] SSE subscription cleanup');
|
console.log('[OpencodeClient] SSE subscription cleanup');
|
||||||
if (this.sseAbortController === abortController) {
|
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
|
||||||
this.sseAbortController = null;
|
this.sseAbortControllers.delete(subscriptionKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Return cleanup function
|
// Return cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
if (this.sseAbortController === abortController) {
|
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
|
||||||
this.sseAbortController = null;
|
this.sseAbortControllers.delete(subscriptionKey);
|
||||||
}
|
}
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export class AgentManagerPanelProvider {
|
|||||||
|
|
||||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||||
|
const shouldInjectActivity = normalizedPath === '/event' || normalizedPath === '/global/event';
|
||||||
|
|
||||||
if (!apiBaseUrl) {
|
if (!apiBaseUrl) {
|
||||||
return {
|
return {
|
||||||
@@ -211,11 +212,12 @@ export class AgentManagerPanelProvider {
|
|||||||
if (!chunk) continue;
|
if (!chunk) continue;
|
||||||
|
|
||||||
// Reduce webview message pressure by forwarding complete SSE blocks.
|
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||||
sseBuffer += chunk;
|
sseBuffer += shouldInjectActivity ? chunk.replace(/\r\n/g, '\n') : chunk;
|
||||||
const blocks = sseBuffer.split('\n\n');
|
const blocks = sseBuffer.split('\n\n');
|
||||||
sseBuffer = blocks.pop() ?? '';
|
sseBuffer = blocks.pop() ?? '';
|
||||||
if (blocks.length > 0) {
|
if (blocks.length > 0) {
|
||||||
const joined = blocks.map((block) => `${block}\n\n`).join('');
|
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
|
||||||
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,10 +225,16 @@ export class AgentManagerPanelProvider {
|
|||||||
|
|
||||||
const tail = decoder.decode();
|
const tail = decoder.decode();
|
||||||
if (tail) {
|
if (tail) {
|
||||||
sseBuffer += tail;
|
sseBuffer += shouldInjectActivity ? tail.replace(/\r\n/g, '\n') : tail;
|
||||||
}
|
}
|
||||||
if (sseBuffer) {
|
if (sseBuffer) {
|
||||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
if (shouldInjectActivity) {
|
||||||
|
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
|
||||||
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
|
} else {
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -286,3 +294,116 @@ export class AgentManagerPanelProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||||
|
|
||||||
|
type SessionActivity = {
|
||||||
|
sessionId: string;
|
||||||
|
phase: SessionActivityPhase;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
|
||||||
|
if (!block) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataLines = block
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadText = dataLines.join('\n').trim();
|
||||||
|
if (!payloadText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadText) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||||
|
return nestedPayload as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = payload.type;
|
||||||
|
const properties = payload.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (type === 'session.status') {
|
||||||
|
const status = properties?.status as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||||
|
const statusType = status?.type;
|
||||||
|
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||||
|
const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
|
||||||
|
return { sessionId, phase };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'message.updated') {
|
||||||
|
const info = properties?.info as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||||
|
const role = info?.role;
|
||||||
|
const finish = info?.finish;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||||
|
return { sessionId, phase: 'cooldown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'message.part.updated') {
|
||||||
|
const info = properties?.info as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||||
|
const role = info?.role;
|
||||||
|
const finish = info?.finish;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||||
|
return { sessionId, phase: 'cooldown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'session.idle') {
|
||||||
|
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
|
return { sessionId, phase: 'idle' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||||
|
return `data: ${JSON.stringify({
|
||||||
|
type: 'openchamber:session-activity',
|
||||||
|
properties: {
|
||||||
|
sessionId: activity.sessionId,
|
||||||
|
phase: activity.phase,
|
||||||
|
},
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||||
|
const expanded: string[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
expanded.push(block);
|
||||||
|
const activity = deriveSessionActivity(parseSseDataPayload(block));
|
||||||
|
if (activity) {
|
||||||
|
expanded.push(buildActivityEventBlock(activity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
};
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
|
|
||||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||||
|
const shouldInjectActivity = normalizedPath === '/event' || normalizedPath === '/global/event';
|
||||||
|
|
||||||
if (!apiBaseUrl) {
|
if (!apiBaseUrl) {
|
||||||
return {
|
return {
|
||||||
@@ -246,11 +247,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
// Reduce webview message pressure by forwarding complete SSE blocks.
|
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||||
// The SDK SSE parser is block-based (\n\n delimited) and can consume
|
// The SDK SSE parser is block-based (\n\n delimited) and can consume
|
||||||
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
|
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
|
||||||
sseBuffer += chunk;
|
sseBuffer += shouldInjectActivity ? chunk.replace(/\r\n/g, '\n') : chunk;
|
||||||
const blocks = sseBuffer.split('\n\n');
|
const blocks = sseBuffer.split('\n\n');
|
||||||
sseBuffer = blocks.pop() ?? '';
|
sseBuffer = blocks.pop() ?? '';
|
||||||
if (blocks.length > 0) {
|
if (blocks.length > 0) {
|
||||||
const joined = blocks.map((block) => `${block}\n\n`).join('');
|
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
|
||||||
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,10 +260,16 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
|
|
||||||
const tail = decoder.decode();
|
const tail = decoder.decode();
|
||||||
if (tail) {
|
if (tail) {
|
||||||
sseBuffer += tail;
|
sseBuffer += shouldInjectActivity ? tail.replace(/\r\n/g, '\n') : tail;
|
||||||
}
|
}
|
||||||
if (sseBuffer) {
|
if (sseBuffer) {
|
||||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
if (shouldInjectActivity) {
|
||||||
|
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
|
||||||
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
|
} else {
|
||||||
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -322,3 +330,116 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||||
|
|
||||||
|
type SessionActivity = {
|
||||||
|
sessionId: string;
|
||||||
|
phase: SessionActivityPhase;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
|
||||||
|
if (!block) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataLines = block
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadText = dataLines.join('\n').trim();
|
||||||
|
if (!payloadText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadText) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||||
|
return nestedPayload as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = payload.type;
|
||||||
|
const properties = payload.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (type === 'session.status') {
|
||||||
|
const status = properties?.status as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||||
|
const statusType = status?.type;
|
||||||
|
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||||
|
const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
|
||||||
|
return { sessionId, phase };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'message.updated') {
|
||||||
|
const info = properties?.info as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||||
|
const role = info?.role;
|
||||||
|
const finish = info?.finish;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||||
|
return { sessionId, phase: 'cooldown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'message.part.updated') {
|
||||||
|
const info = properties?.info as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||||
|
const role = info?.role;
|
||||||
|
const finish = info?.finish;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||||
|
return { sessionId, phase: 'cooldown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'session.idle') {
|
||||||
|
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
|
return { sessionId, phase: 'idle' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||||
|
return `data: ${JSON.stringify({
|
||||||
|
type: 'openchamber:session-activity',
|
||||||
|
properties: {
|
||||||
|
sessionId: activity.sessionId,
|
||||||
|
phase: activity.phase,
|
||||||
|
},
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||||
|
const expanded: string[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
expanded.push(block);
|
||||||
|
const activity = deriveSessionActivity(parseSseDataPayload(block));
|
||||||
|
if (activity) {
|
||||||
|
expanded.push(buildActivityEventBlock(activity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
};
|
||||||
|
|||||||
@@ -937,7 +937,16 @@ function parseSseDataPayload(block) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(payloadText);
|
const parsed = JSON.parse(payloadText);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
typeof parsed.payload === 'object' &&
|
||||||
|
parsed.payload !== null
|
||||||
|
) {
|
||||||
|
return parsed.payload;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -950,7 +959,7 @@ function deriveSessionActivity(payload) {
|
|||||||
|
|
||||||
if (payload.type === 'session.status') {
|
if (payload.type === 'session.status') {
|
||||||
const status = payload.properties?.status;
|
const status = payload.properties?.status;
|
||||||
const sessionId = payload.properties?.sessionID;
|
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||||
const statusType = status?.type;
|
const statusType = status?.type;
|
||||||
|
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||||
@@ -961,7 +970,17 @@ function deriveSessionActivity(payload) {
|
|||||||
|
|
||||||
if (payload.type === 'message.updated') {
|
if (payload.type === 'message.updated') {
|
||||||
const info = payload.properties?.info;
|
const info = payload.properties?.info;
|
||||||
const sessionId = info?.sessionID;
|
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||||
|
const role = info?.role;
|
||||||
|
const finish = info?.finish;
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||||
|
return { sessionId, phase: 'cooldown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'message.part.updated') {
|
||||||
|
const info = payload.properties?.info;
|
||||||
|
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||||
const role = info?.role;
|
const role = info?.role;
|
||||||
const finish = info?.finish;
|
const finish = info?.finish;
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||||
@@ -970,7 +989,7 @@ function deriveSessionActivity(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'session.idle') {
|
if (payload.type === 'session.idle') {
|
||||||
const sessionId = payload.properties?.sessionID;
|
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
return { sessionId, phase: 'idle' };
|
return { sessionId, phase: 'idle' };
|
||||||
}
|
}
|
||||||
@@ -2072,11 +2091,119 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/event', async (req, res) => {
|
app.get('/api/global/event', async (req, res) => {
|
||||||
if (!openCodePort) {
|
if (!openCodeApiPrefixDetected) {
|
||||||
|
try {
|
||||||
|
await detectOpenCodeApiPrefix();
|
||||||
|
} catch {
|
||||||
|
// ignore detection failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetUrl;
|
||||||
|
try {
|
||||||
|
const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
|
||||||
|
targetUrl = new URL(buildOpenCodeUrl('/global/event', prefix));
|
||||||
|
} catch (error) {
|
||||||
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastEventId = req.header('Last-Event-ID');
|
||||||
|
if (typeof lastEventId === 'string' && lastEventId.length > 0) {
|
||||||
|
headers['Last-Event-ID'] = lastEventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cleanup = () => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
req.on('close', cleanup);
|
||||||
|
req.on('error', cleanup);
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(targetUrl.toString(), {
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
if (typeof res.flushHeaders === 'function') {
|
||||||
|
res.flushHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const forwardBlock = (block) => {
|
||||||
|
if (!block) return;
|
||||||
|
res.write(`${block}\n\n`);
|
||||||
|
const payload = parseSseDataPayload(block);
|
||||||
|
const activity = deriveSessionActivity(payload);
|
||||||
|
if (activity) {
|
||||||
|
writeSseEvent(res, {
|
||||||
|
type: 'openchamber:session-activity',
|
||||||
|
properties: {
|
||||||
|
sessionId: activity.sessionId,
|
||||||
|
phase: activity.phase,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
let separatorIndex;
|
||||||
|
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
||||||
|
const block = buffer.slice(0, separatorIndex);
|
||||||
|
buffer = buffer.slice(separatorIndex + 2);
|
||||||
|
forwardBlock(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim().length > 0) {
|
||||||
|
forwardBlock(buffer.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
console.warn('SSE proxy stream error:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
res.end();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/event', async (req, res) => {
|
||||||
if (!openCodeApiPrefixDetected) {
|
if (!openCodeApiPrefixDetected) {
|
||||||
try {
|
try {
|
||||||
await detectOpenCodeApiPrefix();
|
await detectOpenCodeApiPrefix();
|
||||||
|
|||||||
Reference in New Issue
Block a user