diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index ddcb0fc3..5c70ee79 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -28,6 +28,7 @@ import { QuestionCard } from './QuestionCard';
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
+import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
@@ -375,6 +376,7 @@ const ChatViewport = React.memo(({
)}
+
diff --git a/packages/ui/src/components/chat/SessionErrorNotice.tsx b/packages/ui/src/components/chat/SessionErrorNotice.tsx
new file mode 100644
index 00000000..3fb6c701
--- /dev/null
+++ b/packages/ui/src/components/chat/SessionErrorNotice.tsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { useI18n } from '@/lib/i18n';
+import { useLatestSessionError } from '@/sync/notification-store';
+import { useDirectoryStore, useSessionStatus } from '@/sync/sync-context';
+
+interface SessionErrorNoticeProps {
+ sessionId: string;
+ directory?: string;
+}
+
+// How long a user message may sit unanswered on an idle session before the
+// notice calls it a reply that never began.
+const UNANSWERED_AFTER_MS = 5_000;
+
+type LastMessageState = {
+ role: string;
+ timestamp: number;
+ hasError: boolean;
+} | null;
+
+// The last message of a session, with whether it already carries an error of
+// its own: an assistant message that OpenCode marked failed renders its error
+// inline, so the session-level notice must not repeat it.
+const useLastMessageState = (sessionId: string, directory?: string): LastMessageState => {
+ const store = useDirectoryStore(directory);
+ const cacheRef = React.useRef(null);
+ const getSnapshot = React.useCallback((): LastMessageState => {
+ if (!sessionId) return null;
+ const messages = store.getState().message[sessionId];
+ const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
+ // SAFETY: store messages are SDK `Message` records; `error` is the optional
+ // assistant-message error the SDK types carry, read here only for presence.
+ const info = last as { role?: string; time?: { completed?: number; created?: number }; error?: unknown } | null;
+ if (!info) {
+ cacheRef.current = null;
+ return null;
+ }
+ const next: LastMessageState = {
+ role: typeof info.role === 'string' ? info.role : '',
+ timestamp: info.time?.completed ?? info.time?.created ?? 0,
+ hasError: Boolean(info.error),
+ };
+ const cached = cacheRef.current;
+ if (cached && cached.role === next.role && cached.timestamp === next.timestamp && cached.hasError === next.hasError) {
+ return cached;
+ }
+ cacheRef.current = next;
+ return next;
+ }, [sessionId, store]);
+ const subscribe = React.useCallback((notify: () => void) => {
+ if (!sessionId) return () => undefined;
+ return store.subscribe(notify);
+ }, [sessionId, store]);
+ return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+};
+
+/**
+ * Shows what OpenCode reported when it stopped a turn without producing a
+ * reply. Rendered under the last message, only while that turn is the latest
+ * one: sending again moves the last message past the error and hides it.
+ */
+export const SessionErrorNotice: React.FC = ({ sessionId, directory }) => {
+ const { t } = useI18n();
+ const latestError = useLatestSessionError(sessionId);
+ const status = useSessionStatus(sessionId, directory);
+ const lastMessage = useLastMessageState(sessionId, directory);
+
+ const isIdle = !status || status.type === 'idle';
+ const reportedError = latestError && isIdle
+ && (!lastMessage || latestError.time >= lastMessage.timestamp)
+ && !(lastMessage?.role === 'assistant' && lastMessage.hasError)
+ ? latestError
+ : null;
+ // A user message that the session is idle on, with nothing after it for a
+ // while, is a reply that never began: the send was accepted but OpenCode
+ // produced neither a message nor an error for it.
+ const unansweredSince = !reportedError && isIdle && lastMessage?.role === 'user' ? lastMessage.timestamp : null;
+ const [now, setNow] = React.useState(() => Date.now());
+ React.useEffect(() => {
+ if (unansweredSince === null) return undefined;
+ const remaining = UNANSWERED_AFTER_MS - (Date.now() - unansweredSince);
+ if (remaining <= 0) return undefined;
+ const timer = window.setTimeout(() => setNow(Date.now()), remaining + 50);
+ return () => window.clearTimeout(timer);
+ }, [unansweredSince]);
+ const unanswered = unansweredSince !== null && Math.max(now, Date.now()) - unansweredSince >= UNANSWERED_AFTER_MS;
+
+ if (!reportedError && !unanswered) return null;
+
+ const detail = reportedError
+ ? (reportedError.error?.message ?? t('chat.sessionError.noDetails'))
+ : t('chat.sessionError.noDetails');
+ const name = reportedError?.error?.name;
+
+ return (
+
+
+
+
+
+
{reportedError ? t('chat.sessionError.title') : t('chat.sessionError.noReply')}
+
{name ? `${name}: ${detail}` : detail}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts
index a455d61c..33e79485 100644
--- a/packages/ui/src/lib/debug.ts
+++ b/packages/ui/src/lib/debug.ts
@@ -13,6 +13,8 @@ import {
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
+import { getRecentSessionErrors } from '@/sync/session-error-log';
+import { buildOpenCodeStatusReport } from '@/lib/openCodeStatus';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -386,6 +388,9 @@ export const debugUtils = {
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
+ // Same reasoning: empty means OpenCode reported no failed turn in this
+ // app session.
+ recentSessionErrors: getRecentSessionErrors(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
@@ -395,6 +400,16 @@ export const debugUtils = {
return report;
},
+ /**
+ * The same text the status report dialog (Ctrl/Cmd+Shift+L) shows, for a
+ * console or remote session that cannot press the shortcut.
+ */
+ async statusReport() {
+ const text = await buildOpenCodeStatusReport();
+ console.log(text);
+ return text;
+ },
+
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index 542f082f..cc6f59a1 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -1553,6 +1553,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminalpanel ({shortcut})',
'chat.recap.aria': 'Sitzungs-Zusammenfassung',
'chat.recap.label': 'Zusammenfassung:',
+ 'chat.sessionError.title': 'OpenCode hat diese Antwort abgebrochen',
+ 'chat.sessionError.noDetails': 'OpenCode hat keine Details gemeldet. Öffne den Statusbericht (Strg/Cmd+Umschalt+L), um die letzten Fehler zu sehen.',
+ 'chat.sessionError.noReply': 'OpenCode hat keine Antwort auf diese Nachricht begonnen.',
'chat.goal.dialog.titleCreate': 'Sitzungsziel festlegen',
'chat.goal.dialog.titleManage': 'Sitzungsziel',
'chat.goal.dialog.objectiveLabel': 'Ziel',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index c693df08..506a7a2c 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -1750,6 +1750,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
'chat.recap.aria': 'Session recap',
'chat.recap.label': 'Recap:',
+ 'chat.sessionError.title': 'OpenCode stopped this reply',
+ 'chat.sessionError.noDetails': 'OpenCode reported no details. Open the status report (Ctrl/Cmd+Shift+L) to see recent errors.',
+ 'chat.sessionError.noReply': 'OpenCode did not start a reply to this message.',
'chat.goal.dialog.titleCreate': 'Set Session Goal',
'chat.goal.dialog.titleManage': 'Session Goal',
'chat.goal.dialog.objectiveLabel': 'Objective',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 38173466..c5175e3d 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -1728,6 +1728,9 @@ export const dict: Record = {
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
"chat.recap.aria": "Resumen de la sesión",
"chat.recap.label": "Resumen:",
+ "chat.sessionError.title": "OpenCode detuvo esta respuesta",
+ "chat.sessionError.noDetails": "OpenCode no informó detalles. Abre el informe de estado (Ctrl/Cmd+Mayús+L) para ver los errores recientes.",
+ "chat.sessionError.noReply": "OpenCode no comenzó una respuesta a este mensaje.",
"chat.goal.dialog.titleCreate": "Definir objetivo de sesión",
"chat.goal.dialog.titleManage": "Objetivo de sesión",
"chat.goal.dialog.objectiveLabel": "Objetivo",
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 72c07450..4575aa2b 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -1507,6 +1507,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})',
'chat.recap.aria': 'Récapitulatif de la session',
'chat.recap.label': 'Récap :',
+ 'chat.sessionError.title': 'OpenCode a interrompu cette réponse',
+ 'chat.sessionError.noDetails': 'OpenCode n\'a fourni aucun détail. Ouvrez le rapport d\'état (Ctrl/Cmd+Maj+L) pour voir les erreurs récentes.',
+ 'chat.sessionError.noReply': 'OpenCode n\'a pas commencé de réponse à ce message.',
'chat.goal.dialog.titleCreate': 'Définir un objectif de session',
'chat.goal.dialog.titleManage': 'Objectif de session',
'chat.goal.dialog.objectiveLabel': 'Objectif',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 3a7fb45a..9a666ec0 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -1746,6 +1746,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})',
'chat.recap.aria': 'セッションの要約',
'chat.recap.label': '要約:',
+ 'chat.sessionError.title': 'OpenCode がこの返答を停止しました',
+ 'chat.sessionError.noDetails': 'OpenCode から詳細は報告されませんでした。ステータスレポート(Ctrl/Cmd+Shift+L)で最近のエラーを確認してください。',
+ 'chat.sessionError.noReply': 'OpenCode はこのメッセージへの返答を開始しませんでした。',
'chat.goal.dialog.titleCreate': 'セッションゴールを設定',
'chat.goal.dialog.titleManage': 'セッションゴール',
'chat.goal.dialog.objectiveLabel': '目標',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 43c1c4e4..786dd155 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -1752,6 +1752,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})',
'chat.recap.aria': '세션 요약',
'chat.recap.label': '요약:',
+ 'chat.sessionError.title': 'OpenCode가 이 응답을 중단했습니다',
+ 'chat.sessionError.noDetails': 'OpenCode가 세부 정보를 보고하지 않았습니다. 상태 보고서(Ctrl/Cmd+Shift+L)에서 최근 오류를 확인하세요.',
+ 'chat.sessionError.noReply': 'OpenCode가 이 메시지에 대한 응답을 시작하지 않았습니다.',
'chat.goal.dialog.titleCreate': '세션 목표 설정',
'chat.goal.dialog.titleManage': '세션 목표',
'chat.goal.dialog.objectiveLabel': '목표',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index c299e3b3..5e6ee9f6 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -2435,6 +2435,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})',
'chat.recap.aria': 'Podsumowanie sesji',
'chat.recap.label': 'Podsumowanie:',
+ 'chat.sessionError.title': 'OpenCode przerwał tę odpowiedź',
+ 'chat.sessionError.noDetails': 'OpenCode nie podał szczegółów. Otwórz raport stanu (Ctrl/Cmd+Shift+L), aby zobaczyć ostatnie błędy.',
+ 'chat.sessionError.noReply': 'OpenCode nie rozpoczął odpowiedzi na tę wiadomość.',
'chat.goal.dialog.titleCreate': 'Ustaw cel sesji',
'chat.goal.dialog.titleManage': 'Cel sesji',
'chat.goal.dialog.objectiveLabel': 'Cel',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 9674b941..581f7450 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -1728,6 +1728,9 @@ export const dict: Record = {
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
"chat.recap.aria": "Resumo da sessão",
"chat.recap.label": "Resumo:",
+ "chat.sessionError.title": "O OpenCode interrompeu esta resposta",
+ "chat.sessionError.noDetails": "O OpenCode não informou detalhes. Abra o relatório de status (Ctrl/Cmd+Shift+L) para ver os erros recentes.",
+ "chat.sessionError.noReply": "O OpenCode não iniciou uma resposta a esta mensagem.",
"chat.goal.dialog.titleCreate": "Definir objetivo da sessão",
"chat.goal.dialog.titleManage": "Objetivo da sessão",
"chat.goal.dialog.objectiveLabel": "Objetivo",
diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts
index f51e8839..6812353d 100644
--- a/packages/ui/src/lib/i18n/messages/tr.ts
+++ b/packages/ui/src/lib/i18n/messages/tr.ts
@@ -1712,6 +1712,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal paneli ({shortcut})',
'chat.recap.aria': 'Session özeti',
'chat.recap.label': 'Özet:',
+ 'chat.sessionError.title': 'OpenCode bu yanıtı durdurdu',
+ 'chat.sessionError.noDetails': 'OpenCode ayrıntı bildirmedi. Son hataları görmek için durum raporunu açın (Ctrl/Cmd+Shift+L).',
+ 'chat.sessionError.noReply': 'OpenCode bu mesaja yanıt vermeye başlamadı.',
'chat.goal.dialog.titleCreate': 'Session hedefi belirle',
'chat.goal.dialog.titleManage': 'Session hedefi',
'chat.goal.dialog.objectiveLabel': 'Amaç',
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index 40e3ba5e..d11b878b 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -1728,6 +1728,9 @@ export const dict: Record = {
"header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})",
"chat.recap.aria": "Підсумок сесії",
"chat.recap.label": "Підсумок:",
+ "chat.sessionError.title": "OpenCode зупинив цю відповідь",
+ "chat.sessionError.noDetails": "OpenCode не повідомив деталей. Відкрий звіт про стан (Ctrl/Cmd+Shift+L), щоб побачити останні помилки.",
+ "chat.sessionError.noReply": "OpenCode не почав відповідь на це повідомлення.",
"chat.goal.dialog.titleCreate": "Встановити ціль сесії",
"chat.goal.dialog.titleManage": "Ціль сесії",
"chat.goal.dialog.objectiveLabel": "Ціль",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 89168582..184213b0 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -1716,6 +1716,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': '终端面板({shortcut})',
'chat.recap.aria': '会话回顾',
'chat.recap.label': '回顾:',
+ 'chat.sessionError.title': 'OpenCode 停止了本次回复',
+ 'chat.sessionError.noDetails': 'OpenCode 未报告任何详情。打开状态报告(Ctrl/Cmd+Shift+L)查看最近的错误。',
+ 'chat.sessionError.noReply': 'OpenCode 没有开始回复这条消息。',
'chat.goal.dialog.titleCreate': '设置会话目标',
'chat.goal.dialog.titleManage': '会话目标',
'chat.goal.dialog.objectiveLabel': '目标',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 3b495f1e..2f6e5d7d 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -1720,6 +1720,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut})',
'chat.recap.aria': '工作階段回顧',
'chat.recap.label': '回顧:',
+ 'chat.sessionError.title': 'OpenCode 停止了本次回覆',
+ 'chat.sessionError.noDetails': 'OpenCode 未回報任何詳情。開啟狀態報告(Ctrl/Cmd+Shift+L)查看最近的錯誤。',
+ 'chat.sessionError.noReply': 'OpenCode 沒有開始回覆這則訊息。',
'chat.goal.dialog.titleCreate': '設定工作階段目標',
'chat.goal.dialog.titleManage': '工作階段目標',
'chat.goal.dialog.objectiveLabel': '目標',
diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts
index 31c7578b..b3d393de 100644
--- a/packages/ui/src/lib/openCodeStatus.ts
+++ b/packages/ui/src/lib/openCodeStatus.ts
@@ -4,6 +4,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRuntimeUrlResolver } from './runtime-url';
import { opencodeClient } from './opencode/client';
import { runtimeFetch } from './runtime-fetch';
+import { getRecentSendFailures } from '@/sync/send-failure-log';
+import { getRecentSessionErrors } from '@/sync/session-error-log';
declare const __APP_VERSION__: string | undefined;
@@ -21,6 +23,8 @@ type OpenChamberHealthSnapshot = {
openCodeAuthSource?: unknown;
isOpenCodeReady?: unknown;
lastOpenCodeError?: unknown;
+ lastOpenCodeHealthFailure?: unknown;
+ lastManagedOpenCodeProcess?: unknown;
lastOpenCodeLaunchDiagnostics?: unknown;
opencodeBinaryResolved?: unknown;
opencodeBinarySource?: unknown;
@@ -128,6 +132,15 @@ const normalizePort = (value: unknown): number | null => {
const isRecord = (value: unknown): value is Record =>
!!value && typeof value === 'object' && !Array.isArray(value);
+const STDERR_TAIL_LINES = 12;
+const RECENT_RECORD_LINES = 8;
+
+const joinPath = (base: string, relative: string, windows: boolean): string => {
+ const separator = windows ? '\\' : '/';
+ const trimmed = base.replace(/[\\/]+$/, '');
+ return `${trimmed}${separator}${windows ? relative.replace(/\//g, '\\') : relative}`;
+};
+
const formatUnknown = (value: unknown, fallback = '(n/a)'): string => {
if (typeof value === 'string') return value.trim() || fallback;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
@@ -148,7 +161,7 @@ const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): st
return 'direct executable';
};
-const buildOpenCodeStatusReport = async (): Promise => {
+export const buildOpenCodeStatusReport = async (): Promise => {
const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)';
@@ -159,6 +172,7 @@ const buildOpenCodeStatusReport = async (): Promise => {
const healthUrl = urls.health();
const apiBase = urls.api('/api/');
+
const openChamberHealth: OpenChamberHealthSnapshot | null = await (async () => {
if (!healthUrl) return null;
const controller = new AbortController();
@@ -227,15 +241,36 @@ const buildOpenCodeStatusReport = async (): Promise => {
const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => {
if (!apiBase) return null;
- const url = new URL(pathname.replace(/^\/+/, ''), apiBase);
+ // A web runtime resolves its API base relative to the page; a relative
+ // base is not a valid URL base on its own.
+ const absoluteBase = /^[a-z][a-z0-9+.-]*:/i.test(apiBase) || !origin ? apiBase : new URL(apiBase, origin).toString();
+ const url = new URL(pathname.replace(/^\/+/, ''), absoluteBase);
if (includeDirectory && directory) {
url.searchParams.set('directory', directory);
}
return url.toString();
};
+ // OpenCode's own view of its directories; `home` anchors the log path below.
+ const pathInfo: { home?: unknown } | null = await (async () => {
+ const url = buildProbeUrl('/path', true);
+ if (!url) return null;
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 5000);
+ try {
+ const resp = await runtimeFetch(url, { signal: controller.signal, cache: 'no-store' });
+ if (!resp.ok) return null;
+ const json = (await resp.json().catch(() => null)) as unknown;
+ return isRecord(json) ? json : null;
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timeout);
+ }
+ })();
+
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
- { label: 'health', path: '/health', includeDirectory: false },
+ { label: 'health', path: '/global/health', includeDirectory: false },
{ label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true },
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
@@ -278,6 +313,64 @@ const buildOpenCodeStatusReport = async (): Promise => {
lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`);
}
+ // What the managed OpenCode process last said for itself. A turn that stops
+ // with nothing on screen usually left its reason here or in the session
+ // errors below, not in the UI.
+ const lastOpenCodeError = formatUnknown(openChamberHealth?.lastOpenCodeError, '');
+ const managedProcess = isRecord(openChamberHealth?.lastManagedOpenCodeProcess)
+ ? openChamberHealth.lastManagedOpenCodeProcess
+ : null;
+ const stderrTail = managedProcess && typeof managedProcess.stderrTail === 'string'
+ ? managedProcess.stderrTail.trim()
+ : '';
+ if (lastOpenCodeError || managedProcess) {
+ lines.push('');
+ lines.push('OpenCode process:');
+ if (lastOpenCodeError) lines.push(`- last error: ${lastOpenCodeError}`);
+ if (managedProcess) {
+ lines.push(`- pid: ${formatUnknown(managedProcess.pid, '(none)')} exit=${formatUnknown(managedProcess.exitCode, '(running)')} signal=${formatUnknown(managedProcess.signalCode, '(none)')}`);
+ }
+ if (stderrTail) {
+ const tailLines = stderrTail.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-STDERR_TAIL_LINES);
+ lines.push(`- stderr (last ${tailLines.length} lines):`);
+ for (const line of tailLines) lines.push(` ${line.slice(0, 300)}`);
+ }
+ }
+
+ const sessionErrors = getRecentSessionErrors();
+ lines.push('');
+ lines.push(`Recent OpenCode session errors: ${sessionErrors.length === 0 ? '(none this app session)' : ''}`.trimEnd());
+ for (const record of sessionErrors.slice(0, RECENT_RECORD_LINES)) {
+ const detail = record.message ?? '(no message)';
+ lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} ${record.name ? `${record.name}: ` : ''}${detail}`);
+ }
+
+ const sendFailures = getRecentSendFailures();
+ lines.push('');
+ lines.push(`Recent rejected sends: ${sendFailures.length === 0 ? '(none this app session)' : ''}`.trimEnd());
+ for (const record of sendFailures.slice(0, RECENT_RECORD_LINES)) {
+ lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} status=${record.status ?? 'transport'}${record.ambiguous ? ' ambiguous' : ''} ${record.reason}`);
+ }
+
+ // Where to look next. OpenCode keeps its own log under the XDG data
+ // directory (the same default on every platform, which is why Windows users
+ // do not find it under AppData); the desktop app writes the server console,
+ // including OpenCode lifecycle lines, through electron-log.
+ const opencodeHome = typeof pathInfo?.home === 'string' ? pathInfo.home : '';
+ const isWindows = /Windows NT/.test(platform);
+ const isDesktop = origin.startsWith('openchamber-ui://');
+ lines.push('');
+ lines.push('Log files:');
+ lines.push(`- OpenCode: ${opencodeHome ? joinPath(opencodeHome, '.local/share/opencode/log', isWindows) : '/.local/share/opencode/log'} (or $XDG_DATA_HOME/opencode/log when set)`);
+ if (isDesktop) {
+ const isMacDesktop = /Mac OS X|Macintosh/.test(platform);
+ lines.push(`- OpenChamber desktop: ${isWindows
+ ? '%APPDATA%\\OpenChamber\\logs\\main.log'
+ : isMacDesktop
+ ? '~/Library/Logs/OpenChamber/main.log'
+ : '~/.config/OpenChamber/logs/main.log'}`);
+ }
+
if (typeof window !== 'undefined') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md
index e7c863cb..fd9ffefe 100644
--- a/packages/ui/src/sync/DOCUMENTATION.md
+++ b/packages/ui/src/sync/DOCUMENTATION.md
@@ -202,6 +202,22 @@ Rules:
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
+## Failed-turn diagnostics
+
+A `session.error` event is the only account of a turn OpenCode stopped, and
+it can arrive with no assistant message to attach to. `session-error-log.ts`
+keeps the last 20 of them in memory (`recordSessionError`, fed from the
+event pipeline next to the error notification) and `summarizeOpenCodeError`
+reads the `{ name, data: { message } }` payload. The chat shows the newest
+error for the open session under its last message while that turn is the
+latest one (`SessionErrorNotice`), and also names a user message that an idle
+session has left unanswered for five seconds, since an accepted send that
+produced neither a message nor an error would otherwise look like nothing
+happened. Both buffers — session errors and rejected sends — appear in the
+status report (`buildOpenCodeStatusReport`, Ctrl/Cmd+Shift+L or
+`__opencodeDebug.statusReport()`) together with the managed OpenCode
+process's last error and stderr tail and the expected log file locations.
+
## Loading diagnostics
Session loading instrumentation is disabled by default. Set `localStorage.openchamber_session_load_perf` to `"1"`, reproduce the interaction, then inspect `window.__openchamberSessionLoadPerformance.events`.
diff --git a/packages/ui/src/sync/notification-store.ts b/packages/ui/src/sync/notification-store.ts
index 9f7d237e..d0c4b938 100644
--- a/packages/ui/src/sync/notification-store.ts
+++ b/packages/ui/src/sync/notification-store.ts
@@ -24,7 +24,8 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
- error?: { message?: string; code?: string }
+ /** What OpenCode reported for the failed turn; both null when it gave no details. */
+ error?: { name: string | null; message: string | null }
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -161,3 +162,15 @@ export function useSessionUnseenCount(sessionId: string): number {
return useNotificationStore((s) => s.index.session.unseenCount[sessionId] ?? 0)
}
+/** The newest error OpenCode reported for this session, viewed or not. */
+export function useLatestSessionError(sessionId: string): ErrorNotification | null {
+ return useNotificationStore((s) => {
+ if (!sessionId) return null
+ for (let index = s.list.length - 1; index >= 0; index -= 1) {
+ const notification = s.list[index]
+ if (notification.session === sessionId && notification.type === "error") return notification
+ }
+ return null
+ })
+}
+
diff --git a/packages/ui/src/sync/session-error-log.test.ts b/packages/ui/src/sync/session-error-log.test.ts
new file mode 100644
index 00000000..98961288
--- /dev/null
+++ b/packages/ui/src/sync/session-error-log.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from 'bun:test';
+import { getRecentSessionErrors, recordSessionError, summarizeOpenCodeError } from './session-error-log';
+
+describe('summarizeOpenCodeError', () => {
+ test('reads the OpenCode shape: name plus data.message', () => {
+ expect(summarizeOpenCodeError({ name: 'ProviderAuthError', data: { providerID: 'openai', message: 'Invalid API key' } }))
+ .toEqual({ name: 'ProviderAuthError', message: 'Invalid API key' });
+ });
+
+ test('falls back to a top-level message and reports missing details as null', () => {
+ expect(summarizeOpenCodeError({ message: 'socket hang up' })).toEqual({ name: null, message: 'socket hang up' });
+ expect(summarizeOpenCodeError({ name: 'UnknownError', data: { message: ' ' } })).toEqual({ name: 'UnknownError', message: null });
+ expect(summarizeOpenCodeError(undefined)).toEqual({ name: null, message: null });
+ });
+
+ test('bounds the message length', () => {
+ const summary = summarizeOpenCodeError({ name: 'UnknownError', data: { message: 'x'.repeat(1000) } });
+ expect(summary.message?.length).toBe(400);
+ });
+});
+
+describe('recordSessionError', () => {
+ test('keeps the newest records first and caps the buffer', () => {
+ for (let index = 0; index < 25; index += 1) {
+ recordSessionError({ sessionId: `ses_${index}`, directory: null, name: 'UnknownError', message: `error ${index}` });
+ }
+ const records = getRecentSessionErrors();
+ expect(records.length).toBe(20);
+ expect(records[0]?.sessionId).toBe('ses_24');
+ expect(records[19]?.sessionId).toBe('ses_5');
+ });
+});
diff --git a/packages/ui/src/sync/session-error-log.ts b/packages/ui/src/sync/session-error-log.ts
new file mode 100644
index 00000000..8d56488b
--- /dev/null
+++ b/packages/ui/src/sync/session-error-log.ts
@@ -0,0 +1,59 @@
+/**
+ * Recent OpenCode session errors, kept in memory for diagnostics.
+ *
+ * OpenCode reports a failed turn as a `session.error` event. The message it
+ * carries is the only account of what went wrong, and it may arrive without
+ * an assistant message to attach itself to, so a turn can end with nothing
+ * on screen. This buffer keeps the last errors until someone asks for them,
+ * via the status report (Ctrl/Cmd+Shift+L) or `__opencodeDebug`. In-memory
+ * only: never persisted, never sent anywhere, dropped on reload.
+ */
+
+import type { EventSessionError } from '@opencode-ai/sdk/v2'
+
+const MAX_RECORDED_SESSION_ERRORS = 20
+const MAX_MESSAGE_LENGTH = 400
+
+export type OpenCodeErrorSummary = {
+ name: string | null
+ message: string | null
+}
+
+export type SessionErrorRecord = OpenCodeErrorSummary & {
+ at: number
+ sessionId: string
+ directory: string | null
+}
+
+/**
+ * OpenCode error payloads are `{ name, data: { message, ... } }`; older or
+ * foreign shapes carry `message` at the top. Returns nulls for anything
+ * else so a caller can tell "no details" from a real message.
+ */
+export type OpenCodeSessionErrorPayload = EventSessionError['properties']['error']
+
+export function summarizeOpenCodeError(error: OpenCodeSessionErrorPayload | { message?: string } | null | undefined): OpenCodeErrorSummary {
+ if (!error || typeof error !== 'object') return { name: null, message: null }
+ // SAFETY: the SDK union is `{ name, data: { message } }` per variant; a
+ // top-level `message` covers foreign shapes. Every field is checked before use.
+ const record = error as { name?: unknown; message?: unknown; data?: { message?: unknown } }
+ const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : null
+ const dataMessage = typeof record.data?.message === 'string' ? record.data.message.trim() : ''
+ const topMessage = typeof record.message === 'string' ? record.message.trim() : ''
+ const message = dataMessage || topMessage || null
+ return { name, message: message ? message.slice(0, MAX_MESSAGE_LENGTH) : null }
+}
+
+const records: SessionErrorRecord[] = []
+
+export function recordSessionError(record: Omit): void {
+ records.push({ ...record, at: Date.now() })
+ if (records.length > MAX_RECORDED_SESSION_ERRORS) {
+ records.splice(0, records.length - MAX_RECORDED_SESSION_ERRORS)
+ }
+}
+
+/** Newest first. */
+export function getRecentSessionErrors(): SessionErrorRecord[] {
+ return [...records].reverse()
+}
diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx
index dea2fa4a..ed2772ed 100644
--- a/packages/ui/src/sync/sync-context.tsx
+++ b/packages/ui/src/sync/sync-context.tsx
@@ -53,6 +53,7 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
+import { recordSessionError, summarizeOpenCodeError, type OpenCodeSessionErrorPayload } from "./session-error-log"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
@@ -1771,8 +1772,12 @@ export function handleEvent(
// Notification dispatch for session turn-complete and error events.
// These are NOT handled by the event reducer — only the notification store.
if (payload.type === "session.idle" || payload.type === "session.error") {
- const props = payload.properties as { sessionID?: string; error?: { message?: string; code?: string } }
+ const props = payload.properties as { sessionID?: string; error?: OpenCodeSessionErrorPayload }
const sessionID = props.sessionID
+ const errorSummary = payload.type === "session.error" ? summarizeOpenCodeError(props.error) : null
+ if (errorSummary && sessionID) {
+ recordSessionError({ sessionId: sessionID, directory: resolvedDirectory ?? null, ...errorSummary })
+ }
// Skip subtask sessions — only top-level sessions generate notifications
const storeState = getDirectoryEventState(store, batch)
const session = storeState.session.find((s) => s.id === sessionID)
@@ -1784,8 +1789,8 @@ export function handleEvent(
session: sessionID,
time: Date.now(),
viewed: isViewedInCurrentSession(resolvedDirectory, sessionID),
- ...(payload.type === "session.error"
- ? { type: "error" as const, error: props.error }
+ ...(errorSummary
+ ? { type: "error" as const, error: errorSummary }
: { type: "turn-complete" as const }),
})
}