qwen-code/packages/webui/src/daemon/session/DaemonSessionProvider.tsx
ytahdn e64010c116
Fix workspace skills for disabled extensions and ACP preheat (#6534)
* fix(cli): keep workspace skills in sync with extensions

* fix(cli): address workspace skills review feedback

* test(cli): cover synthesized inactive extension skills

* fix(cli): address workspace skills review issues

* fix(cli): address workspace skills review followups

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
2026-07-09 09:11:17 +00:00

2316 lines
86 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
createContext,
type Dispatch,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type SetStateAction,
useSyncExternalStore,
} from 'react';
import {
DaemonClient,
DaemonHttpError,
DaemonSessionClient,
createDaemonTranscriptStore,
extractServerTimestamp,
matchTurnEvent,
normalizeDaemonEvent,
type DaemonEvent,
type DaemonTranscriptBlock,
type DaemonTranscriptState,
type DaemonTranscriptStore,
type DaemonTurnCompleteData,
type DaemonUiEvent,
} from '@qwen-code/sdk/daemon';
import { createDaemonSessionActions, getPromptSettledKey } from './actions.js';
import {
detachDaemonClient,
getStableClientId,
persistStableClientId,
} from './clientLifecycle.js';
import { useOptionalDaemonWorkspace } from '../workspace/DaemonWorkspaceProvider.js';
import {
getCurrentMode,
getSessionDisplayName,
getReplayTokenUsage,
getTokenCountFromUsage,
mapProviderStatus,
mapSessionContextModels,
mapSupportedCommands,
mapWorkspaceSkills,
updateConnectionFromDaemonEvent,
} from './mappers.js';
import {
selectDaemonActiveTodoList,
selectDaemonPendingPermissions,
selectDaemonStreamingState,
} from './selectors.js';
import {
clearPassiveAssistantDoneTimer,
delay,
getReconnectDelayMs,
schedulePassiveAssistantDone,
type TimerRef,
} from '../timing.js';
import {
parseSidechannelFollowupSuggestion,
publishSidechannelFollowupSuggestion,
} from '../followupSidechannel.js';
import {
parseSidechannelMidTurnInjected,
publishSidechannelMidTurnInjected,
} from '../midTurnInjectedSidechannel.js';
import {
isPendingPromptEvent,
publishPendingPromptEvent,
} from '../pendingPromptVersion.js';
import {
MISSING_SESSION_HTTP_STATUSES,
isMissingSessionHttpStatus,
resolveConnectionErrorStatus,
} from './status.js';
import type {
ActivePrompt,
AddDaemonSessionNotice,
DaemonConnectionState,
DaemonPromptStatus,
DaemonSessionActions,
DaemonSessionContextValue,
DaemonSessionNotice,
DaemonSessionProviderProps,
DaemonWorkspaceEventSignals,
PendingSessionLoad,
SettledPrompt,
} from './types.js';
export type {
DaemonCommandInfo,
DaemonConnectionState,
DaemonConnectionStatus,
DaemonModelInfo,
DaemonNoticeCategory,
DaemonNoticeOperation,
DaemonNoticeSeverity,
DaemonPromptImage,
DaemonPromptStatus,
DaemonSessionActions,
DaemonSessionContextValue,
DaemonSessionNotice,
DaemonSessionProviderProps,
DaemonTodoItem,
DaemonTodoList,
DaemonTodoPriority,
DaemonTodoStatus,
DaemonWorkspaceEventSignals,
SendPromptOptions,
} from './types.js';
function assistantDoneFromTurnEvent(
event: DaemonEvent,
reason: string,
): DaemonUiEvent {
const serverTimestamp = extractServerTimestamp(event);
return {
type: 'assistant.done',
reason,
eventId: event.id,
...(serverTimestamp !== undefined ? { serverTimestamp } : {}),
};
}
const DaemonStoreContext = createContext<DaemonTranscriptStore | undefined>(
undefined,
);
const DaemonConnectionContext = createContext<
DaemonConnectionState | undefined
>(undefined);
const DaemonActionsContext = createContext<DaemonSessionActions | undefined>(
undefined,
);
const DaemonPromptStatusContext = createContext<DaemonPromptStatus | undefined>(
undefined,
);
interface SessionNoticesValue {
notices: readonly DaemonSessionNotice[];
dismissNotice(id: string): void;
clearNotices(): void;
}
type SessionNoticeInput = Parameters<AddDaemonSessionNotice>[0];
const DaemonSessionNoticesContext = createContext<
SessionNoticesValue | undefined
>(undefined);
const DaemonWorkspaceEventSignalsContext = createContext<
DaemonWorkspaceEventSignals | undefined
>(undefined);
/**
* Subset of TERMINAL_SESSION_HTTP_STATUSES that represent **credential
* failures** (vs session-not-found 404/410). Auth failures should NOT enter
* the reconnect loop even when `autoReconnect: true` — retrying with the
* same bad token loops forever, hammering the server with bad credentials
* and risking transcript wipes if reconnect later attaches a different
* session and hits the sessionId-change `store.reset()` branch.
*
* 404/410 (session-not-found) leave the requested session disconnected instead
* of silently creating a replacement empty session.
*/
const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]);
const TERMINAL_SESSION_HTTP_STATUSES = new Set([
...AUTH_FAILURE_HTTP_STATUSES,
...MISSING_SESSION_HTTP_STATUSES,
]);
interface HeartbeatFailureState {
sessionId?: string;
consecutiveFailures: number;
lastHttpError?: { status: number; message: string };
}
// Keep enough transcript history for large daemon replay streams so event order
// and subagent grouping survive replay. Rendering is virtualized, but message
// normalization still rebuilds from retained blocks today, so this high default
// is a history-preservation tradeoff rather than a claim that large transcripts
// are CPU-free. Callers can pass a smaller maxBlocks in constrained contexts.
const DEFAULT_MAX_BLOCKS = 200_000;
const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
memoryVersion: 0,
agentsVersion: 0,
toolsVersion: 0,
settingsVersion: 0,
mcpVersion: 0,
extensionsVersion: 0,
initVersion: 0,
authVersion: 0,
};
const UNHANDLED_SESSION = Symbol('unhandled session');
export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const {
baseUrl,
token,
workspaceCwd,
sessionId,
clientId,
createSessionRequest,
maxQueued = 1024,
maxBlocks = DEFAULT_MAX_BLOCKS,
suppressOwnUserEcho = true,
includeRawEvent = false,
autoConnect = true,
autoReconnect = true,
reconnectDelayMs = 1_000,
maxReconnectDelayMs = 10_000,
heartbeatIntervalMs = 30_000,
heartbeatFailureThreshold = 3,
loadWarnings,
children,
} = props;
const workspace = useOptionalDaemonWorkspace();
const resolvedBaseUrl = baseUrl ?? workspace?.baseUrl;
const resolvedToken = token ?? workspace?.token;
const resolvedWorkspaceCwd = workspaceCwd ?? workspace?.workspaceCwd;
const workspaceClientRef = useRef(workspace?.client);
workspaceClientRef.current = workspace?.client;
const workspaceCapabilitiesRef = useRef(workspace?.capabilities);
workspaceCapabilitiesRef.current = workspace?.capabilities;
const workspaceGetCapabilitiesRef = useRef(workspace?.getCapabilities);
workspaceGetCapabilitiesRef.current = workspace?.getCapabilities;
const workspaceAcpPreheatInFlightRef = useRef(false);
const initialRestoreSessionIdRef = useRef(sessionId);
const initialRestoreSessionId = initialRestoreSessionIdRef.current;
// Captured once at mount: if the host did not provide an initial session,
// keep the provider empty until the first prompt creates one. Later
// sessionId prop changes are handled by the controlled-session effect below.
const shouldDeferInitialSessionCreation =
initialRestoreSessionId === undefined;
const resolvedWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
resolvedWorkspaceCwdRef.current = resolvedWorkspaceCwd;
const activeWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
if (resolvedWorkspaceCwd) {
activeWorkspaceCwdRef.current = resolvedWorkspaceCwd;
}
const store = useMemo(
() => createDaemonTranscriptStore({ maxBlocks }),
[maxBlocks],
);
const sessionRef = useRef<DaemonSessionClient | undefined>(undefined);
const lastSessionIdRef = useRef<string | undefined>(undefined);
const activePromptsRef = useRef<Map<string, ActivePrompt>>(new Map());
const settledPromptsRef = useRef<Map<string, SettledPrompt>>(new Map());
const pendingSessionLoadRef = useRef<PendingSessionLoad | undefined>(
undefined,
);
const pendingSessionLoadIdRef = useRef(0);
const passiveAssistantDoneTimerRef = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);
const heartbeatSupportedRef = useRef(false);
const heartbeatFailureStateRef = useRef<HeartbeatFailureState>({
consecutiveFailures: 0,
});
const manualSessionClearRef = useRef(false);
const skipNextCleanupDetachSessionIdRef = useRef<string | undefined>(
undefined,
);
const settledRestoredActivePromptSessionsRef = useRef<
WeakSet<DaemonSessionClient>
>(new WeakSet());
const eventOptionsRef = useRef({ suppressOwnUserEcho, includeRawEvent });
const reconnectConfigRef = useRef({ reconnectDelayMs, maxReconnectDelayMs });
const loadWarningsRef = useRef(loadWarnings);
const clientIdRef = useRef<string | undefined>(undefined);
if (!clientIdRef.current || clientId) {
clientIdRef.current = getStableClientId(clientId);
}
eventOptionsRef.current = { suppressOwnUserEcho, includeRawEvent };
reconnectConfigRef.current = { reconnectDelayMs, maxReconnectDelayMs };
loadWarningsRef.current = loadWarnings;
const modelServiceId = createSessionRequest?.modelServiceId;
const sessionScope = createSessionRequest?.sessionScope;
const createSessionRequestRef = useRef(createSessionRequest);
createSessionRequestRef.current = createSessionRequest;
const [promptStatus, setPromptStatus] = useState<DaemonPromptStatus>('idle');
const [restoreSessionId, setRestoreSessionId] = useState<string | undefined>(
initialRestoreSessionId,
);
const [restoreMode, setRestoreMode] = useState<'load' | 'resume'>('load');
const [restoreSessionNonce, setRestoreSessionNonce] = useState(0);
const [attachSessionNonce, setAttachSessionNonce] = useState(0);
const [newSessionNonce, setNewSessionNonce] = useState(0);
const [connection, setConnection] = useState<DaemonConnectionState>({
status: autoConnect ? 'connecting' : 'idle',
...(initialRestoreSessionId ? { sessionId: initialRestoreSessionId } : {}),
});
const connectionRef = useRef(connection);
connectionRef.current = connection;
const noticeIdRef = useRef(0);
const [notices, setNotices] = useState<DaemonSessionNotice[]>([]);
const addNotice = useCallback<AddDaemonSessionNotice>((input) => {
const notice: DaemonSessionNotice = {
...input,
id: input.id ?? `daemon-notice-${Date.now()}-${++noticeIdRef.current}`,
createdAt: input.createdAt ?? Date.now(),
};
setNotices((current) => [...current.slice(-49), notice]);
return notice;
}, []);
const dismissNotice = useCallback((id: string) => {
setNotices((current) => current.filter((notice) => notice.id !== id));
}, []);
const clearNotices = useCallback(() => {
setNotices([]);
}, []);
const noticesValue = useMemo<SessionNoticesValue>(
() => ({
notices,
dismissNotice,
clearNotices,
}),
[clearNotices, dismissNotice, notices],
);
const [workspaceEventSignals, setWorkspaceEventSignals] =
useState<DaemonWorkspaceEventSignals>(INITIAL_WORKSPACE_EVENT_SIGNALS);
const hasCurrentSessionActivePromptRef = useRef<() => boolean>(() => false);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!autoConnect) return undefined;
if (!workspaceClientRef.current && !resolvedBaseUrl) {
setConnection({
status: 'error',
error:
'DaemonSessionProvider requires a baseUrl prop or an ancestor DaemonWorkspaceProvider.',
});
return undefined;
}
const abort = new AbortController();
let disposed = false;
const run = async () => {
const client =
workspaceClientRef.current ??
new DaemonClient({ baseUrl: resolvedBaseUrl!, token: resolvedToken });
let session: DaemonSessionClient | undefined;
let capabilities:
| Awaited<ReturnType<DaemonClient['capabilities']>>
| undefined;
let reconnectSessionId = restoreSessionId;
let shouldCreateFreshSession = !restoreSessionId && newSessionNonce > 0;
let reconnectAttempt = 0;
let hasCurrentSessionActivePrompt = () => false;
// Set when the user explicitly deletes the session (server
// publishes session_closed with reason 'client_close').
// Reconnecting would auto-create a new session, undoing the
// user's delete. Other session_closed reasons (idle_timeout,
// last_client_detached) fall through to normal reconnect.
let userDeletedSession = false;
while (!disposed && !abort.signal.aborted) {
let loadingRequestedSession = false;
try {
// ── SSE Reconnection Strategy ────────────────────────────────
//
// Two reconnection paths depending on whether `session` survived
// the previous iteration's error handler:
//
// PATH A — Incremental (session preserved, retriable errors):
// `session` is non-null → skip this entire `if (!session)` block
// → go straight to `activeSession.events()` which sends
// `Last-Event-ID` → daemon serves only missed events →
// store.dispatch() appends to existing blocks. No reset, no
// load(), minimal re-render.
//
// PATH B — Snapshot reload (session cleared, terminal/auth errors,
// ring eviction):
// `session` is null → enter this block → DaemonSessionClient
// .load() fetches compactedReplay + liveJournal → deferred
// store.reset() + store.dispatch(replayEvents) rebuilds the
// current bounded replay window in a single synchronous batch.
//
// The `needsStoreReset` flag defers store.reset() to avoid an
// intermediate empty-blocks state that causes virtualizer
// removeChild errors (see replay injection section below).
// ─────────────────────────────────────────────────────────────
let isSameSessionReconnect = false;
let shouldInjectReplaySnapshot = false;
let needsStoreReset = false;
let attachedExistingSession = false;
// Only populated when this attempt (re)loads the session: a reused
// session object carries the snapshot from its original load, whose
// usage may be older than the in-memory count.
let replayTokenUsage: DaemonConnectionState['tokenUsage'];
let replayTokenCount: number | undefined;
if (!session) {
const existingSession = sessionRef.current;
if (
existingSession &&
!restoreSessionId &&
!reconnectSessionId &&
!shouldCreateFreshSession
) {
session = existingSession;
reconnectSessionId = existingSession.sessionId;
lastSessionIdRef.current = existingSession.sessionId;
attachedExistingSession = true;
}
}
if (!session) {
setConnection((current) => ({
...current,
status: 'connecting',
error: undefined,
errorStatus: resolveConnectionErrorStatus(
undefined,
current.errorStatus,
),
}));
const getWorkspaceCapabilities =
workspaceGetCapabilitiesRef.current;
const caps =
workspaceCapabilitiesRef.current ??
(getWorkspaceCapabilities
? await getWorkspaceCapabilities()
: await client.capabilities());
if (disposed || abort.signal.aborted) return;
capabilities = caps;
heartbeatSupportedRef.current =
Array.isArray(caps.features) &&
caps.features.includes('client_heartbeat');
const effectWorkspaceCwd =
resolvedWorkspaceCwdRef.current ?? caps.workspaceCwd;
activeWorkspaceCwdRef.current = effectWorkspaceCwd;
if (
(shouldDeferInitialSessionCreation ||
manualSessionClearRef.current) &&
!restoreSessionId &&
!reconnectSessionId &&
!shouldCreateFreshSession
) {
// Fetch skills alongside providers so skill-backed slash
// commands (e.g. /review) can autocomplete before the first
// prompt. Both are session-less workspace queries; the
// session-scoped supported-commands snapshot (which also carries
// custom/MCP/workflow commands) still lands once the first prompt
// creates a session.
const [providerResult, skillsResult, acpStatusResult] =
await Promise.allSettled([
client.workspaceProviders(),
client.workspaceSkills(),
client.workspaceAcpStatus(),
]);
if (providerResult.status === 'rejected') {
console.warn(
'[DaemonSessionProvider] workspaceProviders failed in deferred connect:',
providerResult.reason,
);
}
if (skillsResult.status === 'rejected') {
console.warn(
'[DaemonSessionProvider] workspaceSkills failed in deferred connect:',
skillsResult.reason,
);
}
if (acpStatusResult.status === 'rejected') {
console.warn(
'[DaemonSessionProvider] workspaceAcpStatus failed in deferred connect:',
acpStatusResult.reason,
);
}
const providers =
providerResult.status === 'fulfilled'
? providerResult.value
: undefined;
const providerModelStatus = mapProviderStatus(providers);
const {
commands: deferredSkillCommands,
skills: deferredSkills,
} = mapWorkspaceSkills(
skillsResult.status === 'fulfilled'
? skillsResult.value
: undefined,
);
const preserveClearedSessionCommands =
skillsResult.status === 'rejected' ||
(manualSessionClearRef.current &&
deferredSkillCommands.length === 0);
setConnection((current) => ({
...current,
status: 'connected',
workspaceCwd: effectWorkspaceCwd,
models: providerModelStatus.models,
currentModel: providerModelStatus.currentModel,
currentMode: providerModelStatus.currentMode,
contextWindow: providerModelStatus.contextWindow,
providers,
capabilities: caps,
commands: preserveClearedSessionCommands
? current.commands
: deferredSkillCommands,
skills: preserveClearedSessionCommands
? current.skills
: deferredSkills,
}));
if (
acpStatusResult.status === 'fulfilled' &&
!acpStatusResult.value.channelLive &&
!workspaceAcpPreheatInFlightRef.current
) {
workspaceAcpPreheatInFlightRef.current = true;
void (async () => {
try {
const preheat = await client.workspaceAcpPreheat(5_000);
if (
disposed ||
abort.signal.aborted ||
!preheat.ready ||
connectionRef.current.sessionId
) {
return;
}
const refreshed = await client.workspaceSkills();
if (
disposed ||
abort.signal.aborted ||
connectionRef.current.sessionId
) {
return;
}
const { commands, skills } = mapWorkspaceSkills(refreshed);
setConnection((current) =>
current.sessionId
? current
: { ...current, commands, skills },
);
} catch (error) {
console.warn(
'[DaemonSessionProvider] ACP preheat for workspace skills failed:',
error,
);
} finally {
workspaceAcpPreheatInFlightRef.current = false;
}
})();
}
return;
}
const restoreMethod =
restoreSessionId && restoreMode === 'resume'
? DaemonSessionClient.resume
: DaemonSessionClient.load;
const targetSessionId = restoreSessionId ?? reconnectSessionId;
const requestClientId = clientId
? clientIdRef.current
: getStableClientId(undefined, targetSessionId);
loadingRequestedSession = Boolean(restoreSessionId);
if (targetSessionId) {
setConnection((current) => ({
...current,
sessionId: targetSessionId,
error: undefined,
errorStatus: undefined,
missingSession: false,
loadingTranscript: true,
}));
}
const nextSession = restoreSessionId
? await restoreMethod(
client,
restoreSessionId,
{ workspaceCwd: effectWorkspaceCwd },
requestClientId,
)
: reconnectSessionId
? await DaemonSessionClient.load(
client,
reconnectSessionId,
{ workspaceCwd: effectWorkspaceCwd },
requestClientId,
)
: await DaemonSessionClient.createOrAttach(
client,
{
...(modelServiceId !== undefined
? { modelServiceId }
: {}),
...(shouldCreateFreshSession
? { sessionScope: 'thread' as const }
: sessionScope !== undefined
? { sessionScope }
: {}),
workspaceCwd: effectWorkspaceCwd,
},
requestClientId,
);
loadingRequestedSession = false;
if (!clientId && nextSession.clientId) {
clientIdRef.current = nextSession.clientId;
persistStableClientId(
nextSession.clientId,
nextSession.sessionId,
);
}
if (disposed || abort.signal.aborted) {
void detachDaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
sessionId: nextSession.sessionId,
clientId: nextSession.clientId,
}).catch((err) =>
console.warn('[DaemonSessionProvider] detach failed:', err),
);
return;
}
const previousSessionId = lastSessionIdRef.current;
if (previousSessionId !== nextSession.sessionId) {
clearNotices();
}
// Defer store.reset() until right before replay dispatch
// (after the await below) so that reset + dispatch share a
// single queueMicrotask notification. Without deferral, the
// microtask fires during the await and React sees an
// intermediate empty-blocks state, which causes removeChild
// errors in the virtualizer.
if (
previousSessionId !== undefined &&
nextSession.sessionId !== previousSessionId
) {
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
needsStoreReset = true;
} else if (previousSessionId !== undefined) {
const replaySnapshotEventCount =
nextSession.replaySnapshot.compactedReplay.length +
nextSession.replaySnapshot.liveJournal.length;
if (replaySnapshotEventCount > 0) {
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
needsStoreReset = true;
} else {
store.dispatch({
type: 'assistant.done',
reason: 'reconnected',
});
if (store.getSnapshot().awaitingResync) {
store.clearAwaitingResync();
}
}
}
isSameSessionReconnect =
previousSessionId !== undefined &&
previousSessionId === nextSession.sessionId;
shouldInjectReplaySnapshot =
nextSession.replaySnapshot.compactedReplay.length > 0 ||
nextSession.replaySnapshot.liveJournal.length > 0;
const replayEvents = [
...nextSession.replaySnapshot.compactedReplay,
...nextSession.replaySnapshot.liveJournal,
];
replayTokenUsage = getReplayTokenUsage(replayEvents);
replayTokenCount = getTokenCountFromUsage(replayTokenUsage);
session = nextSession;
reconnectSessionId = session.sessionId;
shouldCreateFreshSession = false;
lastSessionIdRef.current = session.sessionId;
sessionRef.current = session;
}
const activeSession = session;
// Prompt activity is session state returned by /load. Surface it
// immediately so a refreshed page shows the running state without
// waiting for auxiliary data such as providers, commands, or context.
//
// `activePromptsRef` only tracks prompts submitted by this browser
// instance. After a page refresh, `/load` can attach to a daemon
// session whose prompt is still running, but there is no local
// controller/promise to put in `activePromptsRef`. Keep that restored
// live state separately so `session.replay_complete` (history caught
// up) does not get mistaken for `turn_complete` (prompt finished).
const restoredActivePromptSettled =
settledRestoredActivePromptSessionsRef.current.has(activeSession);
let restoredActivePrompt =
activeSession.hasActivePrompt === true &&
!restoredActivePromptSettled;
const settleRestoredActivePrompt = () => {
// `hasActivePrompt` is a load/resume snapshot on this session client.
// Once a terminal event consumes it, keep it consumed across SSE
// reconnects for the same client; later prompts from this page are
// still tracked independently in activePromptsRef.
settledRestoredActivePromptSessionsRef.current.add(activeSession);
restoredActivePrompt = false;
};
const hasSessionActivePrompt = () =>
restoredActivePrompt ||
activePromptsRef.current.has(activeSession.sessionId);
hasCurrentSessionActivePrompt = hasSessionActivePrompt;
hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt;
setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle');
const pendingLoad = pendingSessionLoadRef.current;
const pendingLoadToResolve =
pendingLoad?.sessionId === activeSession.sessionId
? pendingLoad
: undefined;
// Feed replay snapshot (compacted history + live journal) into
// the store before starting the SSE loop. The SSE stream begins
// from lastEventId, so only post-snapshot events are delivered.
//
// This runs before the providers/commands/context fetches below:
// the snapshot is already in hand, so the transcript paints one
// metadata round-trip earlier (visible on high-latency mobile).
//
// The deferred store.reset() runs here — in the same synchronous
// block as store.dispatch() — so the queueMicrotask notification
// only fires once with the fully-populated state.
const { compactedReplay, liveJournal } = activeSession.replaySnapshot;
const replayEvents = [...compactedReplay, ...liveJournal];
const replayInjected =
shouldInjectReplaySnapshot && replayEvents.length > 0;
if (needsStoreReset && !replayInjected) {
// Reset needed but no replay data (e.g. fresh session) — reset
// immediately since there is no dispatch to batch with.
store.reset();
}
if (replayInjected) {
const replayOpts = {
...eventOptionsRef.current,
suppressOwnUserEcho: false,
};
const allUiEvents: DaemonUiEvent[] = [];
for (const replayEvent of replayEvents) {
try {
const replayUiEvents = normalizeAndFilterEvent(
replayEvent,
activeSession.clientId,
replayOpts,
setConnection,
{ updateConnection: false },
);
allUiEvents.push(
...filterDaemonUiEventsForTranscript(
replayEvent,
replayUiEvents,
addNotice,
),
);
if (replayEvent.type === 'turn_complete') {
const stopReason =
(replayEvent.data as DaemonTurnCompleteData | undefined)
?.stopReason ?? 'end_turn';
allUiEvents.push(
assistantDoneFromTurnEvent(replayEvent, stopReason),
);
} else if (replayEvent.type === 'turn_error') {
allUiEvents.push(
assistantDoneFromTurnEvent(replayEvent, 'error'),
);
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
addNotice({
severity: 'warning',
category: 'protocol',
operation: 'normalize_event',
code: 'daemon.replay_event_malformed',
message: 'Skipped malformed replay event',
debugMessage: message,
recoverable: true,
});
console.warn(
'[DaemonSessionProvider] skipped malformed replay event:',
error,
);
}
}
if (needsStoreReset) {
store.reset();
}
if (allUiEvents.length > 0) {
store.dispatch(allUiEvents);
bumpWorkspaceEventSignals(allUiEvents, setWorkspaceEventSignals);
}
for (const replayEvent of replayEvents) {
settleActivePromptFromTurnEvent(
activePromptsRef.current,
settledPromptsRef.current,
activeSession.sessionId,
replayEvent,
store,
setPromptStatus,
passiveAssistantDoneTimerRef,
{ requireBoundPromptId: true },
);
}
setConnection((c) => ({ ...c, catchingUp: undefined }));
}
setConnection((current) => ({
...current,
status: 'connected',
sessionId: activeSession.sessionId,
...(activeSession.clientId
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
displayName:
getSessionDisplayName(activeSession.state) ??
(current.sessionId === activeSession.sessionId
? current.displayName
: undefined),
tokenUsage:
replayTokenUsage !== undefined
? replayTokenUsage
: current.sessionId === activeSession.sessionId
? current.tokenUsage
: undefined,
tokenCount:
replayTokenCount !== undefined
? replayTokenCount
: current.sessionId === activeSession.sessionId
? (current.tokenCount ?? 0)
: 0,
loadingTranscript: undefined,
catchingUp: replayInjected
? current.catchingUp
: isSameSessionReconnect ||
activeSession.lastEventId != null ||
undefined,
}));
if (pendingLoadToResolve) {
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoadToResolve.timeout);
if (
skipNextCleanupDetachSessionIdRef.current ===
activeSession.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
pendingLoadToResolve.resolve();
}
const canReuseSessionMetadata =
attachedExistingSession &&
connectionRef.current.commands !== undefined &&
connectionRef.current.skills !== undefined &&
connectionRef.current.supportedCommands !== undefined &&
connectionRef.current.context !== undefined;
const [providerResult, commandResult, contextResult] =
canReuseSessionMetadata
? [undefined, undefined, undefined]
: await Promise.allSettled([
client.workspaceProviders(),
activeSession.supportedCommands(),
activeSession.context(),
]);
const providers =
providerResult?.status === 'fulfilled'
? providerResult.value
: undefined;
const supportedCommands =
commandResult?.status === 'fulfilled'
? commandResult.value
: undefined;
const context =
contextResult?.status === 'fulfilled'
? contextResult.value
: undefined;
const loadWarningTexts = [
providerResult?.status === 'rejected'
? loadWarningsRef.current?.models
: undefined,
commandResult?.status === 'rejected'
? loadWarningsRef.current?.commands
: undefined,
contextResult?.status === 'rejected'
? loadWarningsRef.current?.context
: undefined,
].filter((warning): warning is string => Boolean(warning));
const providerModelStatus = mapProviderStatus(providers);
const contextModelStatus = mapSessionContextModels(context);
const sessionModels =
contextModelStatus && contextModelStatus.models.length > 0
? contextModelStatus.models
: providerModelStatus.models;
const sessionCurrentModel =
contextModelStatus?.currentModel ??
providerModelStatus.currentModel;
const providerContextWindow =
sessionCurrentModel === providerModelStatus.currentModel
? providerModelStatus.contextWindow
: providerModelStatus.models.find(
(model) => model.id === sessionCurrentModel,
)?.contextWindow;
const sessionContextWindow =
contextModelStatus?.contextWindow ??
sessionModels.find((model) => model.id === sessionCurrentModel)
?.contextWindow ??
providerContextWindow;
const { commands, skills } = mapSupportedCommands(supportedCommands);
const currentMode =
getCurrentMode(context) ?? providerModelStatus.currentMode;
setConnection((current) => {
if (current.sessionId !== activeSession.sessionId) return current;
return {
...current,
status: 'connected',
sessionId: activeSession.sessionId,
// Surface the bound client id so consumers can recognize their own
// originator-stamped frames (e.g. the web-shell's mid-turn dedupe).
...(activeSession.clientId
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
// A fulfilled supported-commands fetch is authoritative even when
// it returns an empty list: fall back to the preserved
// `current.commands` only when the fetch was skipped or failed
// (supportedCommands === undefined). Keying on length instead
// would let a genuinely-empty snapshot leave a stale command list
// in place (see getConnectionAfterSessionClear, which now
// preserves commands across a clear).
commands:
supportedCommands !== undefined ? commands : current.commands,
skills: supportedCommands !== undefined ? skills : current.skills,
models: sessionModels.length > 0 ? sessionModels : current.models,
currentModel: sessionCurrentModel ?? current.currentModel,
currentMode: currentMode ?? current.currentMode,
displayName:
getSessionDisplayName(activeSession.state) ??
current.displayName,
contextWindow: sessionContextWindow ?? current.contextWindow,
providers: providers ?? current.providers,
supportedCommands: supportedCommands ?? current.supportedCommands,
context: context ?? current.context,
capabilities: capabilities ?? current.capabilities,
loadingTranscript: undefined,
catchingUp:
// Replay already injected above — keep the cleared flag rather
// than re-arming it (nothing before SSE would clear it again).
replayInjected
? current.catchingUp
: isSameSessionReconnect ||
activeSession.lastEventId != null ||
undefined,
};
});
if (loadWarningTexts.length > 0) {
store.dispatch(
loadWarningTexts.map((text) => ({
type: 'status' as const,
text,
})),
);
}
let sawEvent = false;
let resyncRequested = false;
const requestEpochResetReload = () => {
// An epoch reset means the daemon/EventBus timeline was rebuilt.
// The current SSE cursor and any restored/local prompt activity may
// describe the old epoch, so do a full /load and let
// hasActivePrompt from that fresh snapshot become authoritative.
const active = activePromptsRef.current.get(
activeSession.sessionId,
);
active?.controller.abort();
activePromptsRef.current.delete(activeSession.sessionId);
if (restoredActivePrompt) {
settleRestoredActivePrompt();
}
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
setPromptStatus('idle');
store.reset();
activeSession.setLastEventId(0);
reconnectSessionId = activeSession.sessionId;
resyncRequested = true;
session = undefined;
sessionRef.current = undefined;
hasCurrentSessionActivePromptRef.current = () => false;
setConnection((current) => ({
...current,
status: 'connecting',
error: undefined,
errorStatus: resolveConnectionErrorStatus(
undefined,
current.errorStatus,
),
}));
};
for await (const event of activeSession.events({
signal: abort.signal,
maxQueued,
})) {
if (sessionRef.current?.sessionId !== activeSession.sessionId) {
break;
}
if (!sawEvent) {
sawEvent = true;
reconnectAttempt = 0;
}
try {
const followupSuggestion =
parseSidechannelFollowupSuggestion(event);
if (followupSuggestion) {
publishSidechannelFollowupSuggestion(followupSuggestion);
continue;
}
const midTurnInjected = parseSidechannelMidTurnInjected(event);
if (midTurnInjected) {
// Keep the sidechannel for queue dedupe, but still normalize the
// event below so chat UIs can render the inserted-message status.
publishSidechannelMidTurnInjected(midTurnInjected);
}
if (isPendingPromptEvent(event)) {
publishPendingPromptEvent(event);
if (event.type === 'pending_prompt_started') {
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
setPromptStatus('waiting');
}
}
const normalizedUiEvents = normalizeAndFilterEvent(
event,
activeSession.clientId,
eventOptionsRef.current,
setConnection,
);
const uiEvents = filterDaemonUiEventsForTranscript(
event,
normalizedUiEvents,
addNotice,
);
if (event.type === 'state_resync_required') {
const reason =
typeof event.data === 'object' && event.data !== null
? (event.data as Record<string, unknown>).reason
: undefined;
if (reason === 'epoch_reset') {
requestEpochResetReload();
break;
}
}
bumpWorkspaceEventSignals(uiEvents, setWorkspaceEventSignals);
if (uiEvents.length > 0) {
const hasGenerationSignal = hasActiveGenerationSignal(uiEvents);
setPromptStatus((current) =>
current === 'waiting' ||
(current === 'idle' && hasGenerationSignal)
? 'streaming'
: current,
);
}
const activePromptSettled = settleActivePromptFromTurnEvent(
activePromptsRef.current,
settledPromptsRef.current,
activeSession.sessionId,
event,
store,
setPromptStatus,
passiveAssistantDoneTimerRef,
);
let restoredPromptSettled = false;
if (
!activePromptSettled &&
restoredActivePrompt &&
(event.type === 'turn_complete' || event.type === 'turn_error')
) {
// A refreshed page restores an already-running prompt without a
// local ActivePrompt entry or prompt promise to settle. The daemon
// terminal event is still authoritative, so end the restored
// running state here instead of relying on the observer branch.
settleRestoredActivePrompt();
restoredPromptSettled = true;
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
const stopReason =
event.type === 'turn_complete'
? ((event.data as DaemonTurnCompleteData | undefined)
?.stopReason ?? 'end_turn')
: 'error';
store.dispatch(assistantDoneFromTurnEvent(event, stopReason));
if (!hasSessionActivePrompt()) {
setPromptStatus('idle');
}
}
const shouldGuardAssistant =
!hasSessionActivePrompt() &&
store.getSnapshot().activeAssistantBlockId != null;
const eventsToDispatch = shouldGuardAssistant
? uiEvents.filter((e) => e.type !== 'debug')
: uiEvents;
store.dispatch(eventsToDispatch);
for (const uiEvent of uiEvents) {
if (
uiEvent.type === 'prompt.cancelled' &&
(restoredActivePrompt ||
uiEvent.originatorClientId !== activeSession.clientId)
) {
store.dispatch(
assistantDoneFromTurnEvent(event, 'cancelled'),
);
const cancellingRestoredPrompt = restoredActivePrompt;
settleRestoredActivePrompt();
restoredPromptSettled = true;
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (!cancellingRestoredPrompt) {
activePromptsRef.current.delete(activeSession.sessionId);
}
if (!hasSessionActivePrompt()) {
setPromptStatus('idle');
}
} else if (uiEvent.type === 'session.replay_complete') {
setConnection((c) => ({ ...c, catchingUp: undefined }));
if (store.getSnapshot().awaitingResync) {
store.clearAwaitingResync();
}
if (!hasSessionActivePrompt()) {
clearPassiveAssistantDoneTimer(
passiveAssistantDoneTimerRef,
);
store.dispatch({
type: 'assistant.done',
reason: 'replay_complete',
});
setPromptStatus('idle');
}
}
}
// A restored active prompt is not in activePromptsRef because this
// browser did not submit it. Treat it as active here too; otherwise
// the passive observer timer can briefly mark a still-running turn
// idle between sparse tool/thinking updates.
const isObserver =
!activePromptSettled &&
!restoredPromptSettled &&
!hasSessionActivePrompt();
if (isObserver) {
const hasUserMsg = uiEvents.some(
(e) => e.type === 'user.text.delta',
);
if (hasUserMsg) {
setPromptStatus('waiting');
} else if (hasActiveGenerationSignal(uiEvents)) {
setPromptStatus((current) =>
current === 'idle' ? 'streaming' : current,
);
}
}
if (isObserver && event.type === 'turn_complete') {
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
const stopReason =
(event.data as DaemonTurnCompleteData | undefined)
?.stopReason ?? 'end_turn';
store.dispatch(assistantDoneFromTurnEvent(event, stopReason));
setPromptStatus('idle');
} else if (isObserver && event.type === 'turn_error') {
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
store.dispatch(assistantDoneFromTurnEvent(event, 'error'));
setPromptStatus('idle');
} else if (isObserver && hasActiveGenerationSignal(uiEvents)) {
schedulePassiveAssistantDone(
store,
passiveAssistantDoneTimerRef,
'passive_observer',
3000,
() => setPromptStatus('idle'),
);
}
// ── state_resync_required handling ──────────────────────
// Resyncs are transcript recovery signals, not prompt terminal
// signals. For epoch_reset and ring_evicted we reload the session
// snapshot; the fresh /load response is the source of truth for
// hasActivePrompt and transcript replay.
if (event.type === 'state_resync_required') {
const reason =
typeof event.data === 'object' && event.data !== null
? (event.data as Record<string, unknown>).reason
: undefined;
if (reason !== 'epoch_reset') {
// Resync asks us to rebuild transcript state, but it is not a
// prompt terminal signal. Keep loading alive for local/restored
// prompts until turn_complete, turn_error, or prompt_cancelled.
if (!hasSessionActivePrompt()) {
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(
passiveAssistantDoneTimerRef,
);
}
store.reset();
// Ring eviction means the SSE replay window has a real gap.
// Resetting and continuing on the same stream can only replay
// the surviving tail; reload the session snapshot instead so
// compactedReplay/liveJournal rebuild the bounded replay
// window.
console.warn(
'[DaemonSessionProvider] ring eviction detected, reloading session (sessionId=%s)',
activeSession.sessionId,
);
resyncRequested = true;
session = undefined;
sessionRef.current = undefined;
hasCurrentSessionActivePromptRef.current = () => false;
setConnection((current) => ({
...current,
status: 'connecting',
error: undefined,
errorStatus: resolveConnectionErrorStatus(
undefined,
current.errorStatus,
),
}));
break;
}
}
// session_closed with reason 'client_close' means the
// user explicitly deleted the session. Stop the
// reconnect loop — without this, the next iteration
// would call createOrAttach and auto-create a new
// session, undoing the user's delete action.
// Other reasons (idle_timeout, last_client_detached)
// fall through to the normal reconnect path.
if (
event.type === 'session_closed' &&
(event.data as Record<string, unknown> | undefined)?.reason ===
'client_close'
) {
userDeletedSession = true;
const closedSessionId = activeSession.sessionId;
const active = activePromptsRef.current.get(closedSessionId);
active?.controller.abort();
activePromptsRef.current.delete(closedSessionId);
session = undefined;
sessionRef.current = undefined;
break;
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
addNotice({
severity: 'warning',
category: 'protocol',
operation: 'normalize_event',
code: 'daemon.event_malformed',
message: 'Skipped malformed daemon event',
debugMessage: message,
recoverable: true,
});
console.warn(
'[DaemonSessionProvider] skipped malformed daemon event:',
error,
);
}
}
if (userDeletedSession) {
// Session was explicitly closed (user deleted it). Do NOT
// reconnect — doing so would auto-create a new session.
// Note: we intentionally do NOT call setRestoreSessionId(undefined)
// here because restoreSessionId is in the useEffect dependency
// array — changing it would trigger an effect re-run that could
// create a new session via createOrAttach.
store.dispatch({ type: 'assistant.done', reason: 'cancelled' });
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: undefined,
errorStatus: undefined,
missingSession: false,
}));
return;
}
if (manualSessionClearRef.current) {
session = undefined;
sessionRef.current = undefined;
hasCurrentSessionActivePromptRef.current = () => false;
return;
}
if (!disposed && !abort.signal.aborted && !resyncRequested) {
// Keep the session handle after a normal SSE close so the next
// subscription can resume from DaemonSessionClient.lastEventId.
if (sessionRef.current?.sessionId === activeSession.sessionId) {
console.debug('[DaemonSessionProvider] SSE stream ended');
if (!hasSessionActivePrompt()) {
// A transport close is only a safe "done" signal for passive
// observers. When a local/restored prompt is still active, the
// daemon may continue running while we reconnect via
// Last-Event-ID, so keep the prompt in streaming state until a
// real turn_complete/turn_error/prompt_cancelled arrives.
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
setPromptStatus('idle');
store.dispatch({
type: 'assistant.done',
reason: 'stream_ended',
});
}
}
setConnection((current) => ({
...current,
status: current.status === 'error' ? 'error' : 'disconnected',
error: current.status === 'error' ? current.error : undefined,
errorStatus: resolveConnectionErrorStatus(
undefined,
current.errorStatus,
),
}));
}
} catch (error) {
if (disposed || abort.signal.aborted) return;
const message =
error instanceof Error ? error.message : String(error);
const errorStatus = extractHttpStatus(error);
const failedSessionId = session?.sessionId;
const isAuthFailure = isAuthFailureHttpError(error);
const isTerminal = isTerminalSessionHttpError(error);
if (failedSessionId && (isAuthFailure || isTerminal)) {
const active = activePromptsRef.current.get(failedSessionId);
active?.controller.abort();
activePromptsRef.current.delete(failedSessionId);
}
// Retriable transport failures are not prompt terminal events. Keep
// restored/local prompts in streaming state until the daemon sends
// turn_complete, turn_error, or prompt_cancelled.
if (isAuthFailure || isTerminal || !hasCurrentSessionActivePrompt()) {
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
setPromptStatus('idle');
}
const pendingLoad = pendingSessionLoadRef.current;
if (
pendingLoad &&
(pendingLoad.sessionId === restoreSessionId ||
pendingLoad.sessionId === reconnectSessionId)
) {
if (
skipNextCleanupDetachSessionIdRef.current ===
pendingLoad.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoad.timeout);
pendingLoad.reject(error);
}
if (isAuthFailure || isTerminal) {
// Auth failures (401/403) and terminal session errors (404/410)
// must clear the session — the server-side state is gone or
// inaccessible, so delta resume is impossible.
session = undefined;
sessionRef.current = undefined;
if (isAuthFailure) {
setConnection((current) => ({
...current,
status: 'error',
sessionId: undefined,
error: message,
errorStatus: resolveConnectionErrorStatus(
errorStatus,
current.errorStatus,
),
missingSession: false,
capabilities: capabilities ?? current.capabilities,
loadingTranscript: undefined,
catchingUp: undefined,
}));
return;
}
const missingLoadedSession =
loadingRequestedSession &&
isMissingSessionHttpStatus(errorStatus);
console.warn(
'[DaemonSessionProvider] terminal session error (sessionId=%s, status=%d, message=%s)',
failedSessionId,
errorStatus,
message,
);
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: message,
errorStatus: resolveConnectionErrorStatus(
errorStatus,
current.errorStatus,
),
// SSE errors should not create the missing-session empty state,
// but they also must not clear one confirmed by load/heartbeat.
missingSession:
missingLoadedSession || current.missingSession === true,
capabilities: capabilities ?? current.capabilities,
loadingTranscript: undefined,
catchingUp: undefined,
}));
return;
} else {
// Retriable error (network failure, timeout, etc.) — preserve
// the session so the next iteration skips the full load() and
// goes straight to events(). DaemonSessionClient tracks
// lastSeenEventId internally; the next SSE subscription sends
// Last-Event-ID and the daemon serves only delta events.
// The transcript store is NOT reset — new events append to
// existing blocks, avoiding a full re-render.
console.debug(
'[DaemonSessionProvider] retriable SSE error, preserving session for delta resume (sessionId=%s)',
session?.sessionId,
);
}
if (!autoReconnect) {
session = undefined;
sessionRef.current = undefined;
setConnection((current) => ({
...current,
status: 'error',
error: message,
errorStatus: resolveConnectionErrorStatus(
errorStatus,
current.errorStatus,
),
missingSession: false,
}));
return;
}
setConnection((current) => ({
...current,
status: 'disconnected',
error: message,
errorStatus: resolveConnectionErrorStatus(
errorStatus,
current.errorStatus,
),
missingSession: false,
loadingTranscript: undefined,
}));
}
if (!autoReconnect) {
sessionRef.current = undefined;
setConnection((current) => ({
...current,
status: 'disconnected',
loadingTranscript: undefined,
catchingUp: undefined,
}));
return;
}
reconnectAttempt += 1;
const reconnectConfig = reconnectConfigRef.current;
const delayMs = getReconnectDelayMs(
reconnectAttempt,
reconnectConfig.reconnectDelayMs,
reconnectConfig.maxReconnectDelayMs,
);
setConnection((current) => ({
...current,
status: 'disconnected',
error: `Reconnecting in ${delayMs}ms`,
}));
await delay(delayMs, abort.signal);
}
};
void run();
return () => {
const session = sessionRef.current;
disposed = true;
abort.abort();
hasCurrentSessionActivePromptRef.current = () => false;
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
const keepSessionForNextEffect =
session?.sessionId === skipNextCleanupDetachSessionIdRef.current;
const isUnmounting = !mountedRef.current;
if (
pendingSessionLoadRef.current &&
(!keepSessionForNextEffect || isUnmounting)
) {
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('Session load interrupted by cleanup', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
if ((!keepSessionForNextEffect || isUnmounting) && session?.clientId) {
void detachDaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
sessionId: session.sessionId,
clientId: session.clientId,
}).catch((err) =>
console.warn('[DaemonSessionProvider] detach failed:', err),
);
}
if (!keepSessionForNextEffect || isUnmounting) {
sessionRef.current = undefined;
}
};
}, [
autoConnect,
autoReconnect,
resolvedBaseUrl,
resolvedToken,
workspaceCwd,
modelServiceId,
sessionScope,
maxQueued,
store,
restoreSessionId,
restoreMode,
restoreSessionNonce,
attachSessionNonce,
newSessionNonce,
clientId,
shouldDeferInitialSessionCreation,
clearNotices,
addNotice,
]);
useEffect(() => {
if (
!heartbeatSupportedRef.current ||
connection.status !== 'connected' ||
heartbeatIntervalMs <= 0 ||
heartbeatFailureThreshold <= 0 ||
!connection.sessionId
) {
return undefined;
}
if (heartbeatFailureStateRef.current.sessionId !== connection.sessionId) {
heartbeatFailureStateRef.current = {
sessionId: connection.sessionId,
consecutiveFailures: 0,
};
}
const heartbeatFailureState = heartbeatFailureStateRef.current;
let disposed = false;
const timer = setInterval(() => {
const session = sessionRef.current;
if (!session) return;
session
.heartbeat()
.then(() => {
if (disposed) return;
if (
heartbeatFailureState.consecutiveFailures >=
heartbeatFailureThreshold
) {
setConnection((current) =>
current.sessionId === session.sessionId
? {
...current,
status: 'connected',
error: undefined,
errorStatus: undefined,
}
: current,
);
}
heartbeatFailureState.consecutiveFailures = 0;
heartbeatFailureState.lastHttpError = undefined;
})
.catch((error: unknown) => {
if (disposed) return;
heartbeatFailureState.consecutiveFailures += 1;
const message =
error instanceof Error ? error.message : 'Session heartbeat failed';
const thisErrorStatus = extractHttpStatus(error);
if (thisErrorStatus !== undefined) {
const lastStatus = heartbeatFailureState.lastHttpError?.status;
heartbeatFailureState.lastHttpError = {
status:
resolveConnectionErrorStatus(thisErrorStatus, lastStatus) ??
thisErrorStatus,
message: isMissingSessionHttpStatus(lastStatus)
? (heartbeatFailureState.lastHttpError?.message ?? message)
: message,
};
}
if (
heartbeatFailureState.consecutiveFailures <
heartbeatFailureThreshold
) {
return;
}
const errorStatus = heartbeatFailureState.lastHttpError?.status;
const effectiveMessage =
heartbeatFailureState.lastHttpError?.message ?? message;
const authFailure =
errorStatus !== undefined &&
AUTH_FAILURE_HTTP_STATUSES.has(errorStatus);
const missingSession = isMissingSessionHttpStatus(errorStatus);
if (authFailure || missingSession) {
const deadSessionId = session.sessionId;
if (missingSession) {
console.warn(
'[DaemonSessionProvider] heartbeat detected missing session (sessionId=%s, status=%d)',
deadSessionId,
errorStatus,
);
} else {
console.warn(
'[DaemonSessionProvider] heartbeat auth failure (sessionId=%s, status=%d)',
deadSessionId,
errorStatus,
);
}
const active = activePromptsRef.current.get(deadSessionId);
active?.controller.abort();
activePromptsRef.current.delete(deadSessionId);
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
setPromptStatus('idle');
if (sessionRef.current?.sessionId === deadSessionId) {
if (missingSession) {
manualSessionClearRef.current = true;
}
sessionRef.current = undefined;
}
}
setConnection((current) =>
current.sessionId === session.sessionId
? {
...current,
status: authFailure ? 'error' : 'disconnected',
error: effectiveMessage,
errorStatus: resolveConnectionErrorStatus(
errorStatus,
current.errorStatus,
),
missingSession,
...(authFailure || missingSession
? {
sessionId: undefined,
loadingTranscript: undefined,
catchingUp: undefined,
}
: {}),
}
: current,
);
});
}, heartbeatIntervalMs);
return () => {
disposed = true;
clearInterval(timer);
};
}, [
connection.sessionId,
connection.status,
heartbeatFailureThreshold,
heartbeatIntervalMs,
]);
const actions = useMemo<DaemonSessionActions>(
() =>
createDaemonSessionActions({
store,
sessionRef,
activePromptsRef,
settledPromptsRef,
pendingSessionLoadRef,
pendingSessionLoadIdRef,
heartbeatSupportedRef,
manualSessionClearRef,
skipNextCleanupDetachSessionIdRef,
passiveAssistantDoneTimerRef,
hasSessionActivePrompt: () =>
hasCurrentSessionActivePromptRef.current(),
resetCurrentSessionActivePrompt: () => {
hasCurrentSessionActivePromptRef.current = () => false;
},
getCreateSessionRequest: () => ({
...createSessionRequestRef.current,
sessionScope: 'thread',
workspaceCwd:
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
}),
createDetachedSession: () => {
const client =
workspaceClientRef.current ??
new DaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
});
const request = {
...createSessionRequestRef.current,
sessionScope: 'thread' as const,
workspaceCwd:
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
};
const requestClientId = clientId
? clientIdRef.current
: getStableClientId(undefined);
return DaemonSessionClient.createOrAttach(
client,
request,
requestClientId,
);
},
getConnection: () => connectionRef.current,
addNotice,
setConnection,
setPromptStatus,
setRestoreSessionId,
setRestoreMode,
setRestoreSessionNonce,
setAttachSessionNonce,
setNewSessionNonce,
}),
[addNotice, clientId, resolvedBaseUrl, resolvedToken, store],
);
const lastHandledSessionIdRef = useRef<
string | undefined | typeof UNHANDLED_SESSION
>(UNHANDLED_SESSION);
useEffect(() => {
if (lastHandledSessionIdRef.current === sessionId) return;
lastHandledSessionIdRef.current = sessionId;
const currentSessionId = connectionRef.current.sessionId;
if (sessionId === currentSessionId) return;
const request = sessionId
? actions.loadSession(sessionId)
: currentSessionId
? actions.clearSession()
: undefined;
if (!request) return;
void request.catch((error: unknown) => {
console.warn(
'[DaemonSessionProvider] controlled session transition failed:',
error,
);
});
}, [actions, sessionId]);
return (
<DaemonStoreContext.Provider value={store}>
<DaemonConnectionContext.Provider value={connection}>
<DaemonPromptStatusContext.Provider value={promptStatus}>
<DaemonSessionNoticesContext.Provider value={noticesValue}>
<DaemonWorkspaceEventSignalsContext.Provider
value={workspaceEventSignals}
>
<DaemonActionsContext.Provider value={actions}>
{children}
</DaemonActionsContext.Provider>
</DaemonWorkspaceEventSignalsContext.Provider>
</DaemonSessionNoticesContext.Provider>
</DaemonPromptStatusContext.Provider>
</DaemonConnectionContext.Provider>
</DaemonStoreContext.Provider>
);
}
function settleActivePromptFromTurnEvent(
activePrompts: Map<string, ActivePrompt>,
settledPrompts: Map<string, SettledPrompt>,
sessionId: string,
event: DaemonEvent,
store: DaemonTranscriptStore,
setPromptStatus: Dispatch<SetStateAction<DaemonPromptStatus>>,
passiveAssistantDoneTimerRef: TimerRef,
opts: { requireBoundPromptId?: boolean } = {},
): boolean {
if (event.type !== 'turn_complete' && event.type !== 'turn_error') {
return false;
}
const promptId = (event.data as { promptId?: string } | null | undefined)
?.promptId;
if (!promptId) return false;
const active = activePrompts.get(sessionId);
if (!active) return false;
if (opts.requireBoundPromptId && active.promptId === undefined) {
return false;
}
if (active.promptId !== undefined && active.promptId !== promptId) {
return false;
}
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
try {
const result = matchTurnEvent(event, promptId);
if (!result) return false;
store.dispatch(assistantDoneFromTurnEvent(event, result.stopReason));
setPromptStatus('idle');
if (active.resolve) {
activePrompts.delete(sessionId);
active.resolve(result);
} else {
activePrompts.delete(sessionId);
settledPrompts.set(getPromptSettledKey(sessionId, promptId), {
status: 'resolved',
result,
});
}
} catch (error) {
store.dispatch(assistantDoneFromTurnEvent(event, 'error'));
setPromptStatus('idle');
if (active.reject) {
activePrompts.delete(sessionId);
active.reject(error);
} else {
activePrompts.delete(sessionId);
settledPrompts.set(getPromptSettledKey(sessionId, promptId), {
status: 'rejected',
error,
});
}
}
return true;
}
function isPromptLifecycleTurnEvent(event: DaemonEvent): boolean {
return event.type === 'turn_complete';
}
function normalizeAndFilterEvent(
event: DaemonEvent,
clientId: string | undefined,
opts: { suppressOwnUserEcho: boolean; includeRawEvent: boolean },
setConnection: Dispatch<SetStateAction<DaemonConnectionState>>,
behavior: { updateConnection?: boolean } = {},
): DaemonUiEvent[] {
logSettingsReloadEvent(event);
if (behavior.updateConnection !== false) {
updateConnectionFromDaemonEvent(event, setConnection);
}
const normalized = normalizeDaemonEvent(event, {
clientId,
suppressOwnUserEcho: opts.suppressOwnUserEcho,
includeRawEvent: opts.includeRawEvent,
});
const goalStatusEvent = normalizeGoalStatusEvent(event);
if (isPromptLifecycleTurnEvent(event)) {
return goalStatusEvent ? [goalStatusEvent] : [];
}
return goalStatusEvent ? [...normalized, goalStatusEvent] : normalized;
}
function logSettingsReloadEvent(event: DaemonEvent): void {
if (event.type !== 'settings_reloaded') return;
console.debug(
'[DaemonSessionProvider] settings reloaded:',
getSettingsReloadLogData(event),
);
}
function getSettingsReloadLogData(event: DaemonEvent): Record<string, unknown> {
const log: Record<string, unknown> = {};
if (event.id !== undefined) log['eventId'] = event.id;
if (!isRecord(event.data)) {
log['payload'] = 'non-object';
return log;
}
const env = getSettingsReloadEnvLog(event.data['env']);
const changedKeys = getStringArray(event.data['changedKeys']);
const sessionsRefreshed = getStringArray(event.data['sessionsRefreshed']);
const sessionsSkipped = getStringArray(event.data['sessionsSkipped']);
const childReloaded = event.data['childReloaded'];
const childError = getString(event.data, 'childError');
if (env) log['env'] = env;
if (changedKeys) log['changedKeys'] = changedKeys;
if (typeof childReloaded === 'boolean') log['childReloaded'] = childReloaded;
if (sessionsRefreshed) log['sessionsRefreshed'] = sessionsRefreshed;
if (sessionsSkipped) log['sessionsSkipped'] = sessionsSkipped;
if (childError) log['childError'] = childError;
return log;
}
function getSettingsReloadEnvLog(
value: unknown,
): { updatedKeys: string[]; removedKeys: string[] } | undefined {
if (!isRecord(value)) return undefined;
return {
updatedKeys: getStringArray(value['updatedKeys']) ?? [],
removedKeys: getStringArray(value['removedKeys']) ?? [],
};
}
function getStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.filter((item): item is string => typeof item === 'string');
}
function filterDaemonUiEventsForTranscript(
sourceEvent: DaemonEvent,
events: DaemonUiEvent[],
addNotice: AddDaemonSessionNotice,
): DaemonUiEvent[] {
const filtered: DaemonUiEvent[] = [];
for (const event of events) {
if (event.type !== 'error') {
filtered.push(event);
continue;
}
if (sourceEvent.type === 'turn_error') {
filtered.push(event);
continue;
}
const notice = addNotice(
daemonErrorEventToNotice(sourceEvent, event as DaemonUiErrorEvent),
);
if (notice.category === 'protocol' || notice.category === 'connection') {
console.warn('[DaemonSessionProvider] daemon notice:', notice);
}
}
return filtered;
}
type DaemonUiErrorEvent = Extract<DaemonUiEvent, { type: 'error' }>;
function daemonErrorEventToNotice(
sourceEvent: DaemonEvent,
event: DaemonUiErrorEvent,
): SessionNoticeInput {
const base = {
message: event.text,
debugMessage: event.text,
recoverable: event.recoverable,
};
switch (sourceEvent.type) {
case 'model_switch_failed':
return {
...base,
severity: 'error',
category: 'user_action',
operation: 'switch_model',
code: 'daemon.switch_model.failed',
};
case 'session_died':
return {
...base,
severity: 'error',
category: 'connection',
operation: 'stream',
code: event.errorKind ?? 'daemon.session_died',
};
case 'client_evicted':
return {
...base,
severity: 'warning',
category: 'connection',
operation: 'stream',
code: 'daemon.client_evicted',
};
case 'stream_error':
return {
...base,
severity: 'warning',
category: 'connection',
operation: 'stream',
code: event.errorKind ?? 'daemon.stream_error',
};
default:
return {
...base,
severity: 'warning',
category: 'protocol',
operation: 'normalize_event',
code: event.code ?? 'daemon.protocol.error',
};
}
}
export function useDaemonSession(): DaemonSessionContextValue {
return {
store: useDaemonTranscriptStore(),
connection: useDaemonConnection(),
promptStatus: useDaemonPromptStatus(),
actions: useDaemonActions(),
};
}
export function useDaemonTranscriptStore(): DaemonTranscriptStore {
const store = useContext(DaemonStoreContext);
if (!store) {
throw new Error(
'useDaemonTranscriptStore must be used within DaemonSessionProvider',
);
}
return store;
}
export function useDaemonTranscriptState(): DaemonTranscriptState {
const store = useDaemonTranscriptStore();
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
}
export function useDaemonTranscriptBlocks(): readonly DaemonTranscriptBlock[] {
const store = useDaemonTranscriptStore();
const getBlocks = useCallback(() => store.getSnapshot().blocks, [store]);
return useSyncExternalStore(store.subscribe, getBlocks, getBlocks);
}
export function useDaemonPendingPermissions() {
// wenshao R5 (qwen3.7-max): subscribe at the blocks level instead of
// the full transcript state. `selectPendingPermissionBlocks` reads
// only `state.blocks`; subscribing to the full state caused this
// hook to re-render on every daemon event (text deltas, tool
// updates, sidechannel changes) even when blocks were unchanged.
const blocks = useDaemonTranscriptBlocks();
return useMemo(() => selectDaemonPendingPermissions(blocks), [blocks]);
}
export function useDaemonActiveTodoList() {
const blocks = useDaemonTranscriptBlocks();
return useMemo(() => selectDaemonActiveTodoList(blocks), [blocks]);
}
export function useDaemonStreamingState() {
const blocks = useDaemonTranscriptBlocks();
const promptStatus = useDaemonPromptStatus();
return useMemo(
() => selectDaemonStreamingState(blocks, promptStatus),
[blocks, promptStatus],
);
}
export function useDaemonActions(): DaemonSessionActions {
const actions = useContext(DaemonActionsContext);
if (!actions) {
throw new Error(
'useDaemonActions must be used within DaemonSessionProvider',
);
}
return actions;
}
export function useOptionalDaemonActions(): DaemonSessionActions | undefined {
return useContext(DaemonActionsContext);
}
export function useDaemonWorkspaceEventSignals():
| DaemonWorkspaceEventSignals
| undefined {
return useContext(DaemonWorkspaceEventSignalsContext);
}
export function useDaemonPromptStatus(): DaemonPromptStatus {
const promptStatus = useContext(DaemonPromptStatusContext);
if (!promptStatus) {
throw new Error(
'useDaemonPromptStatus must be used within DaemonSessionProvider',
);
}
return promptStatus;
}
export function useDaemonConnection(): DaemonConnectionState {
const connection = useContext(DaemonConnectionContext);
if (!connection) {
throw new Error(
'useDaemonConnection must be used within DaemonSessionProvider',
);
}
return connection;
}
export function useDaemonSessionNotices(): {
notices: readonly DaemonSessionNotice[];
dismissNotice(id: string): void;
clearNotices(): void;
} {
const value = useContext(DaemonSessionNoticesContext);
if (!value) {
throw new Error(
'useDaemonSessionNotices must be used within DaemonSessionProvider',
);
}
return value;
}
function hasActiveGenerationSignal(
events: ReadonlyArray<{ type: string }>,
): boolean {
return events.some(
(event) =>
event.type === 'assistant.text.delta' ||
event.type === 'thought.text.delta' ||
event.type === 'tool.update',
);
}
function normalizeGoalStatusEvent(event: DaemonEvent): DaemonUiEvent | null {
if (event.type !== 'session_update') return null;
const data = isRecord(event.data) ? event.data : undefined;
const update = isRecord(data?.['update'])
? data['update']
: isRecord(event.data)
? event.data
: undefined;
if (!update || update['sessionUpdate'] !== 'agent_message_chunk') {
return null;
}
const meta = update['_meta'];
if (!isRecord(meta)) return null;
const status = normalizeGoalStatus(meta['goalStatus']);
if (status) {
return createGoalStatusUiEvent(event, status);
}
const terminal = normalizeGoalTerminal(meta['goalTerminal']);
if (terminal) {
return createGoalStatusUiEvent(event, terminal);
}
const loop = meta['stopHookLoop'];
if (!isRecord(loop)) return null;
const goal = loop['goal'];
if (!isRecord(goal)) return null;
const condition = getString(goal, 'condition');
if (!condition) return null;
// Suppress per-iteration "checking" events from the transcript to avoid
// flooding with one card per stop-hook turn. The active goal state is
// already visible in the status bar; only terminal events and the initial
// "set" event are shown as transcript cards.
return null;
}
function createGoalStatusUiEvent(
event: DaemonEvent,
status: Record<string, unknown>,
): DaemonUiEvent {
return {
type: 'status',
...(event.id !== undefined ? { eventId: event.id } : {}),
...(event.originatorClientId
? { originatorClientId: event.originatorClientId }
: {}),
text: '',
source: 'goal',
data: status,
};
}
function normalizeGoalStatus(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const kind = getString(value, 'kind');
if (
kind !== 'set' &&
kind !== 'cleared' &&
kind !== 'achieved' &&
kind !== 'failed' &&
kind !== 'aborted'
) {
return null;
}
const condition = getString(value, 'condition');
if (!condition) return null;
const iterations = getNumber(value, 'iterations');
const durationMs = getNumber(value, 'durationMs');
const setAt = getNumber(value, 'setAt');
const lastReason = getString(value, 'lastReason');
return {
kind,
condition,
...(iterations !== undefined ? { iterations } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(setAt !== undefined ? { setAt } : {}),
...(lastReason ? { lastReason } : {}),
};
}
function normalizeGoalTerminal(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const kind = getString(value, 'kind');
if (kind !== 'achieved' && kind !== 'failed' && kind !== 'aborted') {
return null;
}
const condition = getString(value, 'condition');
if (!condition) return null;
const iterations = getNumber(value, 'iterations');
const durationMs = getNumber(value, 'durationMs');
const lastReason = getString(value, 'lastReason');
return {
kind,
condition,
...(iterations !== undefined ? { iterations } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(lastReason ? { lastReason } : {}),
};
}
function getString(
value: Record<string, unknown>,
key: string,
): string | undefined {
const raw = value[key];
return typeof raw === 'string' ? raw : undefined;
}
function getNumber(
value: Record<string, unknown>,
key: string,
): number | undefined {
const raw = value[key];
return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
}
function bumpWorkspaceEventSignals(
events: readonly DaemonUiEvent[],
setSignals: Dispatch<SetStateAction<DaemonWorkspaceEventSignals>>,
): void {
let memory = 0;
let agents = 0;
let tools = 0;
let settings = 0;
let mcp = 0;
let extensions = 0;
let lastExtensionChange:
| DaemonWorkspaceEventSignals['lastExtensionChange']
| undefined;
let init = 0;
let auth = 0;
for (const event of events) {
switch (event.type) {
case 'workspace.memory.changed':
memory += 1;
break;
case 'workspace.agent.changed':
agents += 1;
break;
case 'workspace.tool.toggled':
tools += 1;
break;
case 'workspace.settings.changed':
settings += 1;
break;
case 'workspace.mcp.budget_warning':
case 'workspace.mcp.child_refused':
case 'workspace.mcp.server_restarted':
case 'workspace.mcp.server_restart_refused':
mcp += 1;
break;
case 'workspace.extensions.changed':
extensions += 1;
lastExtensionChange = {
...(event.status ? { status: event.status } : {}),
...(event.source ? { source: event.source } : {}),
...(event.name ? { name: event.name } : {}),
...(event.version ? { version: event.version } : {}),
...(event.error ? { error: event.error } : {}),
refreshed: event.refreshed,
failed: event.failed,
};
break;
case 'workspace.initialized':
init += 1;
break;
case 'auth.device_flow.started':
case 'auth.device_flow.throttled':
case 'auth.device_flow.authorized':
case 'auth.device_flow.failed':
case 'auth.device_flow.cancelled':
auth += 1;
break;
default:
break;
}
}
if (memory + agents + tools + settings + mcp + extensions + init + auth === 0)
return;
setSignals((current) => ({
memoryVersion: current.memoryVersion + memory,
agentsVersion: current.agentsVersion + agents,
toolsVersion: current.toolsVersion + tools,
settingsVersion: current.settingsVersion + settings,
mcpVersion: current.mcpVersion + mcp,
extensionsVersion: current.extensionsVersion + extensions,
...(lastExtensionChange ? { lastExtensionChange } : {}),
initVersion: current.initVersion + init,
authVersion: current.authVersion + auth,
}));
}
function isTerminalSessionHttpError(error: unknown): boolean {
const status = extractHttpStatus(error);
return status !== undefined && TERMINAL_SESSION_HTTP_STATUSES.has(status);
}
function isAuthFailureHttpError(error: unknown): boolean {
const status = extractHttpStatus(error);
return status !== undefined && AUTH_FAILURE_HTTP_STATUSES.has(status);
}
function extractHttpStatus(error: unknown): number | undefined {
if (error instanceof DaemonHttpError) return error.status;
if (isRecord(error) && typeof error['status'] === 'number') {
return error['status'];
}
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}