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;
|
||||
}, [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 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 lastEventTimestampRef = React.useRef<number>(Date.now());
|
||||
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
||||
const activityStreamAbortControllerRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
const maybeBootstrapIfStale = React.useCallback(
|
||||
(reason: string) => {
|
||||
@@ -266,6 +292,8 @@ export const useEventStream = () => {
|
||||
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
||||
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
||||
const previousSessionIdRef = React.useRef<string | null>(null);
|
||||
const previousSessionDirectoryRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
@@ -361,28 +389,6 @@ export const useEventStream = () => {
|
||||
}
|
||||
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 }>) => {
|
||||
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
||||
if (!sessionId || !raw) return;
|
||||
@@ -439,7 +445,109 @@ export const useEventStream = () => {
|
||||
|
||||
sessionStatusRefreshInFlightRef.current = 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) => {
|
||||
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) {
|
||||
trackMessage(messageId, 'desktop_completion_without_stop');
|
||||
@@ -1035,6 +1144,7 @@ export const useEventStream = () => {
|
||||
console.debug('[useEventStream] Connection state:', {
|
||||
isDesktopRuntime: isDesktopRuntimeRef.current,
|
||||
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
||||
hasActivityStream: Boolean(activityStreamAbortControllerRef.current),
|
||||
currentSessionId: currentSessionIdRef.current,
|
||||
effectiveDirectory,
|
||||
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;
|
||||
}, []);
|
||||
|
||||
@@ -1159,7 +1278,143 @@ export const useEventStream = () => {
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
try {
|
||||
@@ -1173,6 +1428,10 @@ export const useEventStream = () => {
|
||||
unsubscribeRef.current = compositeUnsub;
|
||||
} else {
|
||||
compositeUnsub();
|
||||
if (activityStreamAbortControllerRef.current) {
|
||||
activityStreamAbortControllerRef.current.abort();
|
||||
activityStreamAbortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (subscriptionError) {
|
||||
console.error('[useEventStream] Error during subscription:', subscriptionError);
|
||||
@@ -1186,6 +1445,7 @@ export const useEventStream = () => {
|
||||
resyncMessages,
|
||||
requestSessionMetadataRefresh,
|
||||
handleEvent,
|
||||
handleActivityEvent,
|
||||
effectiveDirectory,
|
||||
refreshSessionActivityStatus,
|
||||
waitForDesktopBridge,
|
||||
|
||||
@@ -129,7 +129,7 @@ const getDesktopFilesApi = (): FilesAPI | null => {
|
||||
class OpencodeService {
|
||||
private client: OpencodeClient;
|
||||
private baseUrl: string;
|
||||
private sseAbortController: AbortController | null = null;
|
||||
private sseAbortControllers: Map<string, AbortController> = new Map();
|
||||
private currentDirectory: string | undefined = undefined;
|
||||
|
||||
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
||||
@@ -835,16 +835,18 @@ class OpencodeService {
|
||||
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
|
||||
onError?: (error: unknown) => void,
|
||||
onOpen?: () => void,
|
||||
directoryOverride?: string | null
|
||||
directoryOverride?: string | null,
|
||||
options?: { scope?: 'global' | 'directory'; key?: string }
|
||||
): () => void {
|
||||
// Stop any existing subscription
|
||||
if (this.sseAbortController) {
|
||||
this.sseAbortController.abort();
|
||||
const subscriptionKey = options?.key ?? 'default';
|
||||
const existingController = this.sseAbortControllers.get(subscriptionKey);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
}
|
||||
|
||||
// Create new AbortController for this subscription
|
||||
const abortController = new AbortController();
|
||||
this.sseAbortController = abortController;
|
||||
this.sseAbortControllers.set(subscriptionKey, abortController);
|
||||
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
@@ -853,10 +855,12 @@ class OpencodeService {
|
||||
// Start async generator in background with reconnect on failure
|
||||
(async () => {
|
||||
const resolvedDirectory =
|
||||
typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
||||
? directoryOverride.trim()
|
||||
: this.currentDirectory;
|
||||
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory);
|
||||
options?.scope === 'global'
|
||||
? undefined
|
||||
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
||||
? directoryOverride.trim()
|
||||
: this.currentDirectory;
|
||||
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
|
||||
|
||||
const connect = async (attempt: number): Promise<void> => {
|
||||
try {
|
||||
@@ -939,16 +943,16 @@ class OpencodeService {
|
||||
await connect(0);
|
||||
} finally {
|
||||
console.log('[OpencodeClient] SSE subscription cleanup');
|
||||
if (this.sseAbortController === abortController) {
|
||||
this.sseAbortController = null;
|
||||
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
|
||||
this.sseAbortControllers.delete(subscriptionKey);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (this.sseAbortController === abortController) {
|
||||
this.sseAbortController = null;
|
||||
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
|
||||
this.sseAbortControllers.delete(subscriptionKey);
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
|
||||
@@ -144,6 +144,7 @@ export class AgentManagerPanelProvider {
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
const shouldInjectActivity = normalizedPath === '/event' || normalizedPath === '/global/event';
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
return {
|
||||
@@ -211,11 +212,12 @@ export class AgentManagerPanelProvider {
|
||||
if (!chunk) continue;
|
||||
|
||||
// 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');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -223,10 +225,16 @@ export class AgentManagerPanelProvider {
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
sseBuffer += tail;
|
||||
sseBuffer += shouldInjectActivity ? tail.replace(/\r\n/g, '\n') : tail;
|
||||
}
|
||||
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 {
|
||||
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 normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
const shouldInjectActivity = normalizedPath === '/event' || normalizedPath === '/global/event';
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
return {
|
||||
@@ -246,11 +247,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||
// 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.
|
||||
sseBuffer += chunk;
|
||||
sseBuffer += shouldInjectActivity ? chunk.replace(/\r\n/g, '\n') : chunk;
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -258,10 +260,16 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
sseBuffer += tail;
|
||||
sseBuffer += shouldInjectActivity ? tail.replace(/\r\n/g, '\n') : tail;
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
@@ -950,7 +959,7 @@ function deriveSessionActivity(payload) {
|
||||
|
||||
if (payload.type === 'session.status') {
|
||||
const status = payload.properties?.status;
|
||||
const sessionId = payload.properties?.sessionID;
|
||||
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||
const statusType = status?.type;
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||
@@ -961,7 +970,17 @@ function deriveSessionActivity(payload) {
|
||||
|
||||
if (payload.type === 'message.updated') {
|
||||
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 finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
@@ -970,7 +989,7 @@ function deriveSessionActivity(payload) {
|
||||
}
|
||||
|
||||
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) {
|
||||
return { sessionId, phase: 'idle' };
|
||||
}
|
||||
@@ -2072,11 +2091,119 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/event', async (req, res) => {
|
||||
if (!openCodePort) {
|
||||
app.get('/api/global/event', async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
await detectOpenCodeApiPrefix();
|
||||
|
||||
Reference in New Issue
Block a user